Entry.tsx

81.6 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { ProFeature, VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { detectHomograph, getDisplayDomain } from "../../shared/domain";6import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516// Compact "time since" label for the password-history list. Coarse on purpose —17// the user wants "roughly when", not a precise duration.18function timeSince(when: number): string {19  const secs = Math.max(0, Math.floor((Date.now() - when) / 1000));20  if (secs < 60) return "just now";21  const mins = Math.floor(secs / 60);22  if (mins < 60) return `${mins}m ago`;23  const hours = Math.floor(mins / 60);24  if (hours < 24) return `${hours}h ago`;25  const days = Math.floor(hours / 24);26  if (days < 30) return `${days}d ago`;27  const months = Math.floor(days / 30);28  if (months < 12) return `${months}mo ago`;29  return `${Math.floor(months / 12)}y ago`;30}3132interface EntryProps {33  entry: VaultEntry | null;34  /**35   * Current active tab's full URL (https only). Empty string when unavailable36   * — an extension page or a chrome:///about: URL. The URL editor uses this37   * to render an "add current site" shortcut.38   */39  currentTabUrl?: string;40  onDelete: () => void;41  /** Called after any inline save so App can reload entries */42  onFieldSaved: () => void | Promise<void>;43  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(217      props.entry.totp,218      props.entry.totpAlgorithm,219      props.entry.totpDigits,220      props.entry.totpPeriod,221    );222  });223224  let prevTotpCode: string | undefined;225  createEffect(() => {226    const state = totpState();227    if (!state) { prevTotpCode = undefined; return; }228    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {229      setTotpHot(true);230      window.setTimeout(() => setTotpHot(false), 3000);231    }232    prevTotpCode = state.code;233  });234235  const totpProgress = createMemo(() => {236    const state = totpState();237    if (!state) return 0;238    return ((state.remaining - 1) / 29) * 100;239  });240241  createEffect(() => {242    const shell = document.getElementById("hd-shell");243    if (!shell) return;244245    const state = totpState();246    if (!state) {247      delete shell.dataset.totpActive;248      shell.style.removeProperty("--peach-totp-progress");249      return;250    }251252    shell.dataset.totpActive = "true";253    shell.style.setProperty(254      "--peach-totp-progress",255      `${Math.max(0, totpProgress())}%`256    );257258    onCleanup(() => {259      delete shell.dataset.totpActive;260      shell.style.removeProperty("--peach-totp-progress");261    });262  });263264  const titleSizeClass = createMemo(() => {265    const length = props.entry?.title.length ?? 0;266    if (length >= 28) return "peach-entry-name--xxl";267    if (length >= 20) return "peach-entry-name--xl";268    if (length >= 14) return "peach-entry-name--lg";269    return "";270  });271  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);272273  // ── Inline editing ────────────────────────────────────────────────274275  function startEdit(field: EditableField, value: string) {276    setEditingField(field);277    setDraft(value);278    setIdentityHelperSuppressed(false);279    setShowGenerator(false);280    setShowHistory(false);281    setRevealedHistory(new Set<number>());282    setConfirmDelete(false);283    if (field === "url") {284      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);285    }286  }287288  // Compare URLs by string after trim — vault.ts has its own canonicalisation289  // when saving, so the dedupe here is a UX-level "don't show the button if290  // we'd just add a duplicate" rather than a security check.291  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {292    const c = candidate.trim();293    if (!c) return true;294    if (primary.trim() === c) return true;295    return others.some((u) => u.trim() === c);296  }297298  function addCurrentSite(url: string) {299    if (!url) return;300    if (!draft().trim()) {301      setDraft(url);302      return;303    }304    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;305    setAdditionalUrlsDraft((arr) => [...arr, url]);306  }307308  async function saveField() {309    const field = editingField();310    const entry = props.entry;311    if (!field || !entry) return;312    // Don't trim passwords — spaces may be intentional313    let value = field === "password" ? draft() : draft().trim();314    if (field === "totp" && value.startsWith("otpauth://")) {315      const parsed = parseOtpauthURI(value);316      value = parsed?.secret ?? value;317    }318    if (field === "password" && entry.type !== "note" && !value) {319      return;320    }321    setSaving(true);322    try {323      const patch: Partial<VaultEntry> = { [field]: value };324      if (field === "url") {325        // Sanitization layer in vault.ts trims, dedupes, and drops empties.326        patch.additionalUrls = additionalUrlsDraft();327      }328      await sendMessage({329        type: "UPDATE_ENTRY",330        payload: { id: entry.id, patch },331      });332      triggerRing();333      setEditingField(null);334      setShowGenerator(false);335      setShowHistory(false);336      setRevealedHistory(new Set<number>());337      await props.onFieldSaved();338    } catch {339      // silent — leave editing open340    } finally {341      setSaving(false);342    }343  }344345  function startEditTags() {346    setTagsDraft([...(props.entry?.tags ?? [])]);347    setTagInput("");348    setEditingTags(true);349  }350351  function addTag(value: string) {352    const n = value.toLowerCase().trim();353    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;354    setTagsDraft([...tagsDraft(), n]);355    setTagInput("");356  }357358  async function saveTags() {359    if (tagInput()) addTag(tagInput());360    const entry = props.entry;361    if (!entry) return;362    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;363    setEditingTags(false);364    setSaving(true);365    try {366      await sendMessage({367        type: "UPDATE_ENTRY",368        payload: { id: entry.id, patch: { tags: next } },369      });370      triggerRing();371      await props.onFieldSaved();372    } catch {373      // silent374    } finally {375      setSaving(false);376    }377  }378379  function cancelEdit() {380    setEditingField(null);381    setDraft("");382    setShowGenerator(false);383    setShowHistory(false);384    setRevealedHistory(new Set<number>());385  }386387  // Switch from one identity slot to the other.388  //389  // Migration source is the SAVED entry value, never the live draft — using390  // the draft caused data loss when the user typed or cleared the input391  // before clicking +add (the migration would skip and a later Save would392  // overwrite the original email-shape value with whatever was in the input).393  //394  // Drafts only get committed when they are non-empty AND actually changed,395  // so an empty input never accidentally clears a saved field.396  async function addOtherIdentity(target: "username" | "email") {397    const entry = props.entry;398    if (!entry) return;399    const current = editingField();400    const draftValue = draft().trim();401    const savedUsername = entry.username ?? "";402    const savedEmail = entry.email ?? "";403404    if (current === "username" && target === "username") {405      // Legacy fix-up: saved username is email-shaped and no separate email406      // exists yet. Move it to entry.email and free the username slot.407      if (savedUsername.includes("@") && !entry.email) {408        setSaving(true);409        try {410          await sendMessage({411            type: "UPDATE_ENTRY",412            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },413          });414          await props.onFieldSaved();415        } finally {416          setSaving(false);417        }418      }419      startEdit("username", "");420      return;421    }422423    if (current === "username" && target === "email") {424      if (draftValue && draftValue !== savedUsername) {425        setSaving(true);426        try {427          await sendMessage({428            type: "UPDATE_ENTRY",429            payload: { id: entry.id, patch: { username: draftValue } },430          });431          await props.onFieldSaved();432        } finally {433          setSaving(false);434        }435      }436      startEdit("email", "");437      return;438    }439440    if (current === "email" && target === "username") {441      if (draftValue && draftValue !== savedEmail) {442        setSaving(true);443        try {444          await sendMessage({445            type: "UPDATE_ENTRY",446            payload: { id: entry.id, patch: { email: draftValue } },447          });448          await props.onFieldSaved();449        } finally {450          setSaving(false);451        }452      }453      startEdit("username", "");454      return;455    }456  }457458  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {459    switch (field) {460      case "title":461        return entry.title;462      case "url":463        return entry.url ?? "";464      case "username":465        return entry.username ?? "";466      case "email":467        return entry.email ?? "";468      case "password":469        return entry.password ?? "";470      case "notes":471        return entry.notes ?? "";472      case "totp":473        return entry.totp ?? "";474    }475  }476477  function handleEditorFocusOut(478    field: Exclude<EditableField, null>,479    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }480  ) {481    const nextTarget = event.relatedTarget;482    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;483484    // addOtherIdentity / startEdit may have already switched editingField before485    // focusout fires (SolidJS removes the old editor node synchronously, which486    // triggers blur with relatedTarget=null). Don't cancel the new edit.487    if (editingField() !== field) return;488489    const entry = props.entry;490    if (!entry) return;491492    const currentValue = fieldValue(entry, field);493    const nextValue = field === "password" ? draft() : draft().trim();494    if (nextValue === currentValue) {495      cancelEdit();496    }497  }498499  // ── Copy ─────────────────────────────────────────────────────────500501  async function copyField(text: string, label: string, button?: HTMLButtonElement) {502    const ok = await popupCopy(text, label);503    if (ok) {504      const entry = props.entry;505      if (entry?.canary?.webhookUrl) {506        const trigger =507          label === "password"508            ? "password_copied"509            : label === "username"510              ? "username_copied"511              : undefined;512        if (trigger) {513          void sendMessage({514            type: "FIRE_CANARY_PING",515            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },516          });517        }518      }519      triggerRing();520      triggerPipPulse();521      setCopied(null);522      requestAnimationFrame(() => setCopied(label));523      if (button) {524        button.classList.remove("is-copied", "is-retreat");525        void button.offsetWidth;526        const drain = document.createElement("span");527        drain.className = "peach-copy-drain";528        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";529        drain.appendChild(bar);530        button.appendChild(drain);531        window.setTimeout(() => {532          button.classList.remove("is-copied");533          button.classList.add("is-retreat");534          drain.remove();535          window.setTimeout(() => button.classList.remove("is-retreat"), 160);536        }, 1800);537      }538      window.setTimeout(() => setCopied(null), 1800);539    }540  }541542  // ── Keyboard shortcuts ────────────────────────────────────────────543544  createEffect(() => {545    if (!props.entry) return;546    const handler = (e: KeyboardEvent) => {547      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;548      if (inInput) return;549      switch (e.key) {550        case "c": e.preventDefault();551          if (props.entry?.password) void copyField(props.entry.password, "password"); break;552        case "u": e.preventDefault();553          if (props.entry?.username) void copyField(props.entry.username, "username"); break;554        case "Escape": e.preventDefault(); props.onEscape?.(); break;555      }556    };557    window.addEventListener("keydown", handler);558    onCleanup(() => window.removeEventListener("keydown", handler));559  });560561  // ── Input ref focus helper ────────────────────────────────────────562563  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {564    requestAnimationFrame(() => { el.focus(); el.select?.(); });565  }566567  function onEditKeyDown(e: KeyboardEvent) {568    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {569      e.preventDefault();570      void saveField();571    }572    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }573  }574575  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {576    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;577    if (e.key === "ArrowDown" && listEl) {578      e.preventDefault();579      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();580      return;581    }582    onEditKeyDown(e);583  }584585  return (586    <>587      <div588        class="peach-ghost"589        aria-hidden="true"590        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;"591      >592        {props.entry?.title || "empty"}593      </div>594595      <div class="peach-stage" style="position:relative;">596        <Show when={props.entry} fallback={<EmptyVaultState />}>597          {(entry) => (598            <>599              {/* ── Breach strip ─────────────────────────────────── */}600              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>601                <div class="peach-breach-badge">602                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">603                    <path d="M6 1L11 10H1L6 1z" />604                    <line x1="6" y1="5" x2="6" y2="7.5" />605                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />606                  </svg>607                  <span style="flex:1">found in a known data breach</span>608                </div>609              </Show>610611              {/* ── Title + trash ─────────────────────────────────── */}612              <div class="peach-entry-header">613                <div class="peach-entry-header-name">614                  <Show615                    when={editingField() === "title"}616                    fallback={617                      <button618                        class="peach-entry-name-button"619                        title="Click to edit title"620                        onClick={() => startEdit("title", entry().title)}621                      >622                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>623                          <span class="peach-entry-name-text">624                            <Show625                              when={useAnimatedTitle()}626                              fallback={entry().title}627                            >628                              <For each={entry().title.split("")}>629                                {(ch, i) => (630                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>631                                    {ch === " " ? "\u00A0" : ch}632                                  </span>633                                )}634                              </For>635                            </Show>636                          </span>637                        </h1>638                      </button>639                    }640                  >641                    <div642                      class="peach-inline-title-wrap"643                      onFocusOut={(event) => handleEditorFocusOut("title", event)}644                    >645                      <input646                        class="peach-inline-title-input"647                        value={draft()}648                        onInput={(e) => setDraft(e.currentTarget.value)}649                        onKeyDown={onEditKeyDown}650                        ref={autoFocus}651                        disabled={saving()}652                      />653                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />654                    </div>655                  </Show>656                </div>657658                {/* Subtle trash icon — hidden while editing title */}659                <Show when={editingField() !== "title"}>660                  <Show661                    when={confirmDelete()}662                    fallback={663                      <button664                        class="peach-entry-trash-btn"665                        onClick={() => setConfirmDelete(true)}666                        aria-label="Move to trash"667                        title="Delete entry"668                      >669                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">670                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />671                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />672                        </svg>673                      </button>674                    }675                  >676                    <div class="peach-entry-trash-confirm">677                      <span class="peach-entry-trash-confirm-label">trash?</span>678                      <button679                        class="peach-btn peach-btn--sm peach-btn--danger"680                        style="font-size:10px;padding:2px 8px;min-height:22px;"681                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}682                      >683                        yes684                      </button>685                      <button686                        class="peach-btn peach-btn--ghost peach-btn--sm"687                        style="font-size:10px;padding:2px 8px;min-height:22px;"688                        onClick={() => setConfirmDelete(false)}689                      >690                        no691                      </button>692                    </div>693                  </Show>694                </Show>695              </div>696697              {/* ── Domain / URL ─────────────────────────────────── */}698              <Show when={entry().type !== "note" && entry().type !== "card"}>699                <div class="peach-domain" style="position:relative;z-index:1;">700                  <Show701                    when={editingField() === "url"}702                    fallback={703                      <div704                        class="peach-domain-row peach-inline-clickable"705                        title="Click to edit URL"706                        onClick={() => startEdit("url", entry().url ?? "")}707                      >708                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />709                        <span class="peach-domain-value">710                          {displayDomain() || (entry().url ? entry().url : "add a website url")}711                        </span>712                        <span class="peach-inline-edit-hint">edit</span>713                      </div>714                    }715                  >716                    <div717                      class="peach-inline-field-wrap peach-inline-field-wrap--url"718                      onFocusOut={(event) => handleEditorFocusOut("url", event)}719                    >720                      <div class="peach-inline-field-row">721                        <input722                          class="peach-inline-input"723                          type="url"724                          placeholder="https://"725                          value={draft()}726                          onInput={(e) => setDraft(e.currentTarget.value)}727                          onKeyDown={onEditKeyDown}728                          ref={autoFocus}729                          disabled={saving()}730                        />731                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />732                      </div>733                      <Show734                        when={735                          detectHomograph(draft()) ||736                          additionalUrlsDraft().some((u) => detectHomograph(u))737                        }738                      >739                        {/* UI-8 / LV-2: non-blocking homograph warning. */}740                        <p class="peach-homograph-warning">741                          ⚠ This URL contains non-ASCII or punycode characters.742                          Verify it&apos;s the site you intend.743                        </p>744                      </Show>745                      <div class="peach-additional-urls">746                        <Show when={additionalUrlsDraft().length > 0}>747                          <span class="peach-label peach-additional-urls-label">also matches</span>748                        </Show>749                        <For each={additionalUrlsDraft()}>750                          {(value, i) => (751                            <div class="peach-additional-url-row">752                              <input753                                class="peach-inline-input"754                                type="url"755                                placeholder="https://"756                                value={value}757                                onInput={(e) => setAdditionalUrlsDraft((arr) => {758                                  const next = [...arr];759                                  next[i()] = e.currentTarget.value;760                                  return next;761                                })}762                                disabled={saving()}763                              />764                              <button765                                class="peach-additional-url-remove"766                                type="button"767                                aria-label="Remove URL"768                                onMouseDown={(e) => e.preventDefault()}769                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}770                                disabled={saving()}771                              >×</button>772                            </div>773                          )}774                        </For>775                        <button776                          class="peach-add-field-link peach-additional-urls-add"777                          type="button"778                          onMouseDown={(e) => e.preventDefault()}779                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}780                          disabled={saving()}781                        >782                          + add another url783                        </button>784                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>785                          <button786                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"787                            type="button"788                            title={props.currentTabUrl}789                            onMouseDown={(e) => e.preventDefault()}790                            onClick={() => addCurrentSite(props.currentTabUrl!)}791                            disabled={saving()}792                          >793                            + add current site794                          </button>795                        </Show>796                      </div>797                    </div>798                  </Show>799                </div>800              </Show>801802              {/* ── Tags — read-only chips (editing state lives at bottom) */}803              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>804                <div805                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"806                  onClick={startEditTags}807                  title="Click to edit tags"808                >809                  <For each={entry().tags!}>810                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}811                  </For>812                </div>813              </Show>814815              {/* ── Note toolbar + inline editor ─────────────────── */}816              <Show when={entry().type === "note"}>817                <div class="peach-entry-note-toolbar-row">818                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />819                </div>820                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">821                  <NoteEditorInline822                    entryKey={entry().id}823                    initialContent={entry().notes ?? ""}824                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}825                    onBlur={saveNoteInline}826                    autoFocus={false}827                  />828                </section>829              </Show>830831              {/* ── Card detail view ─────────────────────────────── */}832              <Show when={entry().type === "card"}>833                <section class="peach-fields" style="position:relative;z-index:1;">834                  {/* Card subtitle: •••• last4 */}835                  <Show when={entry().last4}>836                    <div class="peach-field-card" style="padding-bottom:4px;">837                      <span class="peach-label">838                        <Show when={entry().network && entry().network !== "unknown"}>839                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>840                        </Show>841                        {entry().cardNickname || `•••• ${entry().last4}`}842                      </span>843                    </div>844                  </Show>845846                  {/* Cardholder name */}847                  <div class="peach-field-card">848                    <span class="peach-label">cardholder name</span>849                    <div class="peach-field-value-row">850                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>851                      <Show when={entry().cardholderName}>852                        <button853                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}854                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}855                          aria-label="Copy cardholder name"856                        >857                          <CopyGlyph /><span class="peach-copy-sheen" />858                        </button>859                      </Show>860                    </div>861                  </div>862863                  {/* Card number */}864                  <div class="peach-field-card">865                    <span class="peach-label">card number</span>866                    <div class="peach-field-value-row">867                      <span class={`peach-field-value peach-card-number`}>868                        <Show when={showCardNumber()} fallback={869                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>870                        }>871                          <span>{entry().cardNumber ?? ""}</span>872                        </Show>873                      </span>874                      <div class="peach-field-actions">875                        <button876                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"877                          onClick={() => setShowCardNumber(!showCardNumber())}878                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}879                        >880                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>881                            <EyeClosedGlyph />882                          </Show>883                        </button>884                        <Show when={entry().cardNumber}>885                          <button886                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}887                            onClick={(e) => {888                              const cardNum = entry().cardNumber!;889                              if (entry().canary?.webhookUrl) {890                                void sendMessage({891                                  type: "FIRE_CANARY_PING",892                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },893                                });894                              }895                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);896                            }}897                            aria-label="Copy card number"898                          >899                            <CopyGlyph /><span class="peach-copy-sheen" />900                          </button>901                        </Show>902                      </div>903                    </div>904                  </div>905906                  {/* Expiry */}907                  <Show when={entry().expMonth || entry().expYear}>908                    <div class="peach-field-card">909                      <span class="peach-label">expiry</span>910                      <div class="peach-field-value-row">911                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>912                        <button913                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}914                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}915                          aria-label="Copy expiry"916                        >917                          <CopyGlyph /><span class="peach-copy-sheen" />918                        </button>919                      </div>920                    </div>921                  </Show>922923                  {/* CVC */}924                  <Show when={entry().cvc}>925                    <div class="peach-field-card">926                      <span class="peach-label">CVC</span>927                      <div class="peach-field-value-row">928                        <span class="peach-field-value">929                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>930                            <span>{entry().cvc}</span>931                          </Show>932                        </span>933                        <div class="peach-field-actions">934                          <button935                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"936                            onClick={() => setShowCardCvc(!showCardCvc())}937                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}938                          >939                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>940                              <EyeClosedGlyph />941                            </Show>942                          </button>943                          <button944                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}945                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}946                            aria-label="Copy CVC"947                          >948                            <CopyGlyph /><span class="peach-copy-sheen" />949                          </button>950                        </div>951                      </div>952                    </div>953                  </Show>954                </section>955              </Show>956957              {/* ── Login fields ─────────────────────────────────── */}958              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>959                <section class="peach-fields" style="position:relative;z-index:1;">960961                  {/* Username — primary identity slot. Auto-labels as "email"962                      when the stored value is email-shaped and no separate963                      email field exists (legacy data path). */}964                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>965                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>966                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>967                      <Show968                        when={editingField() === "username"}969                        fallback={970                          <div class="peach-field-value-row">971                            <span972                              class="peach-field-value peach-inline-clickable"973                              title="Click to edit"974                              onClick={() => startEdit("username", entry().username ?? "")}975                            >976                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}977                            </span>978                            <Show when={entry().username}>979                              <button980                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}981                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}982                                aria-label="Copy username"983                              >984                                <CopyGlyph /><span class="peach-copy-sheen" />985                              </button>986                            </Show>987                          </div>988                        }989                      >990                        <div991                          class="peach-inline-field-wrap"992                          onFocusOut={(event) => handleEditorFocusOut("username", event)}993                        >994                          <input995                            class="peach-inline-input"996                            value={draft()}997                            onInput={(e) => setDraft(e.currentTarget.value)}998                            onKeyDown={(e) => identityKeyDown(e, "username")}999                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}1000                            disabled={saving()}1001                            autocomplete="off"1002                          />1003                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1004                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>1005                            <IdentitySuggestList1006                              listRef={(el) => (usernameIdentityEl = el)}1007                              inputRef={() => usernameInlineInput}1008                              suggestions={identitySuggestions()}1009                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1010                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1011                            />1012                          </Show>1013                          {/* +add the OTHER identity. If the slot is acting as1014                              email (value has @, no separate email), offer1015                              "+ add username" — clicking migrates the email1016                              into entry.email. Otherwise offer "+ add email". */}1017                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1018                            <button1019                              class="peach-add-field-btn"1020                              type="button"1021                              onMouseDown={(e) => e.preventDefault()}1022                              onClick={() => {1023                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1024                                const hasSeparateEmail = !!entry().email;1025                                if (isEmailShape && !hasSeparateEmail) {1026                                  void addOtherIdentity("username");1027                                } else {1028                                  void addOtherIdentity("email");1029                                }1030                              }}1031                            >1032                              {(() => {1033                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1034                                const hasSeparateEmail = !!entry().email;1035                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1036                              })()}1037                            </button>1038                          </Show>1039                        </div>1040                      </Show>1041                    </div>1042                  </Show>10431044                  {/* Email — secondary identity slot. Visible whenever a1045                      separate email field is set, or while it is being edited. */}1046                  <Show when={entry().email || editingField() === "email"}>1047                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1048                      <span class="peach-label">email</span>1049                      <Show1050                        when={editingField() === "email"}1051                        fallback={1052                          <div class="peach-field-value-row">1053                            <span1054                              class="peach-field-value peach-inline-clickable"1055                              title="Click to edit"1056                              onClick={() => startEdit("email", entry().email ?? "")}1057                            >1058                              {entry().email}1059                            </span>1060                            <Show when={entry().email}>1061                              <button1062                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1063                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1064                                aria-label="Copy email"1065                              >1066                                <CopyGlyph /><span class="peach-copy-sheen" />1067                              </button>1068                            </Show>1069                          </div>1070                        }1071                      >1072                        <div1073                          class="peach-inline-field-wrap"1074                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1075                        >1076                          <input1077                            class="peach-inline-input"1078                            value={draft()}1079                            onInput={(e) => setDraft(e.currentTarget.value)}1080                            onKeyDown={(e) => identityKeyDown(e, "email")}1081                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1082                            disabled={saving()}1083                            autocomplete="email"1084                            type="email"1085                          />1086                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1087                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1088                            <IdentitySuggestList1089                              listRef={(el) => (emailIdentityEl = el)}1090                              inputRef={() => emailInlineInput}1091                              suggestions={identitySuggestions()}1092                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1093                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1094                            />1095                          </Show>1096                          <Show when={!entry().username || entry().username?.includes("@")}>1097                            <button1098                              class="peach-add-field-btn"1099                              type="button"1100                              onMouseDown={(e) => e.preventDefault()}1101                              onClick={() => void addOtherIdentity("username")}1102                            >1103                              + add username1104                            </button>1105                          </Show>1106                        </div>1107                      </Show>1108                    </div>1109                  </Show>11101111                  {/* Password */}1112                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1113                    <span class="peach-label">password</span>1114                    <Show1115                      when={editingField() === "password"}1116                      fallback={1117                        <div class="peach-field-value-row">1118                          <span1119                            class="peach-password-value"1120                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1121                            style="cursor:pointer;"1122                            title="Click to edit password"1123                            onClick={() => startEdit("password", entry().password ?? "")}1124                          >1125                            <Show1126                              when={showPassword()}1127                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1128                            >1129                              <span class="peach-password-text">{entry().password ?? ""}</span>1130                            </Show>1131                          </span>1132                          <div class="peach-field-actions">1133                            <button1134                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1135                              onClick={() => setShowPassword(!showPassword())}1136                              aria-label="Reveal password"1137                            >1138                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1139                                <EyeClosedGlyph />1140                              </Show>1141                            </button>1142                            <button1143                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1144                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1145                              aria-label="Copy password"1146                            >1147                              <CopyGlyph /><span class="peach-copy-sheen" />1148                            </button>1149                          </div>1150                        </div>1151                      }1152                    >1153                      {/* ── Edit mode: type / paste / generate ── */}1154                      <div1155                        class="peach-inline-field-wrap"1156                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1157                      >1158                        <div class="peach-password-edit-row">1159                          <input1160                            class="peach-inline-input"1161                            type={showPassword() ? "text" : "password"}1162                            placeholder="paste or type a password"1163                            value={draft()}1164                            onInput={(e) => setDraft(e.currentTarget.value)}1165                            onKeyDown={(e) => {1166                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1167                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1168                            }}1169                            ref={autoFocus}1170                            disabled={saving()}1171                            autocomplete="new-password"1172                          />1173                          <button1174                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1175                            type="button"1176                            onClick={() => setShowPassword(!showPassword())}1177                            aria-label={showPassword() ? "Hide" : "Show"}1178                          >1179                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1180                              <EyeClosedGlyph />1181                            </Show>1182                          </button>1183                        </div>1184                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1185                        <div class="peach-password-edit-links">1186                          <button1187                            class="peach-generate-toggle-link"1188                            type="button"1189                            onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1190                          >1191                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1192                              <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1193                              <path d="M9.5 2.5H11V1" />1194                            </svg>1195                            {showGenerator() ? "hide generator" : "generate"}1196                          </button>1197                          <button1198                            class="peach-generate-toggle-link"1199                            type="button"1200                            onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1201                          >1202                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1203                              <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1204                              <path d="M6.5 3.5V6.5L8.5 8" />1205                              <path d="M1 1.5V4H3.5" />1206                            </svg>1207                            {showHistory()1208                              ? "hide history"1209                              : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1210                          </button>1211                        </div>1212                        <Show when={showGenerator()}>1213                          <GeneratorDrawer1214                            open={true}1215                            compact={true}1216                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1217                          />1218                        </Show>1219                        <Show when={showHistory()}>1220                          <div class="peach-password-history">1221                            <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1222                              <div class="peach-history-empty">1223                                No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1224                              </div>1225                            </Show>1226                            <For each={entry().passwordHistory ?? []}>1227                              {(item, i) => (1228                                <div class="peach-history-row">1229                                  <div class="peach-history-main">1230                                    <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1231                                      <Show1232                                        when={revealedHistory().has(i())}1233                                        fallback={<span class="peach-password-mask">••••••••••••</span>}1234                                      >1235                                        {item.password}1236                                      </Show>1237                                    </span>1238                                    <span class="peach-history-age">{timeSince(item.changedAt)}</span>1239                                  </div>1240                                  <div class="peach-history-actions">1241                                    <button1242                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1243                                      type="button"1244                                      title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1245                                      onClick={() => setRevealedHistory((prev) => {1246                                        const next = new Set(prev);1247                                        if (next.has(i())) next.delete(i()); else next.add(i());1248                                        return next;1249                                      })}1250                                    >1251                                      <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1252                                        <EyeClosedGlyph />1253                                      </Show>1254                                    </button>1255                                    <button1256                                      class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1257                                      type="button"1258                                      title="Copy"1259                                      onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1260                                    >1261                                      <CopyGlyph /><span class="peach-copy-sheen" />1262                                    </button>1263                                    <button1264                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1265                                      type="button"1266                                      title="Use this password again"1267                                      onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1268                                    >1269                                      use1270                                    </button>1271                                  </div>1272                                </div>1273                              )}1274                            </For>1275                          </div>1276                        </Show>1277                      </div>1278                    </Show>1279                  </div>12801281                </section>1282              </Show>12831284              {/* ── Notes (on login entries) ──────────────────────── */}1285              <Show when={entry().type !== "note" && entry().type !== "card"}>1286                <Show1287                  when={entry().notes || editingField() === "notes"}1288                  fallback={1289                    <button1290                      class="peach-add-field-link peach-entry-add-link"1291                      onClick={() => startEdit("notes", "")}1292                    >1293                      + notes1294                    </button>1295                  }1296                >1297                  <div style="position:relative;z-index:1;margin-top:6px;">1298                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1299                    <Show1300                      when={editingField() === "notes"}1301                      fallback={1302                        <div1303                          class="peach-note-content peach-inline-clickable"1304                          style="cursor:text;white-space:pre-wrap;"1305                          onClick={() => startEdit("notes", entry().notes ?? "")}1306                        >1307                          {entry().notes}1308                        </div>1309                      }1310                    >1311                      <div1312                        class="peach-inline-field-wrap"1313                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1314                      >1315                        <textarea1316                          class="peach-inline-textarea"1317                          value={draft()}1318                          onInput={(e) => setDraft(e.currentTarget.value)}1319                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1320                          ref={(el) => requestAnimationFrame(() => el?.focus())}1321                          rows={4}1322                          disabled={saving()}1323                        />1324                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1325                      </div>1326                    </Show>1327                  </div>1328                </Show>1329              </Show>13301331              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1332              <Show1333                when={editingTags()}1334                fallback={1335                  <Show when={!entry().tags || entry().tags!.length === 0}>1336                    <button1337                      class="peach-add-field-link peach-entry-add-link"1338                      onClick={startEditTags}1339                    >1340                      + tags1341                    </button>1342                  </Show>1343                }1344              >1345                <div1346                  class="peach-inline-tag-editor"1347                  onFocusOut={(e) => {1348                    const next = e.relatedTarget;1349                    if (next instanceof Node && e.currentTarget.contains(next)) return;1350                    void saveTags();1351                  }}1352                >1353                  <span class="peach-label">tags</span>1354                  <div class="peach-tag-input-wrap">1355                    <For each={tagsDraft()}>1356                      {(tag) => (1357                        <span class="peach-tag-chip">1358                          {tag}1359                          <button1360                            class="peach-tag-chip-remove"1361                            onMouseDown={(e) => e.preventDefault()}1362                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1363                            aria-label={`Remove ${tag}`}1364                          >×</button>1365                        </span>1366                      )}1367                    </For>1368                    <input1369                      class="peach-tag-input"1370                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1371                      value={tagInput()}1372                      onInput={(e) => setTagInput(e.currentTarget.value)}1373                      onKeyDown={(e) => {1374                        if (e.key === "Enter" || e.key === ",") {1375                          e.preventDefault();1376                          addTag(tagInput());1377                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1378                          setTagsDraft(tagsDraft().slice(0, -1));1379                        } else if (e.key === "Escape") {1380                          e.preventDefault();1381                          setEditingTags(false);1382                          setTagInput("");1383                        }1384                      }}1385                      ref={(el) => requestAnimationFrame(() => el?.focus())}1386                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1387                    />1388                  </div>1389                </div>1390              </Show>13911392              {/* ── TOTP ─────────────────────────────────────────── */}1393              <Show when={entry().type === "login"}>1394                <Show1395                  when={totpState() || editingField() === "totp"}1396                  fallback={1397                    <button1398                      class="peach-add-field-link peach-entry-add-link"1399                      onClick={() => startEdit("totp", "")}1400                    >1401                      + 2FA1402                    </button>1403                  }1404                >1405                  <section class="peach-totp-band">1406                    <Show1407                      when={editingField() === "totp"}1408                      fallback={1409                        <Show when={totpState()}>1410                          {(state) => (1411                            <>1412                              <div1413                                class="peach-totp-main"1414                                onClick={() => {1415                                  // Delay copy so a double-click can cancel it before1416                                  // it overwrites the clipboard the user wants to paste from.1417                                  if (totpClickTimer) return;1418                                  totpClickTimer = setTimeout(() => {1419                                    totpClickTimer = null;1420                                    void copyField(state().code.replace(" ", ""), "totp");1421                                  }, 220);1422                                }}1423                                onDblClick={() => {1424                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1425                                  startEdit("totp", entry().totp ?? "");1426                                }}1427                                title="Click to copy · double-click to edit"1428                              >1429                                <span class="peach-label">2-factor</span>1430                                <div class="peach-totp-code-row">1431                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1432                                    <span class="peach-totp-code-text">1433                                      {state().code.slice(0, 3)} {state().code.slice(3)}1434                                    </span>1435                                  </p>1436                                </div>1437                              </div>1438                              <div class="peach-totp-meta">1439                                <span class="peach-label">expires</span>1440                                <div class="peach-totp-countdown">{state().remaining}s</div>1441                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1442                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1443                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1444                                    stroke-dasharray="106.8"1445                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1446                                  />1447                                </svg>1448                              </div>1449                            </>1450                          )}1451                        </Show>1452                      }1453                    >1454                      <div1455                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1456                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1457                      >1458                        <span class="peach-label">2-factor</span>1459                        <input1460                          class="peach-inline-input"1461                          value={draft()}1462                          onInput={(e) => setDraft(e.currentTarget.value)}1463                          onKeyDown={onEditKeyDown}1464                          ref={autoFocus}1465                          disabled={saving()}1466                          placeholder="otpauth://... or base32 secret"1467                          autocomplete="off"1468                        />1469                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1470                      </div>1471                    </Show>1472                  </section>1473                </Show>1474              </Show>14751476              {/* ── Advanced (canary + passkey) ───────────────────── */}1477              <Show when={entry().type !== "note" && entry().type !== "card"}>1478                <button1479                  class="peach-add-field-link peach-entry-add-link"1480                  onClick={() => setShowAdvanced((v) => !v)}1481                  aria-expanded={showAdvanced()}1482                >1483                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1484                </button>1485                <Show when={showAdvanced()}>1486                  <Show when={!isPasskeyOnly(entry())}>1487                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1488                  </Show>14891490                  <Show when={entry().passkey}>1491                    {(passkey) => {1492                      const currentBrowser = detectBrowser();1493                      const crossBrowser =1494                        passkey().browser &&1495                        currentBrowser !== "unknown" &&1496                        passkey().browser !== currentBrowser;1497                      const algorithmLabel =1498                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1499                      const formatDate = (ts?: number) =>1500                        ts ? new Date(ts).toLocaleDateString() : "Never";1501                      return (1502                        <section1503                          class="peach-fields peach-passkey-section"1504                          style="position:relative;z-index:1;margin-top:6px;"1505                        >1506                          <div class="peach-field-card">1507                            <span class="peach-label">passkey · {passkey().rpId}</span>1508                            <div class="peach-field-value-row">1509                              <span class="peach-field-value">{passkey().userName}</span>1510                              <span class="peach-passkey-badge">passkey</span>1511                            </div>1512                          </div>15131514                          <div class="peach-field-card">1515                            <span class="peach-label">algorithm</span>1516                            <div class="peach-field-value-row">1517                              <span class="peach-field-value">{algorithmLabel}</span>1518                            </div>1519                          </div>15201521                          <div class="peach-field-card">1522                            <span class="peach-label">activity</span>1523                            <div class="peach-field-value-row peach-passkey-activity">1524                              <span class="peach-field-value">1525                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1526                              </span>1527                              <span class="peach-passkey-meta">1528                                created {formatDate(passkey().createdAt)}1529                              </span>1530                              <span class="peach-passkey-meta">1531                                last used {formatDate(passkey().lastUsedAt)}1532                              </span>1533                            </div>1534                          </div>15351536                          <Show when={crossBrowser}>1537                            <div class="peach-info-notice peach-info-notice--warn">1538                              {`This passkey was created on ${1539                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1540                              }. To use it on ${1541                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1542                              }, re-register it on this device.`}1543                            </div>1544                          </Show>15451546                          <div class="peach-info-notice">1547                            {passkey().browser === "firefox"1548                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1549                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1550                          </div>15511552                          <button1553                            class="peach-add-field-link peach-entry-add-link"1554                            style="color:rgba(220,38,38,0.7);"1555                            onClick={async () => {1556                              const id = entry().id;1557                              await sendMessage({1558                                type: "UPDATE_ENTRY",1559                                payload: { id, patch: { passkey: undefined } },1560                              });1561                              await props.onFieldSaved();1562                            }}1563                          >1564                            − remove passkey1565                          </button>1566                        </section>1567                      );1568                    }}1569                  </Show>1570                </Show>1571              </Show>15721573              {/* ── Bottom spacer ─────────────────────────────────── */}1574              <div style="height:4px;" />1575            </>1576          )}1577        </Show>1578      </div>15791580    </>1581  );1582}15831584// ── Sub-components ────────────────────────────────────────────────────15851586function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1587  return (1588    <div class="peach-inline-actions">1589      <button1590        class="peach-inline-save-btn"1591        onClick={props.onSave}1592        disabled={props.saving}1593        aria-label="Save"1594        title="Save (Enter)"1595      >1596        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1597          <path d="M1 4.5L4 7.5L10 1.5" />1598        </svg>1599      </button>1600      <button1601        class="peach-inline-cancel-btn"1602        onClick={props.onCancel}1603        disabled={props.saving}1604        aria-label="Cancel"1605        title="Cancel (Esc)"1606      >1607        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1608          <path d="M1 1L8 8M8 1L1 8" />1609        </svg>1610      </button>1611    </div>1612  );1613}16141615function EmptyVaultState() {1616  return (1617    <>1618      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1619        <div class="peach-entry-name">1620          <span class="peach-entry-name-text">1621            <span class="peach-entry-name-char" style="--char-index:0">e</span>1622            <span class="peach-entry-name-char" style="--char-index:1">m</span>1623            <span class="peach-entry-name-char" style="--char-index:2">p</span>1624            <span class="peach-entry-name-char" style="--char-index:3">t</span>1625            <span class="peach-entry-name-char" style="--char-index:4">y</span>1626          </span>1627        </div>1628      </div>1629      <div class="peach-empty-copy">1630        <span class="peach-label">vault</span>1631        <p>Your vault is ready for its first entry.</p>1632      </div>1633    </>1634  );1635}16361637function CopyGlyph() {1638  return (1639    <span class="peach-copy-icon">1640      <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">1641        <rect x="4" y="4" width="8" height="9" rx="1.5" />1642        <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" />1643      </svg>1644    </span>1645  );1646}16471648function EyeClosedGlyph() {1649  return (1650    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1651      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1652      <path d="M3.5 5.5L2.5 7.5" />1653      <path d="M7 6.5V8.5" />1654      <path d="M10.5 5.5L11.5 7.5" />1655    </svg>1656  );1657}16581659function EyeOpenGlyph() {1660  return (1661    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1662      <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" />1663      <circle cx="7" cy="5" r="1.75" />1664    </svg>1665  );1666}16671668function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1669  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1670  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1671  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1672  const [testResult, setTestResult] = createSignal<string | null>(null);1673  const [testing, setTesting] = createSignal(false);16741675  // Pro gate entry-point for canary creation. Free users cannot toggle the1676  // canary on; the server-side UPDATE_ENTRY handler is the single hard1677  // enforcement point, but this branch avoids the bad UX of letting the1678  // toggle slip into "on" before the save rejects. Existing canary entries1679  // (e.g. retained after a license deactivation, future-proofing) remain1680  // togglable off — gating only applies to enabling a new canary.1681  const isProGated = () => !props.isPro && !props.entry.canary;16821683  async function save() {1684    const url = webhookUrl().trim();1685    const nextLabel = label().trim() || undefined;1686    const canary = enabled() && url1687      ? {1688          webhookUrl: url,1689          label: nextLabel,1690          lastPinged: props.entry.canary?.lastPinged,1691          pingCount: props.entry.canary?.pingCount,1692        }1693      : undefined;1694    await sendMessage({1695      type: "UPDATE_ENTRY",1696      payload: { id: props.entry.id, patch: { canary } },1697    });1698    await props.onSaved();1699  }17001701  async function testPing() {1702    const url = webhookUrl().trim();1703    if (!url) return;1704    setTesting(true);1705    setTestResult(null);1706    const res = await sendMessage({1707      type: "FIRE_CANARY_PING",1708      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1709    }) as { ok: boolean; error?: string };1710    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1711    setTesting(false);1712  }17131714  return (1715    <div class="peach-canary-section">1716      <div class="peach-field-value-row">1717        <span class="peach-field-value" style="font-size:12px;">1718          canary alert1719          <Show when={isProGated()}>1720            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1721          </Show>1722        </span>1723        <button1724          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1725          onClick={() => {1726            if (isProGated()) {1727              // Pro gate: tap on free tier navigates to the upgrade screen1728              // with Canary Entries highlighted. The toggle never flips on1729              // for a free user, regardless of click count.1730              props.onProGate?.("canary_entry");1731              return;1732            }1733            setEnabled(!enabled());1734            void save();1735          }}1736          aria-label="Toggle canary"1737          aria-disabled={isProGated() ? "true" : "false"}1738        >1739          <span class="peach-toggle-knob" />1740        </button>1741      </div>1742      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1743        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1744      </p>1745      <Show when={enabled()}>1746        <div class="peach-inline-field-wrap" style="margin-top:8px;">1747          <input1748            class="peach-inline-input"1749            placeholder="https://hooks.zapier.com/..."1750            value={webhookUrl()}1751            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1752            onBlur={() => void save()}1753          />1754          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1755            When triggered, the entry title is sent to this URL. Use an endpoint you control.1756          </p>1757          <input1758            class="peach-inline-input"1759            placeholder="label (optional, e.g. AWS decoy)"1760            value={label()}1761            onInput={(e) => setLabel(e.currentTarget.value)}1762            onBlur={() => void save()}1763          />1764          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1765            <button1766              class="peach-btn peach-btn--ghost peach-btn--sm"1767              onClick={() => void testPing()}1768              disabled={testing() || !webhookUrl().trim()}1769            >1770              {testing() ? "sending..." : "test ping"}1771            </button>1772            <Show when={testResult()}>1773              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1774            </Show>1775          </div>1776          <Show when={props.entry.canary?.lastPinged}>1777            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1778              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1779              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1780            </p>1781          </Show>1782        </div>1783      </Show>1784    </div>1785  );1786}