Entry.tsx

74.2 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 { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516interface EntryProps {17  entry: VaultEntry | null;18  /**19   * Current active tab's full URL (https only). Empty string when unavailable20   * — Capacitor, an extension page, or a chrome:///about: URL. The URL21   * editor uses this to render an "add current site" shortcut.22   */23  currentTabUrl?: string;24  onDelete: () => void;25  /** Called after any inline save so App can reload entries */26  onFieldSaved: () => void | Promise<void>;27  onEscape?: () => void;28  entries?: VaultEntry[];29  /** Whether the user holds an active Peach Pro license. Drives Pro gates on30   *  canary entries (see CanarySection). */31  isPro?: boolean;32  /** Invoked when a Pro-gated control is tapped on free tier. App owns the33   *  upgrade navigation + highlight state. */34  onProGate?: (feature: ProFeature) => void;35}3637export function EntryView(props: EntryProps) {38  const [editingField, setEditingField] = createSignal<EditableField>(null);39  const [draft, setDraft] = createSignal("");40  const [saving, setSaving] = createSignal(false);41  const [showPassword, setShowPassword] = createSignal(false);42  const [showGenerator, setShowGenerator] = createSignal(false);43  const [copied, setCopied] = createSignal<string | null>(null);44  // Local draft for the additional URLs list, edited only while the URL field45  // is in inline-edit mode. Saved alongside `url` when the user confirms.46  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);47  const [editingTags, setEditingTags] = createSignal(false);48  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);49  const [tagInput, setTagInput] = createSignal("");50  const [showCardNumber, setShowCardNumber] = createSignal(false);51  const [showCardCvc, setShowCardCvc] = createSignal(false);52  const [totpTick, setTotpTick] = createSignal(0);53  const [confirmDelete, setConfirmDelete] = createSignal(false);54  const [totpHot, setTotpHot] = createSignal(false);55  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);56  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);57  // Auto-open advanced when entry already has passkey or canary configured58  const [showAdvanced, setShowAdvanced] = createSignal(false);59  const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);60  let usernameInlineInput: HTMLInputElement | undefined;61  let emailInlineInput: HTMLInputElement | undefined;62  let usernameIdentityEl: HTMLDivElement | undefined;63  let emailIdentityEl: HTMLDivElement | undefined;64  let lastSavedNote: string | null = null;65  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;66  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });6768  function noteExec(cmd: string, val?: string) {69    document.execCommand(cmd, false, val);70    noteEditorEl()?.focus();71  }7273  async function saveNoteInline(html: string) {74    const entry = props.entry;75    if (!entry) return;76    const currentStored = entry.notes ?? "";77    if (html === currentStored) return;78    if (lastSavedNote !== null && html === lastSavedNote) return;79    lastSavedNote = html;80    setSaving(true);81    try {82      await sendMessage({83        type: "UPDATE_ENTRY",84        payload: { id: entry.id, patch: { notes: html } },85      });86      triggerRing();87      await props.onFieldSaved();88    } catch {89      lastSavedNote = null;90    } finally {91      setSaving(false);92    }93  }9495  const allIdentities = createMemo(() => {96    const emailMap = new Map<string, number>();97    const usernameMap = new Map<string, number>();98    for (const e of props.entries ?? []) {99      const uname = e.username?.trim() ?? "";100      const em = e.email?.trim() ?? "";101      if (uname) {102        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);103        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);104      }105      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);106    }107    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];108    const seen = new Set<string>();109    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {110      if (seen.has(v)) continue;111      seen.add(v);112      const ec = emailMap.get(v) ?? 0;113      const uc = usernameMap.get(v) ?? 0;114      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });115    }116    return result.sort((a, b) => b.count - a.count);117  });118119  const identitySuggestions = createMemo(() => {120    const field = editingField();121    if (field !== "username" && field !== "email") return [];122    const input = draft().toLowerCase();123    return allIdentities()124      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))125      .slice(0, 8);126  });127128  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);129  onCleanup(() => window.clearInterval(interval));130131  const displayDomain = createMemo(() =>132    props.entry?.url ? getDisplayDomain(props.entry.url) : ""133  );134135  createEffect(() => {136    const domain = displayDomain();137    setFaviconSrc(domain ? faviconUrl(domain) : null);138  });139140  // Reset transient prompts (trash confirmation, inline edits, advanced panel)141  // when the selected entry changes so they don't carry over between entries.142  // Track the last id so a same-entry refresh (e.g. after saving a field)143  // doesn't blow away in-flight edit state — that race used to cancel144  // startEdit calls that ran right after onFieldSaved.145  let lastEntryId: string | null = null;146  createEffect(() => {147    const id = props.entry?.id ?? null;148    if (id === lastEntryId) return;149    lastEntryId = id;150    setConfirmDelete(false);151    setEditingField(null);152    setEditingTags(false);153    setShowAdvanced(false);154  });155156  const totpState = createMemo(() => {157    void totpTick();158    if (!props.entry?.totp) return null;159    return generateTOTP(props.entry.totp);160  });161162  let prevTotpCode: string | undefined;163  createEffect(() => {164    const state = totpState();165    if (!state) { prevTotpCode = undefined; return; }166    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {167      setTotpHot(true);168      window.setTimeout(() => setTotpHot(false), 3000);169    }170    prevTotpCode = state.code;171  });172173  const totpProgress = createMemo(() => {174    const state = totpState();175    if (!state) return 0;176    return ((state.remaining - 1) / 29) * 100;177  });178179  createEffect(() => {180    const shell = document.getElementById("hd-shell");181    if (!shell) return;182183    const state = totpState();184    if (!state) {185      delete shell.dataset.totpActive;186      shell.style.removeProperty("--peach-totp-progress");187      return;188    }189190    shell.dataset.totpActive = "true";191    shell.style.setProperty(192      "--peach-totp-progress",193      `${Math.max(0, totpProgress())}%`194    );195196    onCleanup(() => {197      delete shell.dataset.totpActive;198      shell.style.removeProperty("--peach-totp-progress");199    });200  });201202  const titleSizeClass = createMemo(() => {203    const length = props.entry?.title.length ?? 0;204    if (length >= 28) return "peach-entry-name--xxl";205    if (length >= 20) return "peach-entry-name--xl";206    if (length >= 14) return "peach-entry-name--lg";207    return "";208  });209  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);210211  // ── Inline editing ────────────────────────────────────────────────212213  function startEdit(field: EditableField, value: string) {214    setEditingField(field);215    setDraft(value);216    setIdentityHelperSuppressed(false);217    setShowGenerator(false);218    setConfirmDelete(false);219    if (field === "url") {220      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);221    }222  }223224  // Compare URLs by string after trim — vault.ts has its own canonicalisation225  // when saving, so the dedupe here is a UX-level "don't show the button if226  // we'd just add a duplicate" rather than a security check.227  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {228    const c = candidate.trim();229    if (!c) return true;230    if (primary.trim() === c) return true;231    return others.some((u) => u.trim() === c);232  }233234  function addCurrentSite(url: string) {235    if (!url) return;236    if (!draft().trim()) {237      setDraft(url);238      return;239    }240    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;241    setAdditionalUrlsDraft((arr) => [...arr, url]);242  }243244  async function saveField() {245    const field = editingField();246    const entry = props.entry;247    if (!field || !entry) return;248    // Don't trim passwords — spaces may be intentional249    let value = field === "password" ? draft() : draft().trim();250    if (field === "totp" && value.startsWith("otpauth://")) {251      const parsed = parseOtpauthURI(value);252      value = parsed?.secret ?? value;253    }254    if (field === "password" && entry.type !== "note" && !value) {255      return;256    }257    setSaving(true);258    try {259      const patch: Partial<VaultEntry> = { [field]: value };260      if (field === "url") {261        // Sanitization layer in vault.ts trims, dedupes, and drops empties.262        patch.additionalUrls = additionalUrlsDraft();263      }264      await sendMessage({265        type: "UPDATE_ENTRY",266        payload: { id: entry.id, patch },267      });268      triggerRing();269      setEditingField(null);270      setShowGenerator(false);271      await props.onFieldSaved();272    } catch {273      // silent — leave editing open274    } finally {275      setSaving(false);276    }277  }278279  function startEditTags() {280    setTagsDraft([...(props.entry?.tags ?? [])]);281    setTagInput("");282    setEditingTags(true);283  }284285  function addTag(value: string) {286    const n = value.toLowerCase().trim();287    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;288    setTagsDraft([...tagsDraft(), n]);289    setTagInput("");290  }291292  async function saveTags() {293    if (tagInput()) addTag(tagInput());294    const entry = props.entry;295    if (!entry) return;296    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;297    setEditingTags(false);298    setSaving(true);299    try {300      await sendMessage({301        type: "UPDATE_ENTRY",302        payload: { id: entry.id, patch: { tags: next } },303      });304      triggerRing();305      await props.onFieldSaved();306    } catch {307      // silent308    } finally {309      setSaving(false);310    }311  }312313  function cancelEdit() {314    setEditingField(null);315    setDraft("");316    setShowGenerator(false);317  }318319  // Switch from one identity slot to the other.320  //321  // Migration source is the SAVED entry value, never the live draft — using322  // the draft caused data loss when the user typed or cleared the input323  // before clicking +add (the migration would skip and a later Save would324  // overwrite the original email-shape value with whatever was in the input).325  //326  // Drafts only get committed when they are non-empty AND actually changed,327  // so an empty input never accidentally clears a saved field.328  async function addOtherIdentity(target: "username" | "email") {329    const entry = props.entry;330    if (!entry) return;331    const current = editingField();332    const draftValue = draft().trim();333    const savedUsername = entry.username ?? "";334    const savedEmail = entry.email ?? "";335336    if (current === "username" && target === "username") {337      // Legacy fix-up: saved username is email-shaped and no separate email338      // exists yet. Move it to entry.email and free the username slot.339      if (savedUsername.includes("@") && !entry.email) {340        setSaving(true);341        try {342          await sendMessage({343            type: "UPDATE_ENTRY",344            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },345          });346          await props.onFieldSaved();347        } finally {348          setSaving(false);349        }350      }351      startEdit("username", "");352      return;353    }354355    if (current === "username" && target === "email") {356      if (draftValue && draftValue !== savedUsername) {357        setSaving(true);358        try {359          await sendMessage({360            type: "UPDATE_ENTRY",361            payload: { id: entry.id, patch: { username: draftValue } },362          });363          await props.onFieldSaved();364        } finally {365          setSaving(false);366        }367      }368      startEdit("email", "");369      return;370    }371372    if (current === "email" && target === "username") {373      if (draftValue && draftValue !== savedEmail) {374        setSaving(true);375        try {376          await sendMessage({377            type: "UPDATE_ENTRY",378            payload: { id: entry.id, patch: { email: draftValue } },379          });380          await props.onFieldSaved();381        } finally {382          setSaving(false);383        }384      }385      startEdit("username", "");386      return;387    }388  }389390  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {391    switch (field) {392      case "title":393        return entry.title;394      case "url":395        return entry.url ?? "";396      case "username":397        return entry.username ?? "";398      case "email":399        return entry.email ?? "";400      case "password":401        return entry.password ?? "";402      case "notes":403        return entry.notes ?? "";404      case "totp":405        return entry.totp ?? "";406    }407  }408409  function handleEditorFocusOut(410    field: Exclude<EditableField, null>,411    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }412  ) {413    const nextTarget = event.relatedTarget;414    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;415416    // addOtherIdentity / startEdit may have already switched editingField before417    // focusout fires (SolidJS removes the old editor node synchronously, which418    // triggers blur with relatedTarget=null). Don't cancel the new edit.419    if (editingField() !== field) return;420421    const entry = props.entry;422    if (!entry) return;423424    const currentValue = fieldValue(entry, field);425    const nextValue = field === "password" ? draft() : draft().trim();426    if (nextValue === currentValue) {427      cancelEdit();428    }429  }430431  // ── Copy ─────────────────────────────────────────────────────────432433  async function copyField(text: string, label: string, button?: HTMLButtonElement) {434    const ok = await popupCopy(text, label);435    if (ok) {436      const entry = props.entry;437      if (entry?.canary?.webhookUrl) {438        const trigger =439          label === "password"440            ? "password_copied"441            : label === "username"442              ? "username_copied"443              : undefined;444        if (trigger) {445          void sendMessage({446            type: "FIRE_CANARY_PING",447            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },448          });449        }450      }451      triggerRing();452      triggerPipPulse();453      setCopied(null);454      requestAnimationFrame(() => setCopied(label));455      if (button) {456        button.classList.remove("is-copied", "is-retreat");457        void button.offsetWidth;458        const drain = document.createElement("span");459        drain.className = "peach-copy-drain";460        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";461        drain.appendChild(bar);462        button.appendChild(drain);463        window.setTimeout(() => {464          button.classList.remove("is-copied");465          button.classList.add("is-retreat");466          drain.remove();467          window.setTimeout(() => button.classList.remove("is-retreat"), 160);468        }, 1800);469      }470      window.setTimeout(() => setCopied(null), 1800);471    }472  }473474  // ── Keyboard shortcuts ────────────────────────────────────────────475476  createEffect(() => {477    if (!props.entry) return;478    const handler = (e: KeyboardEvent) => {479      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;480      if (inInput) return;481      switch (e.key) {482        case "c": e.preventDefault();483          if (props.entry?.password) void copyField(props.entry.password, "password"); break;484        case "u": e.preventDefault();485          if (props.entry?.username) void copyField(props.entry.username, "username"); break;486        case "Escape": e.preventDefault(); props.onEscape?.(); break;487      }488    };489    window.addEventListener("keydown", handler);490    onCleanup(() => window.removeEventListener("keydown", handler));491  });492493  // ── Input ref focus helper ────────────────────────────────────────494495  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {496    requestAnimationFrame(() => { el.focus(); el.select?.(); });497  }498499  function onEditKeyDown(e: KeyboardEvent) {500    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {501      e.preventDefault();502      void saveField();503    }504    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }505  }506507  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {508    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;509    if (e.key === "ArrowDown" && listEl) {510      e.preventDefault();511      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();512      return;513    }514    onEditKeyDown(e);515  }516517  return (518    <>519      <div520        class="peach-ghost"521        aria-hidden="true"522        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;overflow:hidden;white-space:nowrap;z-index:0;"523      >524        {props.entry?.title || "empty"}525      </div>526527      <div class="peach-stage" style="position:relative;">528        <Show when={props.entry} fallback={<EmptyVaultState />}>529          {(entry) => (530            <>531              {/* ── Breach strip ─────────────────────────────────── */}532              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>533                <div class="peach-breach-badge">534                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">535                    <path d="M6 1L11 10H1L6 1z" />536                    <line x1="6" y1="5" x2="6" y2="7.5" />537                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />538                  </svg>539                  <span style="flex:1">found in a known data breach</span>540                  <button541                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"542                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}543                  >544                    Change password545                  </button>546                </div>547              </Show>548549              {/* ── Title + trash ─────────────────────────────────── */}550              <div class="peach-entry-header">551                <div class="peach-entry-header-name">552                  <Show553                    when={editingField() === "title"}554                    fallback={555                      <button556                        class="peach-entry-name-button"557                        title="Click to edit title"558                        onClick={() => startEdit("title", entry().title)}559                      >560                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>561                          <span class="peach-entry-name-text">562                            <Show563                              when={useAnimatedTitle()}564                              fallback={entry().title}565                            >566                              <For each={entry().title.split("")}>567                                {(ch, i) => (568                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>569                                    {ch === " " ? "\u00A0" : ch}570                                  </span>571                                )}572                              </For>573                            </Show>574                          </span>575                        </h1>576                      </button>577                    }578                  >579                    <div580                      class="peach-inline-title-wrap"581                      onFocusOut={(event) => handleEditorFocusOut("title", event)}582                    >583                      <input584                        class="peach-inline-title-input"585                        value={draft()}586                        onInput={(e) => setDraft(e.currentTarget.value)}587                        onKeyDown={onEditKeyDown}588                        ref={autoFocus}589                        disabled={saving()}590                      />591                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />592                    </div>593                  </Show>594                </div>595596                {/* Subtle trash icon — hidden while editing title */}597                <Show when={editingField() !== "title"}>598                  <Show599                    when={confirmDelete()}600                    fallback={601                      <button602                        class="peach-entry-trash-btn"603                        onClick={() => setConfirmDelete(true)}604                        aria-label="Move to trash"605                        title="Delete entry"606                      >607                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">608                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />609                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />610                        </svg>611                      </button>612                    }613                  >614                    <div class="peach-entry-trash-confirm">615                      <span class="peach-entry-trash-confirm-label">trash?</span>616                      <button617                        class="peach-btn peach-btn--sm peach-btn--danger"618                        style="font-size:10px;padding:2px 8px;min-height:22px;"619                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}620                      >621                        yes622                      </button>623                      <button624                        class="peach-btn peach-btn--ghost peach-btn--sm"625                        style="font-size:10px;padding:2px 8px;min-height:22px;"626                        onClick={() => setConfirmDelete(false)}627                      >628                        no629                      </button>630                    </div>631                  </Show>632                </Show>633              </div>634635              {/* ── Domain / URL ─────────────────────────────────── */}636              <Show when={entry().type !== "note" && entry().type !== "card"}>637                <div class="peach-domain" style="position:relative;z-index:1;">638                  <Show639                    when={editingField() === "url"}640                    fallback={641                      <div642                        class="peach-domain-row peach-inline-clickable"643                        title="Click to edit URL"644                        onClick={() => startEdit("url", entry().url ?? "")}645                      >646                        <Show when={faviconSrc()}>647                          <img648                            src={faviconSrc()!}649                            width="16"650                            height="16"651                            alt=""652                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"653                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}654                          />655                        </Show>656                        <span class="peach-domain-value">657                          {displayDomain() || (entry().url ? entry().url : "add a website url")}658                        </span>659                        <span class="peach-inline-edit-hint">edit</span>660                      </div>661                    }662                  >663                    <div664                      class="peach-inline-field-wrap peach-inline-field-wrap--url"665                      onFocusOut={(event) => handleEditorFocusOut("url", event)}666                    >667                      <div class="peach-inline-field-row">668                        <input669                          class="peach-inline-input"670                          type="url"671                          placeholder="https://"672                          value={draft()}673                          onInput={(e) => setDraft(e.currentTarget.value)}674                          onKeyDown={onEditKeyDown}675                          ref={autoFocus}676                          disabled={saving()}677                        />678                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />679                      </div>680                      <div class="peach-additional-urls">681                        <Show when={additionalUrlsDraft().length > 0}>682                          <span class="peach-label peach-additional-urls-label">also matches</span>683                        </Show>684                        <For each={additionalUrlsDraft()}>685                          {(value, i) => (686                            <div class="peach-additional-url-row">687                              <input688                                class="peach-inline-input"689                                type="url"690                                placeholder="https://"691                                value={value}692                                onInput={(e) => setAdditionalUrlsDraft((arr) => {693                                  const next = [...arr];694                                  next[i()] = e.currentTarget.value;695                                  return next;696                                })}697                                disabled={saving()}698                              />699                              <button700                                class="peach-additional-url-remove"701                                type="button"702                                aria-label="Remove URL"703                                onMouseDown={(e) => e.preventDefault()}704                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}705                                disabled={saving()}706                              >×</button>707                            </div>708                          )}709                        </For>710                        <button711                          class="peach-add-field-link peach-additional-urls-add"712                          type="button"713                          onMouseDown={(e) => e.preventDefault()}714                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}715                          disabled={saving()}716                        >717                          + add another url718                        </button>719                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>720                          <button721                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"722                            type="button"723                            title={props.currentTabUrl}724                            onMouseDown={(e) => e.preventDefault()}725                            onClick={() => addCurrentSite(props.currentTabUrl!)}726                            disabled={saving()}727                          >728                            + add current site729                          </button>730                        </Show>731                      </div>732                    </div>733                  </Show>734                </div>735              </Show>736737              {/* ── Tags — read-only chips (editing state lives at bottom) */}738              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>739                <div740                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"741                  onClick={startEditTags}742                  title="Click to edit tags"743                >744                  <For each={entry().tags!}>745                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}746                  </For>747                </div>748              </Show>749750              {/* ── Note toolbar + inline editor ─────────────────── */}751              <Show when={entry().type === "note"}>752                <div class="peach-entry-note-toolbar-row">753                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />754                </div>755                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">756                  <NoteEditorInline757                    entryKey={entry().id}758                    initialContent={entry().notes ?? ""}759                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}760                    onBlur={saveNoteInline}761                    autoFocus={false}762                  />763                </section>764              </Show>765766              {/* ── Card detail view ─────────────────────────────── */}767              <Show when={entry().type === "card"}>768                <section class="peach-fields" style="position:relative;z-index:1;">769                  {/* Card subtitle: •••• last4 */}770                  <Show when={entry().last4}>771                    <div class="peach-field-card" style="padding-bottom:4px;">772                      <span class="peach-label">773                        <Show when={entry().network && entry().network !== "unknown"}>774                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>775                        </Show>776                        {entry().cardNickname || `•••• ${entry().last4}`}777                      </span>778                    </div>779                  </Show>780781                  {/* Cardholder name */}782                  <div class="peach-field-card">783                    <span class="peach-label">cardholder name</span>784                    <div class="peach-field-value-row">785                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>786                      <Show when={entry().cardholderName}>787                        <button788                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}789                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}790                          aria-label="Copy cardholder name"791                        >792                          <CopyGlyph /><span class="peach-copy-sheen" />793                        </button>794                      </Show>795                    </div>796                  </div>797798                  {/* Card number */}799                  <div class="peach-field-card">800                    <span class="peach-label">card number</span>801                    <div class="peach-field-value-row">802                      <span class={`peach-field-value peach-card-number`}>803                        <Show when={showCardNumber()} fallback={804                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>805                        }>806                          <span>{entry().cardNumber ?? ""}</span>807                        </Show>808                      </span>809                      <div class="peach-field-actions">810                        <button811                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"812                          onClick={() => setShowCardNumber(!showCardNumber())}813                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}814                        >815                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>816                            <EyeClosedGlyph />817                          </Show>818                        </button>819                        <Show when={entry().cardNumber}>820                          <button821                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}822                            onClick={(e) => {823                              const cardNum = entry().cardNumber!;824                              if (entry().canary?.webhookUrl) {825                                void sendMessage({826                                  type: "FIRE_CANARY_PING",827                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },828                                });829                              }830                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);831                            }}832                            aria-label="Copy card number"833                          >834                            <CopyGlyph /><span class="peach-copy-sheen" />835                          </button>836                        </Show>837                      </div>838                    </div>839                  </div>840841                  {/* Expiry */}842                  <Show when={entry().expMonth || entry().expYear}>843                    <div class="peach-field-card">844                      <span class="peach-label">expiry</span>845                      <div class="peach-field-value-row">846                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>847                        <button848                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}849                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}850                          aria-label="Copy expiry"851                        >852                          <CopyGlyph /><span class="peach-copy-sheen" />853                        </button>854                      </div>855                    </div>856                  </Show>857858                  {/* CVC */}859                  <Show when={entry().cvc}>860                    <div class="peach-field-card">861                      <span class="peach-label">CVC</span>862                      <div class="peach-field-value-row">863                        <span class="peach-field-value">864                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>865                            <span>{entry().cvc}</span>866                          </Show>867                        </span>868                        <div class="peach-field-actions">869                          <button870                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"871                            onClick={() => setShowCardCvc(!showCardCvc())}872                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}873                          >874                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>875                              <EyeClosedGlyph />876                            </Show>877                          </button>878                          <button879                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}880                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}881                            aria-label="Copy CVC"882                          >883                            <CopyGlyph /><span class="peach-copy-sheen" />884                          </button>885                        </div>886                      </div>887                    </div>888                  </Show>889                </section>890              </Show>891892              {/* ── Login fields ─────────────────────────────────── */}893              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>894                <section class="peach-fields" style="position:relative;z-index:1;">895896                  {/* Username — primary identity slot. Auto-labels as "email"897                      when the stored value is email-shaped and no separate898                      email field exists (legacy data path). */}899                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>900                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>901                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>902                      <Show903                        when={editingField() === "username"}904                        fallback={905                          <div class="peach-field-value-row">906                            <span907                              class="peach-field-value peach-inline-clickable"908                              title="Click to edit"909                              onClick={() => startEdit("username", entry().username ?? "")}910                            >911                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}912                            </span>913                            <Show when={entry().username}>914                              <button915                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}916                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}917                                aria-label="Copy username"918                              >919                                <CopyGlyph /><span class="peach-copy-sheen" />920                              </button>921                            </Show>922                          </div>923                        }924                      >925                        <div926                          class="peach-inline-field-wrap"927                          onFocusOut={(event) => handleEditorFocusOut("username", event)}928                        >929                          <input930                            class="peach-inline-input"931                            value={draft()}932                            onInput={(e) => setDraft(e.currentTarget.value)}933                            onKeyDown={(e) => identityKeyDown(e, "username")}934                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}935                            disabled={saving()}936                            autocomplete="off"937                          />938                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />939                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>940                            <IdentitySuggestList941                              listRef={(el) => (usernameIdentityEl = el)}942                              inputRef={() => usernameInlineInput}943                              suggestions={identitySuggestions()}944                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}945                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}946                            />947                          </Show>948                          {/* +add the OTHER identity. If the slot is acting as949                              email (value has @, no separate email), offer950                              "+ add username" — clicking migrates the email951                              into entry.email. Otherwise offer "+ add email". */}952                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>953                            <button954                              class="peach-add-field-btn"955                              type="button"956                              onMouseDown={(e) => e.preventDefault()}957                              onClick={() => {958                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);959                                const hasSeparateEmail = !!entry().email;960                                if (isEmailShape && !hasSeparateEmail) {961                                  void addOtherIdentity("username");962                                } else {963                                  void addOtherIdentity("email");964                                }965                              }}966                            >967                              {(() => {968                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);969                                const hasSeparateEmail = !!entry().email;970                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";971                              })()}972                            </button>973                          </Show>974                        </div>975                      </Show>976                    </div>977                  </Show>978979                  {/* Email — secondary identity slot. Visible whenever a980                      separate email field is set, or while it is being edited. */}981                  <Show when={entry().email || editingField() === "email"}>982                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>983                      <span class="peach-label">email</span>984                      <Show985                        when={editingField() === "email"}986                        fallback={987                          <div class="peach-field-value-row">988                            <span989                              class="peach-field-value peach-inline-clickable"990                              title="Click to edit"991                              onClick={() => startEdit("email", entry().email ?? "")}992                            >993                              {entry().email}994                            </span>995                            <Show when={entry().email}>996                              <button997                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}998                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}999                                aria-label="Copy email"1000                              >1001                                <CopyGlyph /><span class="peach-copy-sheen" />1002                              </button>1003                            </Show>1004                          </div>1005                        }1006                      >1007                        <div1008                          class="peach-inline-field-wrap"1009                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1010                        >1011                          <input1012                            class="peach-inline-input"1013                            value={draft()}1014                            onInput={(e) => setDraft(e.currentTarget.value)}1015                            onKeyDown={(e) => identityKeyDown(e, "email")}1016                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1017                            disabled={saving()}1018                            autocomplete="email"1019                            type="email"1020                          />1021                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1022                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1023                            <IdentitySuggestList1024                              listRef={(el) => (emailIdentityEl = el)}1025                              inputRef={() => emailInlineInput}1026                              suggestions={identitySuggestions()}1027                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1028                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1029                            />1030                          </Show>1031                          <Show when={!entry().username || entry().username?.includes("@")}>1032                            <button1033                              class="peach-add-field-btn"1034                              type="button"1035                              onMouseDown={(e) => e.preventDefault()}1036                              onClick={() => void addOtherIdentity("username")}1037                            >1038                              + add username1039                            </button>1040                          </Show>1041                        </div>1042                      </Show>1043                    </div>1044                  </Show>10451046                  {/* Password */}1047                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1048                    <span class="peach-label">password</span>1049                    <Show1050                      when={editingField() === "password"}1051                      fallback={1052                        <div class="peach-field-value-row">1053                          <span1054                            class="peach-password-value"1055                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1056                            style="cursor:pointer;"1057                            title="Click to edit password"1058                            onClick={() => startEdit("password", entry().password ?? "")}1059                          >1060                            <Show1061                              when={showPassword()}1062                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1063                            >1064                              <span class="peach-password-text">{entry().password ?? ""}</span>1065                            </Show>1066                          </span>1067                          <div class="peach-field-actions">1068                            <button1069                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1070                              onClick={() => setShowPassword(!showPassword())}1071                              aria-label="Reveal password"1072                            >1073                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1074                                <EyeClosedGlyph />1075                              </Show>1076                            </button>1077                            <button1078                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1079                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1080                              aria-label="Copy password"1081                            >1082                              <CopyGlyph /><span class="peach-copy-sheen" />1083                            </button>1084                          </div>1085                        </div>1086                      }1087                    >1088                      {/* ── Edit mode: type / paste / generate ── */}1089                      <div1090                        class="peach-inline-field-wrap"1091                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1092                      >1093                        <div class="peach-password-edit-row">1094                          <input1095                            class="peach-inline-input"1096                            type={showPassword() ? "text" : "password"}1097                            placeholder="paste or type a password"1098                            value={draft()}1099                            onInput={(e) => setDraft(e.currentTarget.value)}1100                            onKeyDown={(e) => {1101                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1102                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1103                            }}1104                            ref={autoFocus}1105                            disabled={saving()}1106                            autocomplete="new-password"1107                          />1108                          <button1109                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1110                            type="button"1111                            onClick={() => setShowPassword(!showPassword())}1112                            aria-label={showPassword() ? "Hide" : "Show"}1113                          >1114                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1115                              <EyeClosedGlyph />1116                            </Show>1117                          </button>1118                        </div>1119                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1120                        <button1121                          class="peach-generate-toggle-link"1122                          type="button"1123                          onClick={() => setShowGenerator(!showGenerator())}1124                        >1125                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1126                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1127                            <path d="M9.5 2.5H11V1" />1128                          </svg>1129                          {showGenerator() ? "hide generator" : "generate"}1130                        </button>1131                        <Show when={showGenerator()}>1132                          <GeneratorDrawer1133                            open={true}1134                            compact={true}1135                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1136                          />1137                        </Show>1138                      </div>1139                    </Show>1140                  </div>11411142                </section>1143              </Show>11441145              {/* ── Notes (on login entries) ──────────────────────── */}1146              <Show when={entry().type !== "note" && entry().type !== "card"}>1147                <Show1148                  when={entry().notes || editingField() === "notes"}1149                  fallback={1150                    <button1151                      class="peach-add-field-link peach-entry-add-link"1152                      onClick={() => startEdit("notes", "")}1153                    >1154                      + notes1155                    </button>1156                  }1157                >1158                  <div style="position:relative;z-index:1;margin-top:6px;">1159                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1160                    <Show1161                      when={editingField() === "notes"}1162                      fallback={1163                        <div1164                          class="peach-note-content peach-inline-clickable"1165                          style="cursor:text;white-space:pre-wrap;"1166                          onClick={() => startEdit("notes", entry().notes ?? "")}1167                        >1168                          {entry().notes}1169                        </div>1170                      }1171                    >1172                      <div1173                        class="peach-inline-field-wrap"1174                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1175                      >1176                        <textarea1177                          class="peach-inline-textarea"1178                          value={draft()}1179                          onInput={(e) => setDraft(e.currentTarget.value)}1180                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1181                          ref={(el) => requestAnimationFrame(() => el?.focus())}1182                          rows={4}1183                          disabled={saving()}1184                        />1185                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1186                      </div>1187                    </Show>1188                  </div>1189                </Show>1190              </Show>11911192              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1193              <Show1194                when={editingTags()}1195                fallback={1196                  <Show when={!entry().tags || entry().tags!.length === 0}>1197                    <button1198                      class="peach-add-field-link peach-entry-add-link"1199                      onClick={startEditTags}1200                    >1201                      + tags1202                    </button>1203                  </Show>1204                }1205              >1206                <div1207                  class="peach-inline-tag-editor"1208                  onFocusOut={(e) => {1209                    const next = e.relatedTarget;1210                    if (next instanceof Node && e.currentTarget.contains(next)) return;1211                    void saveTags();1212                  }}1213                >1214                  <span class="peach-label">tags</span>1215                  <div class="peach-tag-input-wrap">1216                    <For each={tagsDraft()}>1217                      {(tag) => (1218                        <span class="peach-tag-chip">1219                          {tag}1220                          <button1221                            class="peach-tag-chip-remove"1222                            onMouseDown={(e) => e.preventDefault()}1223                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1224                            aria-label={`Remove ${tag}`}1225                          >×</button>1226                        </span>1227                      )}1228                    </For>1229                    <input1230                      class="peach-tag-input"1231                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1232                      value={tagInput()}1233                      onInput={(e) => setTagInput(e.currentTarget.value)}1234                      onKeyDown={(e) => {1235                        if (e.key === "Enter" || e.key === ",") {1236                          e.preventDefault();1237                          addTag(tagInput());1238                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1239                          setTagsDraft(tagsDraft().slice(0, -1));1240                        } else if (e.key === "Escape") {1241                          e.preventDefault();1242                          setEditingTags(false);1243                          setTagInput("");1244                        }1245                      }}1246                      ref={(el) => requestAnimationFrame(() => el?.focus())}1247                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1248                    />1249                  </div>1250                </div>1251              </Show>12521253              {/* ── TOTP ─────────────────────────────────────────── */}1254              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1255                <Show1256                  when={totpState() || editingField() === "totp"}1257                  fallback={1258                    <button1259                      class="peach-add-field-link peach-entry-add-link"1260                      onClick={() => startEdit("totp", "")}1261                    >1262                      + 2FA1263                    </button>1264                  }1265                >1266                  <section class="peach-totp-band">1267                    <Show1268                      when={editingField() === "totp"}1269                      fallback={1270                        <Show when={totpState()}>1271                          {(state) => (1272                            <>1273                              <div1274                                class="peach-totp-main"1275                                onClick={() => {1276                                  // Delay copy so a double-click can cancel it before1277                                  // it overwrites the clipboard the user wants to paste from.1278                                  if (totpClickTimer) return;1279                                  totpClickTimer = setTimeout(() => {1280                                    totpClickTimer = null;1281                                    void copyField(state().code.replace(" ", ""), "totp");1282                                  }, 220);1283                                }}1284                                onDblClick={() => {1285                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1286                                  startEdit("totp", entry().totp ?? "");1287                                }}1288                                title="Click to copy · double-click to edit"1289                              >1290                                <span class="peach-label">2-factor</span>1291                                <div class="peach-totp-code-row">1292                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1293                                    <span class="peach-totp-code-text">1294                                      {state().code.slice(0, 3)} {state().code.slice(3)}1295                                    </span>1296                                  </p>1297                                </div>1298                              </div>1299                              <div class="peach-totp-meta">1300                                <span class="peach-label">expires</span>1301                                <div class="peach-totp-countdown">{state().remaining}s</div>1302                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1303                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1304                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1305                                    stroke-dasharray="106.8"1306                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1307                                  />1308                                </svg>1309                              </div>1310                            </>1311                          )}1312                        </Show>1313                      }1314                    >1315                      <div1316                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1317                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1318                      >1319                        <span class="peach-label">2-factor</span>1320                        <input1321                          class="peach-inline-input"1322                          value={draft()}1323                          onInput={(e) => setDraft(e.currentTarget.value)}1324                          onKeyDown={onEditKeyDown}1325                          ref={autoFocus}1326                          disabled={saving()}1327                          placeholder="otpauth://... or base32 secret"1328                          autocomplete="off"1329                        />1330                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1331                      </div>1332                    </Show>1333                  </section>1334                </Show>1335              </Show>13361337              {/* ── Advanced (canary + passkey) ───────────────────── */}1338              <Show when={entry().type === "login"}>1339                <button1340                  class="peach-add-field-link peach-entry-add-link"1341                  onClick={() => setShowAdvanced((v) => !v)}1342                  aria-expanded={showAdvanced()}1343                >1344                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1345                </button>1346                <Show when={showAdvanced()}>1347                  <Show when={!isPasskeyOnly(entry())}>1348                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1349                  </Show>13501351                  <Show when={entry().passkey}>1352                    {(passkey) => {1353                      const currentBrowser = detectBrowser();1354                      const crossBrowser =1355                        passkey().browser &&1356                        currentBrowser !== "unknown" &&1357                        passkey().browser !== currentBrowser;1358                      const algorithmLabel =1359                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1360                      const formatDate = (ts?: number) =>1361                        ts ? new Date(ts).toLocaleDateString() : "Never";1362                      return (1363                        <section1364                          class="peach-fields peach-passkey-section"1365                          style="position:relative;z-index:1;margin-top:6px;"1366                        >1367                          <div class="peach-field-card">1368                            <span class="peach-label">passkey · {passkey().rpId}</span>1369                            <div class="peach-field-value-row">1370                              <span class="peach-field-value">{passkey().userName}</span>1371                              <span class="peach-passkey-badge">passkey</span>1372                            </div>1373                          </div>13741375                          <div class="peach-field-card">1376                            <span class="peach-label">algorithm</span>1377                            <div class="peach-field-value-row">1378                              <span class="peach-field-value">{algorithmLabel}</span>1379                            </div>1380                          </div>13811382                          <div class="peach-field-card">1383                            <span class="peach-label">activity</span>1384                            <div class="peach-field-value-row peach-passkey-activity">1385                              <span class="peach-field-value">1386                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1387                              </span>1388                              <span class="peach-passkey-meta">1389                                created {formatDate(passkey().createdAt)}1390                              </span>1391                              <span class="peach-passkey-meta">1392                                last used {formatDate(passkey().lastUsedAt)}1393                              </span>1394                            </div>1395                          </div>13961397                          <Show when={crossBrowser}>1398                            <div class="peach-info-notice peach-info-notice--warn">1399                              {`This passkey was created on ${1400                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1401                              }. To use it on ${1402                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1403                              }, re-register it on this device.`}1404                            </div>1405                          </Show>14061407                          <div class="peach-info-notice">1408                            {passkey().browser === "firefox"1409                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1410                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1411                          </div>14121413                          <button1414                            class="peach-add-field-link peach-entry-add-link"1415                            style="color:rgba(220,38,38,0.7);"1416                            onClick={async () => {1417                              const id = entry().id;1418                              await sendMessage({1419                                type: "UPDATE_ENTRY",1420                                payload: { id, patch: { passkey: undefined } },1421                              });1422                              await props.onFieldSaved();1423                            }}1424                          >1425                            − remove passkey1426                          </button>1427                        </section>1428                      );1429                    }}1430                  </Show>1431                </Show>1432              </Show>14331434              {/* ── Bottom spacer ─────────────────────────────────── */}1435              <div style="height:4px;" />1436            </>1437          )}1438        </Show>1439      </div>14401441    </>1442  );1443}14441445// ── Sub-components ────────────────────────────────────────────────────14461447function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1448  return (1449    <div class="peach-inline-actions">1450      <button1451        class="peach-inline-save-btn"1452        onClick={props.onSave}1453        disabled={props.saving}1454        aria-label="Save"1455        title="Save (Enter)"1456      >1457        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1458          <path d="M1 4.5L4 7.5L10 1.5" />1459        </svg>1460      </button>1461      <button1462        class="peach-inline-cancel-btn"1463        onClick={props.onCancel}1464        disabled={props.saving}1465        aria-label="Cancel"1466        title="Cancel (Esc)"1467      >1468        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1469          <path d="M1 1L8 8M8 1L1 8" />1470        </svg>1471      </button>1472    </div>1473  );1474}14751476function EmptyVaultState() {1477  return (1478    <>1479      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1480        <div class="peach-entry-name">1481          <span class="peach-entry-name-text">1482            <span class="peach-entry-name-char" style="--char-index:0">e</span>1483            <span class="peach-entry-name-char" style="--char-index:1">m</span>1484            <span class="peach-entry-name-char" style="--char-index:2">p</span>1485            <span class="peach-entry-name-char" style="--char-index:3">t</span>1486            <span class="peach-entry-name-char" style="--char-index:4">y</span>1487          </span>1488        </div>1489      </div>1490      <div class="peach-empty-copy">1491        <span class="peach-label">vault</span>1492        <p>Your vault is ready for its first entry.</p>1493      </div>1494    </>1495  );1496}14971498function CopyGlyph() {1499  return (1500    <span class="peach-copy-icon">1501      <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">1502        <rect x="4" y="4" width="8" height="9" rx="1.5" />1503        <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" />1504      </svg>1505    </span>1506  );1507}15081509function EyeClosedGlyph() {1510  return (1511    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1512      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1513      <path d="M3.5 5.5L2.5 7.5" />1514      <path d="M7 6.5V8.5" />1515      <path d="M10.5 5.5L11.5 7.5" />1516    </svg>1517  );1518}15191520function EyeOpenGlyph() {1521  return (1522    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1523      <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" />1524      <circle cx="7" cy="5" r="1.75" />1525    </svg>1526  );1527}15281529function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1530  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1531  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1532  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1533  const [testResult, setTestResult] = createSignal<string | null>(null);1534  const [testing, setTesting] = createSignal(false);15351536  // Pro gate entry-point for canary creation. Free users cannot toggle the1537  // canary on; the server-side UPDATE_ENTRY handler is the single hard1538  // enforcement point, but this branch avoids the bad UX of letting the1539  // toggle slip into "on" before the save rejects. Existing canary entries1540  // (e.g. retained after a license deactivation, future-proofing) remain1541  // togglable off — gating only applies to enabling a new canary.1542  const isProGated = () => !props.isPro && !props.entry.canary;15431544  async function save() {1545    const url = webhookUrl().trim();1546    const nextLabel = label().trim() || undefined;1547    const canary = enabled() && url1548      ? {1549          webhookUrl: url,1550          label: nextLabel,1551          lastPinged: props.entry.canary?.lastPinged,1552          pingCount: props.entry.canary?.pingCount,1553        }1554      : undefined;1555    await sendMessage({1556      type: "UPDATE_ENTRY",1557      payload: { id: props.entry.id, patch: { canary } },1558    });1559    await props.onSaved();1560  }15611562  async function testPing() {1563    const url = webhookUrl().trim();1564    if (!url) return;1565    setTesting(true);1566    setTestResult(null);1567    const res = await sendMessage({1568      type: "FIRE_CANARY_PING",1569      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1570    }) as { ok: boolean; error?: string };1571    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1572    setTesting(false);1573  }15741575  return (1576    <div class="peach-canary-section">1577      <div class="peach-field-value-row">1578        <span class="peach-field-value" style="font-size:12px;">1579          canary alert1580          <Show when={isProGated()}>1581            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1582          </Show>1583        </span>1584        <button1585          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1586          onClick={() => {1587            if (isProGated()) {1588              // Pro gate: tap on free tier navigates to the upgrade screen1589              // with Canary Entries highlighted. The toggle never flips on1590              // for a free user, regardless of click count.1591              props.onProGate?.("canary_entry");1592              return;1593            }1594            setEnabled(!enabled());1595            void save();1596          }}1597          aria-label="Toggle canary"1598          aria-disabled={isProGated() ? "true" : "false"}1599        >1600          <span class="peach-toggle-knob" />1601        </button>1602      </div>1603      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1604        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1605      </p>1606      <Show when={enabled()}>1607        <div class="peach-inline-field-wrap" style="margin-top:8px;">1608          <input1609            class="peach-inline-input"1610            placeholder="https://hooks.zapier.com/..."1611            value={webhookUrl()}1612            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1613            onBlur={() => void save()}1614          />1615          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1616            When triggered, the entry title is sent to this URL. Use an endpoint you control.1617          </p>1618          <input1619            class="peach-inline-input"1620            placeholder="label (optional, e.g. AWS decoy)"1621            value={label()}1622            onInput={(e) => setLabel(e.currentTarget.value)}1623            onBlur={() => void save()}1624          />1625          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1626            <button1627              class="peach-btn peach-btn--ghost peach-btn--sm"1628              onClick={() => void testPing()}1629              disabled={testing() || !webhookUrl().trim()}1630            >1631              {testing() ? "sending..." : "test ping"}1632            </button>1633            <Show when={testResult()}>1634              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1635            </Show>1636          </div>1637          <Show when={props.entry.canary?.lastPinged}>1638            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1639              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1640              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1641            </p>1642          </Show>1643        </div>1644      </Show>1645    </div>1646  );1647}