273bcb437c64

add Notes Mode: SAVE_NOTE/DELETE_NOTE handlers, 6 new components/views, Notes button in vault header, crossfade overlay, expansion animation, autosave, swipe gestures (v2.39.0)

time
2026-05-13 14:14 UTC · 5 months ago
machine
koh2
parent
412457316a6b
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+26 −0src/popup/App.tsx+38 −0src/popup/components/NoteRow.tsx+194 −0src/popup/components/NoteRunes.tsx+159 −0src/popup/components/NoteToolbar.tsx+62 −0src/popup/styles/popup.css+574 −0src/popup/views/NotesEditor.tsx+267 −0src/popup/views/NotesMode.tsx+158 −0src/popup/views/NotesTOC.tsx+116 −0src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.38.21",4+  "version": "2.39.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.38.21",4+  "version": "2.39.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.38.13",3+  "version": "2.39.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+26 −0
@@ -518,6 +518,32 @@518518         const purged = await vault.purgeExpiredTrash(retentionMs);519519         return { ok: true, data: purged };520520       }521+      case "SAVE_NOTE": {522+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };523+        const { id, title, body } = msg.payload as { id?: string; title: string; body: string };524+        const now = Date.now();525+        if (id) {526+          const existing = vault.getEntryById(id);527+          if (!existing) return { ok: false, error: "not_found" };528+          await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });529+          return { ok: true, data: vault.getEntryById(id) };530+        } else {531+          const entry = await vault.addEntry({532+            type: "note",533+            title: title || "Untitled",534+            notes: body,535+            createdAt: now,536+            modifiedAt: now,537+          } as any);538+          return { ok: true, data: entry };539+        }540+      }541+      case "DELETE_NOTE": {542+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };543+        const { id } = msg.payload as { id: string };544+        await vault.deleteEntry(id);545+        return { ok: true };546+      }521547       case "AUTOFILL_QUERY": {522548         if (!vault.isUnlocked()) {523549           return { ok: false, error: "VAULT_LOCKED" };

view file

src/popup/App.tsx+38 −0
@@ -8,6 +8,7 @@88 import { isPasskeyOnly } from "../shared/types";99 import { LockView } from "./views/Lock";1010 import { Onboarding } from "./views/Onboarding";11+import { NotesMode } from "./views/NotesMode";1112 import { ProCelebration } from "./views/ProCelebration";1213 import { VaultView, type SortOrder } from "./views/Vault";1314 import { EntryView } from "./views/Entry";@@ -171,6 +172,8 @@171172   // onBack fires we return to Panic instead of Settings so the user keeps the172173   // destruction context they came from.173174   const [returnToPanic, setReturnToPanic] = createSignal(false);175+  const [showNotes, setShowNotes] = createSignal(false);176+  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);174177   // Set on popup boot when peach_recovery_pending flag is found in session175178   // storage — instructs LockView to skip the unlock UI and open the recovery176179   // words confirmation surface instead. Cleared via handleUnlock once the@@ -515,6 +518,12 @@515518     // decides what "back" means per surface. Lock+no-vault is the common case516519     // — return to onboarding rather than letting the OS dismiss the activity.517520     const handlePeachBack = () => {521+      // Notes mode captures back first522+      if (showNotes()) {523+        setShowNotes(false);524+        setOpenNoteId(undefined);525+        return;526+      }518527       const v = view();519528       if (v === "lock" && !hasVault() && onboarded()) {520529         setOnboarded(false);@@ -710,6 +719,7 @@710719 711720       // ── Escape — fixed binding (not user-customizable) ───────712721       if (e.key === "Escape") {722+        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }713723         if (v === "list") { setView("entry"); return; }714724         if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }715725         if (v === "sync-pairing") { setView("settings"); return; }@@ -993,6 +1003,11 @@9931003   }9941004 9951005   function handleSelectEntry(entry: VaultEntry) {1006+    if (entry.type === "note") {1007+      setOpenNoteId(entry.id);1008+      setShowNotes(true);1009+      return;1010+    }9961011     setSelectedEntry(entry);9971012     setContentTransitioning(false);9981013     setContentPhase("enter");@@ -1191,6 +1206,19 @@11911206               <div class="peach-chrome-actions">11921207                 <button11931208                   class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1209+                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1210+                  aria-label="Notes"1211+                  title="Notes"1212+                >1213+                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1214+                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1215+                    <line x1="4" y1="5" x2="10" y2="5" />1216+                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1217+                    <line x1="4" y1="10" x2="7" y2="10" />1218+                  </svg>1219+                </button>1220+                <button1221+                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"11941222                   onClick={() => setView("settings")}11951223                   aria-label="Settings"11961224                 >@@ -1536,6 +1564,16 @@15361564                   onRestored={() => void loadEntries()}15371565                 />15381566               </Suspense>1567+            </Show>1568+            <Show when={showNotes()}>1569+              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1570+                <NotesMode1571+                  notes={entries().filter((e) => e.type === "note")}1572+                  initialNoteId={openNoteId()}1573+                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1574+                  onNoteListChanged={() => void loadEntries()}1575+                />1576+              </div>15391577             </Show>15401578             <Show when={restoreView() === "file"}>15411579               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>

view file

src/popup/components/NoteRow.tsx+194 −0
@@ -0,0 +1,194 @@1+import { createSignal, onCleanup, onMount } from "solid-js";2+import type { VaultEntry } from "../../shared/types";3+4+function vibrate(ms: number) {5+  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }6+}7+8+function toRoman(n: number): string {9+  const VALS: [number, string][] = [10+    [1000,"M"],[900,"CM"],[500,"D"],[400,"CD"],[100,"C"],[90,"XC"],11+    [50,"L"],[40,"XL"],[10,"X"],[9,"IX"],[5,"V"],[4,"IV"],[1,"I"],12+  ];13+  let result = "";14+  for (const [val, sym] of VALS) {15+    while (n >= val) { result += sym; n -= val; }16+  }17+  return result;18+}19+20+function formatNoteDate(ts: number): string {21+  const now = Date.now();22+  const diff = now - ts;23+  const DAY = 86_400_000;24+  if (diff < DAY) return "Today";25+  if (diff < 2 * DAY) return "Yesterday";26+  const d = new Date(ts);27+  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });28+}29+30+const SWIPE_THRESHOLD = 80;31+const SWIPE_CONFIRM_THRESHOLD = 120;32+33+interface NoteRowProps {34+  note: VaultEntry;35+  index: number;36+  searchQuery?: string;37+  onTap: (note: VaultEntry, el: HTMLElement) => void;38+  onDelete: (note: VaultEntry) => void;39+  onArchive: (note: VaultEntry) => void;40+  fadingClass?: string;41+}42+43+export function NoteRow(props: NoteRowProps) {44+  let rowEl!: HTMLDivElement;45+  const [swipeX, setSwipeX] = createSignal(0);46+  const [swiping, setSwping] = createSignal(false);47+  const [confirmDelete, setConfirmDelete] = createSignal(false);48+49+  let touchStartX = 0;50+  let touchStartY = 0;51+  let captured = false;52+  let longPressTimer: number | undefined;53+54+  function handleTouchStart(e: TouchEvent) {55+    const t = e.touches[0];56+    if (!t) return;57+    touchStartX = t.clientX;58+    touchStartY = t.clientY;59+    captured = false;60+61+    longPressTimer = window.setTimeout(() => {62+      vibrate(60);63+      // Long press: context menu (future)64+    }, 500);65+  }66+67+  function handleTouchMove(e: TouchEvent) {68+    const t = e.touches[0];69+    if (!t) return;70+    const dx = t.clientX - touchStartX;71+    const dy = Math.abs(t.clientY - touchStartY);72+73+    if (!captured) {74+      if (dy > 8 && dy > Math.abs(dx)) {75+        // vertical scroll — abort76+        clearTimeout(longPressTimer);77+        return;78+      }79+      if (Math.abs(dx) > 8) {80+        captured = true;81+        clearTimeout(longPressTimer);82+        setSwping(true);83+      }84+      return;85+    }86+87+    setSwipeX(dx);88+  }89+90+  function handleTouchEnd(e: TouchEvent) {91+    clearTimeout(longPressTimer);92+    if (!captured) {93+      // Tap94+      props.onTap(props.note, rowEl);95+      vibrate(12);96+      setSwping(false);97+      return;98+    }99+100+    const dx = swipeX();101+    setSwping(false);102+103+    if (dx < -SWIPE_CONFIRM_THRESHOLD) {104+      // Committed delete swipe105+      vibrate(40);106+      setConfirmDelete(true);107+      setSwipeX(0);108+      setTimeout(() => setConfirmDelete(false), 4000);109+    } else if (dx > SWIPE_CONFIRM_THRESHOLD) {110+      // Archive111+      vibrate(12);112+      props.onArchive(props.note);113+      setSwipeX(0);114+    } else {115+      // Snap back116+      setSwipeX(0);117+    }118+    captured = false;119+  }120+121+  function handleTouchCancel() {122+    clearTimeout(longPressTimer);123+    setSwipeX(0);124+    setSwping(false);125+    captured = false;126+  }127+128+  // Highlight search match in title129+  function renderTitle() {130+    const title = props.note.title || "Untitled";131+    const q = props.searchQuery?.trim().toLowerCase();132+    if (!q) return <span class="peach-note-title">{title}</span>;133+    const idx = title.toLowerCase().indexOf(q);134+    if (idx === -1) return <span class="peach-note-title">{title}</span>;135+    return (136+      <span class="peach-note-title">137+        {title.slice(0, idx)}138+        <span class="peach-note-title-match">{title.slice(idx, idx + q.length)}</span>139+        {title.slice(idx + q.length)}140+      </span>141+    );142+  }143+144+  return (145+    <div146+      ref={rowEl}147+      class={`peach-note-row${props.fadingClass ? " " + props.fadingClass : ""}`}148+      style={(() => {149+        const dx = swipeX();150+        if (dx === 0) return undefined;151+        return { transform: `translateX(${dx}px)`, transition: swiping() ? "none" : "transform 250ms ease" };152+      })()}153+      onTouchStart={handleTouchStart}154+      onTouchMove={handleTouchMove}155+      onTouchEnd={handleTouchEnd}156+      onTouchCancel={handleTouchCancel}157+      onClick={() => {158+        if (!("ontouchstart" in window)) {159+          props.onTap(props.note, rowEl);160+        }161+      }}162+    >163+      {/* Delete underlay */}164+      <div class="peach-note-row-underlay peach-note-row-underlay--delete" style={{ opacity: Math.min(1, Math.max(0, -swipeX() / SWIPE_THRESHOLD)) }}>165+        DELETE166+      </div>167+      {/* Archive underlay */}168+      <div class="peach-note-row-underlay peach-note-row-underlay--archive" style={{ opacity: Math.min(1, Math.max(0, swipeX() / SWIPE_THRESHOLD)) }}>169+        ARCHIVE170+      </div>171+172+      <span class="peach-note-numeral">{toRoman(props.index + 1)}.</span>173+      <span class="peach-note-title-gap" />174+      {renderTitle()}175+      <span class="peach-note-date">{formatNoteDate(props.note.modifiedAt)}</span>176+177+      {confirmDelete() && (178+        <div class="peach-note-delete-confirm">179+          <span>Move to trash?</span>180+          <button class="peach-note-delete-confirm-btn" onClick={(e) => {181+            e.stopPropagation();182+            vibrate(60);183+            props.onDelete(props.note);184+            setConfirmDelete(false);185+          }}>Delete</button>186+          <button class="peach-note-delete-cancel-btn" onClick={(e) => {187+            e.stopPropagation();188+            setConfirmDelete(false);189+          }}>Cancel</button>190+        </div>191+      )}192+    </div>193+  );194+}

view file

src/popup/components/NoteRunes.tsx+159 −0
@@ -0,0 +1,159 @@1+import { onCleanup, onMount } from "solid-js";2+3+interface NoteRunesProps {4+  mode: "toc" | "editor";5+}6+7+const FONT_SIZE = 13;8+const RUNE_LEN = 120;9+10+function buildRuneString(): string {11+  const out: string[] = [];12+  for (let i = 0; i < RUNE_LEN; i++) {13+    out.push(String.fromCodePoint(0xe000 + ((i * 41 + 7) & 0xff)));14+  }15+  return out.join("");16+}17+18+const RUNES = buildRuneString();19+20+interface RuneState {21+  x: number;22+  y: number;23+  char: string;24+  vx: number;25+  vy: number;26+  opacity: number;27+  dwellMs: number;28+  elapsed: number;29+  fading: boolean;30+}31+32+function makeRune(w: number, h: number, mode: "toc" | "editor"): RuneState {33+  // Edge-biased placement: runes stay in margins34+  const MARGIN = 40;35+  const side = Math.random() < 0.5 ? "left" : "right";36+  const x = side === "left"37+    ? Math.random() * MARGIN38+    : w - MARGIN + Math.random() * MARGIN;39+  const y = Math.random() * h;40+41+  const [minOp, maxOp] = mode === "toc" ? [0.03, 0.06] : [0.02, 0.04];42+  const [minV, maxV] = mode === "toc" ? [0.04, 0.08] : [0.02, 0.05];43+  const [minD, maxD] = mode === "toc" ? [20000, 40000] : [30000, 60000];44+45+  const speed = minV + Math.random() * (maxV - minV);46+  const angle = Math.random() * Math.PI * 2;47+  const charIdx = Math.floor(Math.random() * RUNES.length);48+49+  return {50+    x,51+    y,52+    char: RUNES[charIdx]!,53+    vx: Math.cos(angle) * speed,54+    vy: Math.sin(angle) * speed,55+    opacity: minOp + Math.random() * (maxOp - minOp),56+    dwellMs: minD + Math.random() * (maxD - minD),57+    elapsed: 0,58+    fading: false,59+  };60+}61+62+export function NoteRunes(props: NoteRunesProps) {63+  let canvas!: HTMLCanvasElement;64+  let raf = 0;65+  let running = false;66+67+  onMount(() => {68+    const ctx = canvas.getContext("2d");69+    if (!ctx) return;70+71+    const W = canvas.width;72+    const H = canvas.height;73+    const count = props.mode === "toc" ? 3 : 2;74+    const runes: RuneState[] = Array.from({ length: count }, () => makeRune(W, H, props.mode));75+76+    let last = performance.now();77+    const FADE_MS = 600;78+79+    const frame = (now: number) => {80+      const dt = Math.min(now - last, 64);81+      last = now;82+83+      ctx.clearRect(0, 0, W, H);84+      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;85+      ctx.textBaseline = "alphabetic";86+87+      for (let i = 0; i < runes.length; i++) {88+        const r = runes[i]!;89+        r.elapsed += dt;90+91+        // Move slowly92+        r.x += r.vx * dt;93+        r.y += r.vy * dt;94+95+        // Clamp to canvas (bounce off edges)96+        if (r.x < 0 || r.x > W) r.vx = -r.vx;97+        if (r.y < 0 || r.y > H) r.vy = -r.vy;98+99+        let alpha = r.opacity;100+        const remaining = r.dwellMs - r.elapsed;101+102+        // Fade in over first FADE_MS103+        if (r.elapsed < FADE_MS) {104+          alpha *= r.elapsed / FADE_MS;105+        }106+        // Fade out over last FADE_MS107+        if (remaining < FADE_MS && remaining > 0) {108+          alpha *= remaining / FADE_MS;109+        }110+111+        // Replace rune when dwell completes112+        if (r.elapsed >= r.dwellMs) {113+          runes[i] = makeRune(W, H, props.mode);114+          continue;115+        }116+117+        ctx.fillStyle = `rgba(245, 240, 234, ${alpha})`;118+        ctx.fillText(r.char, r.x, r.y);119+      }120+121+      raf = requestAnimationFrame(frame);122+    };123+124+    const start = () => {125+      if (running) return;126+      running = true;127+      last = performance.now();128+      raf = requestAnimationFrame(frame);129+    };130+131+    const stop = () => {132+      if (!running) return;133+      running = false;134+      cancelAnimationFrame(raf);135+    };136+137+    const onVisibility = () => {138+      if (document.hidden) stop(); else start();139+    };140+    document.addEventListener("visibilitychange", onVisibility);141+142+    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);143+144+    onCleanup(() => {145+      stop();146+      document.removeEventListener("visibilitychange", onVisibility);147+    });148+  });149+150+  return (151+    <canvas152+      ref={canvas}153+      width={400}154+      height={700}155+      class="peach-note-rune-canvas"156+      aria-hidden="true"157+    />158+  );159+}

view file

src/popup/components/NoteToolbar.tsx+62 −0
@@ -0,0 +1,62 @@1+import { Show } from "solid-js";2+import { isNativePlatform } from "../../shared/native";3+4+export type ToolbarExec = (cmd: string, val?: string) => void;5+6+function applyBold(exec: ToolbarExec) { exec("bold"); }7+function applyItalic(exec: ToolbarExec) { exec("italic"); }8+function applyUnderline(exec: ToolbarExec) { exec("underline"); }9+function applyHeading(exec: ToolbarExec) {10+  const val = document.queryCommandValue("formatBlock").toLowerCase();11+  exec("formatBlock", val === "h2" ? "p" : "h2");12+}13+function applyQuote(exec: ToolbarExec) {14+  const val = document.queryCommandValue("formatBlock").toLowerCase();15+  exec("formatBlock", val === "blockquote" ? "p" : "blockquote");16+}17+function applyBullet(exec: ToolbarExec) { exec("insertUnorderedList"); }18+function applyNumbered(exec: ToolbarExec) { exec("insertOrderedList"); }19+20+interface ToolbarProps {21+  exec: ToolbarExec;22+  keyboardVisible?: boolean;23+  onDismissKeyboard?: () => void;24+  class?: string;25+}26+27+export function NoteToolbar(props: ToolbarProps) {28+  return (29+    <Show30+      when={isNativePlatform()}31+      fallback={32+        // Desktop: toolbar rendered inline above the body, shown always33+        <div class={props.class ?? "peach-note-toolbar-desktop-inline"}>34+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBold(props.exec); }} title="Bold">B</button>35+          <button class="peach-note-toolbar-btn peach-note-toolbar-btn--italic" onMouseDown={(e) => { e.preventDefault(); applyItalic(props.exec); }} title="Italic">I</button>36+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyUnderline(props.exec); }} title="Underline">U</button>37+          <div class="peach-note-toolbar-divider" />38+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyHeading(props.exec); }} title="Heading">H</button>39+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyQuote(props.exec); }} title="Quote">"</button>40+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBullet(props.exec); }} title="Bullet">•</button>41+          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyNumbered(props.exec); }} title="Numbered">1.</button>42+        </div>43+      }44+    >45+      {/* Mobile: sits above keyboard when keyboard is visible */}46+      <Show when={props.keyboardVisible}>47+        <div class={props.class ?? "peach-note-toolbar-mobile"}>48+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyBold(props.exec); }}>B</button>49+          <button class="peach-note-toolbar-btn peach-note-toolbar-btn--italic" onTouchEnd={(e) => { e.preventDefault(); applyItalic(props.exec); }}>I</button>50+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyUnderline(props.exec); }}>U</button>51+          <div class="peach-note-toolbar-divider" />52+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyHeading(props.exec); }}>H</button>53+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyQuote(props.exec); }}>"</button>54+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyBullet(props.exec); }}>•</button>55+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); applyNumbered(props.exec); }}>1.</button>56+          <div class="peach-note-toolbar-spacer" />57+          <button class="peach-note-toolbar-btn" onTouchEnd={(e) => { e.preventDefault(); props.onDismissKeyboard?.(); }}>⌨</button>58+        </div>59+      </Show>60+    </Show>61+  );62+}

view file

src/popup/styles/popup.css+574 −0
@@ -8112,3 +8112,577 @@81128112   border-radius: 8px;81138113   pointer-events: none;81148114 }8115+8116+/* ── Notes Mode ── */8117+8118+/* Overlay that crossfades over the vault */8119+.peach-notes-overlay {8120+  position: fixed;8121+  inset: 0;8122+  z-index: 90;8123+  opacity: 0;8124+  pointer-events: none;8125+  animation: peach-mode-in 220ms ease-in forwards;8126+}8127+8128+.peach-notes-overlay.is-visible {8129+  opacity: 1;8130+  pointer-events: auto;8131+}8132+8133+@keyframes peach-mode-in {8134+  from { opacity: 0; }8135+  to   { opacity: 1; }8136+}8137+8138+/* Top-level notes surface */8139+.peach-notes-mode {8140+  position: absolute;8141+  inset: 0;8142+  background: var(--peach-bg);8143+  display: flex;8144+  flex-direction: column;8145+}8146+8147+/* Rune canvas positioned behind content */8148+.peach-note-rune-canvas {8149+  position: absolute;8150+  inset: 0;8151+  width: 100%;8152+  height: 100%;8153+  pointer-events: none;8154+  z-index: 0;8155+}8156+8157+/* TOC wrapper */8158+.peach-notes-toc-wrapper {8159+  position: absolute;8160+  inset: 0;8161+  transition: opacity 180ms ease-out;8162+  z-index: 1;8163+}8164+8165+.peach-notes-toc-wrapper.is-visible { opacity: 1; pointer-events: auto; }8166+.peach-notes-toc-wrapper.is-expanding { opacity: 0; pointer-events: none; transition: opacity 150ms ease-out; }8167+.peach-notes-toc-wrapper.is-collapsing { opacity: 1; pointer-events: none; }8168+8169+/* Editor wrapper */8170+.peach-notes-editor-wrapper {8171+  position: absolute;8172+  inset: 0;8173+  opacity: 0;8174+  pointer-events: none;8175+  z-index: 2;8176+  transition: opacity 220ms ease-in;8177+}8178+8179+.peach-notes-editor-wrapper.is-visible {8180+  opacity: 1;8181+  pointer-events: auto;8182+}8183+8184+.peach-notes-editor-wrapper.is-entering {8185+  animation: peach-note-editor-enter 220ms 180ms ease-in both;8186+}8187+8188+.peach-notes-editor-wrapper.is-leaving {8189+  animation: peach-note-editor-leave 200ms ease-out both;8190+}8191+8192+@keyframes peach-note-editor-enter {8193+  from { opacity: 0; transform: translateY(6px); }8194+  to   { opacity: 1; transform: translateY(0); }8195+}8196+8197+@keyframes peach-note-editor-leave {8198+  from { opacity: 1; transform: translateY(0); }8199+  to   { opacity: 0; transform: translateY(6px); }8200+}8201+8202+/* TOC inner */8203+.peach-notes-toc {8204+  position: relative;8205+  display: flex;8206+  flex-direction: column;8207+  height: 100%;8208+  padding-top: env(safe-area-inset-top, 0px);8209+  padding-bottom: env(safe-area-inset-bottom, 0px);8210+}8211+8212+/* Shared header */8213+.peach-notes-header {8214+  display: flex;8215+  align-items: center;8216+  padding: 12px 16px;8217+  gap: 12px;8218+  flex-shrink: 0;8219+  min-height: 52px;8220+  position: relative;8221+  z-index: 2;8222+}8223+8224+.peach-notes-back {8225+  font-size: 22px;8226+  color: var(--peach-t2);8227+  background: none;8228+  border: none;8229+  padding: 4px;8230+  cursor: pointer;8231+  line-height: 1;8232+  flex-shrink: 0;8233+  transition: color 150ms ease;8234+  -webkit-tap-highlight-color: transparent;8235+}8236+8237+.peach-notes-back:active { color: var(--peach-t0); }8238+8239+.peach-notes-header-title {8240+  flex: 1;8241+  font-family: 'Cormorant Garamond', serif;8242+  font-style: italic;8243+  font-size: 22px;8244+  color: var(--peach-t0);8245+  text-align: center;8246+}8247+8248+.peach-notes-header-title.is-breadcrumb {8249+  font-family: 'IBM Plex Mono', monospace;8250+  font-style: normal;8251+  font-size: 11px;8252+  color: var(--peach-t3);8253+  letter-spacing: 0.08em;8254+  text-transform: uppercase;8255+  cursor: pointer;8256+}8257+8258+.peach-notes-header-actions {8259+  display: flex;8260+  gap: 8px;8261+  flex-shrink: 0;8262+}8263+8264+.peach-notes-header-btn {8265+  width: 36px;8266+  height: 36px;8267+  display: flex;8268+  align-items: center;8269+  justify-content: center;8270+  background: none;8271+  border: none;8272+  color: var(--peach-t2);8273+  cursor: pointer;8274+  border-radius: 8px;8275+  font-size: 16px;8276+  transition: color 150ms ease, background 150ms ease;8277+  -webkit-tap-highlight-color: transparent;8278+}8279+8280+.peach-notes-header-btn:active {8281+  background: rgba(255,255,255,0.06);8282+  color: var(--peach-t0);8283+}8284+8285+/* Inline search bar */8286+.peach-notes-search-input {8287+  flex: 1;8288+  background: none;8289+  border: none;8290+  outline: none;8291+  font-family: 'IBM Plex Mono', monospace;8292+  font-size: 13px;8293+  color: var(--peach-t0);8294+  padding: 4px 0;8295+  letter-spacing: 0.02em;8296+}8297+8298+.peach-notes-search-input::placeholder {8299+  color: var(--peach-t3);8300+}8301+8302+/* TOC list */8303+.peach-notes-list-container {8304+  flex: 1;8305+  overflow-y: auto;8306+  overflow-x: hidden;8307+  scrollbar-width: none;8308+  -webkit-overflow-scrolling: touch;8309+  overscroll-behavior: contain;8310+  position: relative;8311+  z-index: 1;8312+}8313+8314+.peach-notes-list-container::-webkit-scrollbar { display: none; }8315+8316+/* Note row */8317+.peach-note-row {8318+  position: relative;8319+  display: grid;8320+  grid-template-columns: 44px 8px 1fr 8px auto;8321+  align-items: center;8322+  min-height: 72px;8323+  padding: 16px 20px;8324+  border-bottom: 0.5px solid var(--peach-border);8325+  cursor: pointer;8326+  overflow: hidden;8327+  touch-action: pan-y;8328+  user-select: none;8329+  -webkit-user-select: none;8330+  -webkit-tap-highlight-color: transparent;8331+}8332+8333+.peach-note-row:first-child {8334+  border-top: 0.5px solid var(--peach-border);8335+}8336+8337+.peach-note-row:active {8338+  background: rgba(255,255,255,0.02);8339+}8340+8341+/* Row fade animations (expansion) */8342+.peach-note-row.is-expanding-away {8343+  transition: opacity 150ms ease, transform 150ms ease;8344+  opacity: 0;8345+}8346+8347+.peach-note-row.is-expanding-away-up { transform: translateY(-6px); }8348+.peach-note-row.is-expanding-away-down { transform: translateY(6px); }8349+8350+.peach-note-numeral {8351+  font-family: 'Cormorant Garamond', serif;8352+  font-style: italic;8353+  font-size: 15px;8354+  color: var(--peach-accent);8355+  text-align: right;8356+  line-height: 1;8357+  flex-shrink: 0;8358+}8359+8360+.peach-note-title-gap { /* grid spacer */ }8361+8362+.peach-note-title {8363+  font-family: 'Cormorant Garamond', serif;8364+  font-style: italic;8365+  font-size: 18px;8366+  color: var(--peach-t0);8367+  white-space: nowrap;8368+  overflow: hidden;8369+  text-overflow: ellipsis;8370+  line-height: 1.3;8371+  min-width: 0;8372+}8373+8374+.peach-note-title-match {8375+  color: var(--peach-accent);8376+}8377+8378+.peach-note-date {8379+  font-family: 'IBM Plex Mono', monospace;8380+  font-size: 11px;8381+  color: var(--peach-t2);8382+  white-space: nowrap;8383+  text-align: right;8384+  flex-shrink: 0;8385+}8386+8387+/* Swipe underlays */8388+.peach-note-row-underlay {8389+  position: absolute;8390+  inset: 0;8391+  display: flex;8392+  align-items: center;8393+  padding: 0 24px;8394+  font-family: 'IBM Plex Mono', monospace;8395+  font-size: 11px;8396+  letter-spacing: 0.1em;8397+  pointer-events: none;8398+  z-index: 0;8399+}8400+8401+.peach-note-row-underlay--delete {8402+  background: #8B2020;8403+  color: rgba(255,255,255,0.9);8404+  justify-content: flex-end;8405+}8406+8407+.peach-note-row-underlay--archive {8408+  background: var(--peach-accent);8409+  color: #0C0A06;8410+  justify-content: flex-start;8411+}8412+8413+/* Delete confirm strip */8414+.peach-note-delete-confirm {8415+  position: absolute;8416+  inset: 0;8417+  background: var(--peach-bg);8418+  display: flex;8419+  align-items: center;8420+  gap: 12px;8421+  padding: 0 20px;8422+  font-family: 'IBM Plex Mono', monospace;8423+  font-size: 11px;8424+  color: var(--peach-t1);8425+  letter-spacing: 0.04em;8426+  animation: peach-delete-confirm-in 150ms ease both;8427+  z-index: 10;8428+}8429+8430+@keyframes peach-delete-confirm-in {8431+  from { opacity: 0; }8432+  to   { opacity: 1; }8433+}8434+8435+.peach-note-delete-confirm-btn {8436+  background: #8B2020;8437+  color: rgba(255,255,255,0.9);8438+  border: none;8439+  border-radius: 6px;8440+  padding: 4px 10px;8441+  font-family: 'IBM Plex Mono', monospace;8442+  font-size: 10px;8443+  cursor: pointer;8444+  letter-spacing: 0.06em;8445+}8446+8447+.peach-note-delete-cancel-btn {8448+  background: none;8449+  border: none;8450+  color: var(--peach-t3);8451+  font-family: 'IBM Plex Mono', monospace;8452+  font-size: 10px;8453+  cursor: pointer;8454+  letter-spacing: 0.06em;8455+  padding: 4px 6px;8456+}8457+8458+/* + New note button */8459+.peach-notes-new-btn {8460+  font-family: 'IBM Plex Mono', monospace;8461+  font-size: 12px;8462+  color: var(--peach-t3);8463+  text-align: center;8464+  padding: 28px 20px calc(28px + env(safe-area-inset-bottom, 0px));8465+  background: none;8466+  border: none;8467+  width: 100%;8468+  cursor: pointer;8469+  letter-spacing: 0.04em;8470+  transition: color 150ms ease;8471+  -webkit-tap-highlight-color: transparent;8472+}8473+8474+.peach-notes-new-btn:active { color: var(--peach-accent); }8475+8476+/* Empty state */8477+.peach-notes-empty {8478+  display: flex;8479+  flex-direction: column;8480+  align-items: center;8481+  justify-content: center;8482+  flex: 1;8483+  padding: 48px 32px;8484+  gap: 16px;8485+}8486+8487+.peach-notes-empty-text {8488+  font-family: 'Cormorant Garamond', serif;8489+  font-style: italic;8490+  font-size: 20px;8491+  color: var(--peach-t3);8492+  text-align: center;8493+  margin: 0;8494+  line-height: 1.4;8495+  white-space: pre-line;8496+}8497+8498+/* ── Editor surface ── */8499+8500+.peach-note-editor-surface {8501+  position: absolute;8502+  inset: 0;8503+  background: var(--peach-bg);8504+  display: flex;8505+  flex-direction: column;8506+  padding-top: env(safe-area-inset-top, 0px);8507+}8508+8509+.peach-note-editor-scroll {8510+  flex: 1;8511+  overflow-y: auto;8512+  overflow-x: hidden;8513+  scrollbar-width: none;8514+  -webkit-overflow-scrolling: touch;8515+  padding: 0 24px 120px;8516+  position: relative;8517+  z-index: 1;8518+}8519+8520+.peach-note-editor-scroll::-webkit-scrollbar { display: none; }8521+8522+/* Desktop toolbar (always visible above body) */8523+.peach-note-toolbar-desktop-inline {8524+  display: flex;8525+  align-items: center;8526+  gap: 2px;8527+  padding: 6px 0 12px;8528+  margin-bottom: 4px;8529+  border-bottom: 0.5px solid var(--peach-border);8530+}8531+8532+/* Title field */8533+.peach-note-title-field {8534+  font-family: 'Cormorant Garamond', serif;8535+  font-style: italic;8536+  font-size: 32px;8537+  font-weight: 600;8538+  color: var(--peach-t0);8539+  line-height: 1.2;8540+  padding: 8px 0 0;8541+  outline: none;8542+  min-height: 44px;8543+  word-break: break-word;8544+  position: relative;8545+  z-index: 1;8546+}8547+8548+.peach-note-title-field:empty::before {8549+  content: attr(data-placeholder);8550+  color: var(--peach-t3);8551+  pointer-events: none;8552+}8553+8554+/* Hairline separator */8555+.peach-note-hairline {8556+  height: 0.5px;8557+  background: var(--peach-border);8558+  margin: 12px 0 20px;8559+  position: relative;8560+  z-index: 1;8561+}8562+8563+/* Body field */8564+.peach-note-body-field {8565+  font-family: 'DM Sans', sans-serif;8566+  font-size: 16px;8567+  color: var(--peach-t0);8568+  line-height: 1.85;8569+  padding: 0;8570+  outline: none;8571+  min-height: 240px;8572+  word-break: break-word;8573+  position: relative;8574+  z-index: 1;8575+}8576+8577+.peach-note-body-field:empty::before {8578+  content: attr(data-placeholder);8579+  color: var(--peach-t3);8580+  font-style: italic;8581+  pointer-events: none;8582+}8583+8584+.peach-note-body-field h2 {8585+  font-family: 'Cormorant Garamond', serif;8586+  font-style: italic;8587+  font-size: 22px;8588+  font-weight: 600;8589+  color: var(--peach-t0);8590+  margin: 20px 0 8px;8591+}8592+8593+.peach-note-body-field blockquote {8594+  border-left: 2px solid var(--peach-accent);8595+  margin: 12px 0;8596+  padding-left: 16px;8597+  color: var(--peach-t1);8598+  font-style: italic;8599+}8600+8601+.peach-note-body-field ul,8602+.peach-note-body-field ol {8603+  padding-left: 20px;8604+  margin: 8px 0;8605+}8606+8607+.peach-note-body-field li {8608+  margin: 4px 0;8609+}8610+8611+.peach-note-body-field hr {8612+  border: none;8613+  border-top: 0.5px solid var(--peach-border);8614+  margin: 16px 0;8615+}8616+8617+/* Saved indicator */8618+.peach-note-saved-indicator {8619+  font-family: 'IBM Plex Mono', monospace;8620+  font-size: 11px;8621+  color: var(--peach-t3);8622+  letter-spacing: 0.04em;8623+  text-align: right;8624+  padding: 6px 24px;8625+  flex-shrink: 0;8626+  transition: opacity 300ms ease;8627+  pointer-events: none;8628+  position: relative;8629+  z-index: 1;8630+}8631+8632+/* Mobile keyboard toolbar */8633+.peach-note-toolbar-mobile {8634+  display: flex;8635+  align-items: center;8636+  padding: 8px 16px;8637+  padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));8638+  background: var(--peach-bg);8639+  border-top: 0.5px solid var(--peach-border);8640+  gap: 4px;8641+  flex-shrink: 0;8642+  position: relative;8643+  z-index: 10;8644+}8645+8646+.peach-note-toolbar-btn {8647+  min-width: 36px;8648+  height: 36px;8649+  display: flex;8650+  align-items: center;8651+  justify-content: center;8652+  background: none;8653+  border: none;8654+  border-radius: 8px;8655+  cursor: pointer;8656+  font-size: 14px;8657+  color: var(--peach-t1);8658+  font-family: 'DM Sans', sans-serif;8659+  font-weight: 600;8660+  transition: background 100ms ease, color 100ms ease;8661+  -webkit-tap-highlight-color: transparent;8662+}8663+8664+.peach-note-toolbar-btn--italic { font-style: italic; }8665+.peach-note-toolbar-btn.is-active { color: var(--peach-accent); }8666+.peach-note-toolbar-btn:active { background: rgba(255,255,255,0.08); }8667+8668+.peach-note-toolbar-divider {8669+  width: 0.5px;8670+  height: 20px;8671+  background: var(--peach-border);8672+  margin: 0 4px;8673+  flex-shrink: 0;8674+}8675+8676+.peach-note-toolbar-spacer { flex: 1; }8677+8678+/* Desktop: larger headline in TOC */8679+@media (min-width: 400px) {8680+  .peach-notes-header-title:not(.is-breadcrumb) {8681+    font-size: 32px;8682+  }8683+8684+  .peach-note-row {8685+    min-height: 64px;8686+    padding: 14px 16px;8687+  }8688+}

view file

src/popup/views/NotesEditor.tsx+267 −0
@@ -0,0 +1,267 @@1+import { createSignal, onCleanup, onMount, Show } from "solid-js";2+import type { VaultEntry } from "../../shared/types";3+import { sendMessage } from "../../shared/bridge";4+import { isNativePlatform } from "../../shared/native";5+import { NoteRunes } from "../components/NoteRunes";6+import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";7+8+const HEADER_HEIGHT = 52;9+const AUTOSAVE_MS = 800;10+const SAVED_DISPLAY_MS = 1500;11+12+interface NotesEditorProps {13+  note: VaultEntry | null; // null = new note14+  onBack: () => void;15+  onSaved: (note: VaultEntry) => void;16+  onDelete: (note: VaultEntry) => void;17+  autoFocusTitle?: boolean;18+}19+20+export function NotesEditor(props: NotesEditorProps) {21+  let titleRef!: HTMLDivElement;22+  let bodyRef!: HTMLDivElement;23+  let contentScrollRef!: HTMLDivElement;24+25+  const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);26+  const [title, setTitle] = createSignal(props.note?.title ?? "");27+  const [body, setBody] = createSignal(props.note?.notes ?? "");28+  const [saved, setSaved] = createSignal(false);29+  const [keyboardVisible, setKeyboardVisible] = createSignal(false);30+  const [contentHeight, setContentHeight] = createSignal<number | null>(null);31+  const [dirty, setDirty] = createSignal(false);32+33+  let saveTimer: number | null = null;34+  let savedFadeTimer: number | null = null;35+36+  function scheduleAutosave() {37+    setDirty(true);38+    if (saveTimer) clearTimeout(saveTimer);39+    saveTimer = window.setTimeout(save, AUTOSAVE_MS);40+  }41+42+  async function save() {43+    const t = title();44+    const b = body();45+    // Don't save if genuinely empty (new note, nothing typed)46+    if (!t.trim() && !b.trim()) return;47+    const res = await sendMessage({48+      type: "SAVE_NOTE",49+      payload: { id: noteId(), title: t || "Untitled", body: b },50+    }) as { ok: boolean; data?: VaultEntry };51+    if (res?.ok && res.data) {52+      setNoteId(res.data.id);53+      setDirty(false);54+      showSaved();55+      props.onSaved(res.data);56+    }57+  }58+59+  function showSaved() {60+    setSaved(true);61+    if (savedFadeTimer) clearTimeout(savedFadeTimer);62+    savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);63+  }64+65+  function exec(cmd: string, val?: string) {66+    document.execCommand(cmd, false, val);67+    bodyRef?.focus();68+    scheduleAutosave();69+  }70+71+  function handleBack() {72+    // Final save before going back (only if there's content and it's dirty)73+    if (dirty() && (title().trim() || body().trim())) {74+      if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }75+      void save();76+    } else if (!noteId() && !title().trim() && !body().trim()) {77+      // Empty new note — discard78+    }79+    props.onBack();80+  }81+82+  function handleKeyboardResize() {83+    if (!isNativePlatform()) return;84+    const vp = window.visualViewport;85+    const viewportH = vp?.height ?? window.innerHeight;86+    const kbH = window.innerHeight - viewportH;87+    if (kbH > 50) {88+      setKeyboardVisible(true);89+      setContentHeight(viewportH - HEADER_HEIGHT);90+      if (document.activeElement) {91+        (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });92+      }93+    } else {94+      setKeyboardVisible(false);95+      setContentHeight(null);96+    }97+  }98+99+  onMount(() => {100+    // Populate editor content101+    if (titleRef) titleRef.textContent = props.note?.title ?? "";102+    if (bodyRef) bodyRef.innerHTML = props.note?.notes ?? "";103+104+    if (props.autoFocusTitle && titleRef) {105+      setTimeout(() => {106+        titleRef.focus();107+        // Place cursor at end108+        const range = document.createRange();109+        const sel = window.getSelection();110+        range.selectNodeContents(titleRef);111+        range.collapse(false);112+        sel?.removeAllRanges();113+        sel?.addRange(range);114+      }, 300);115+    }116+117+    if (isNativePlatform()) {118+      window.addEventListener("resize", handleKeyboardResize);119+    }120+121+    onCleanup(() => {122+      if (saveTimer) clearTimeout(saveTimer);123+      if (savedFadeTimer) clearTimeout(savedFadeTimer);124+      if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);125+    });126+  });127+128+  // Swipe right from left edge to go back (mobile)129+  let swipeStartX = 0;130+  let swipeStartY = 0;131+  let swipeCaptured = false;132+133+  function handleSwipeStart(e: TouchEvent) {134+    const t = e.touches[0];135+    if (!t) return;136+    swipeStartX = t.clientX;137+    swipeStartY = t.clientY;138+    swipeCaptured = false;139+  }140+141+  function handleSwipeMove(e: TouchEvent) {142+    const t = e.touches[0];143+    if (!t) return;144+    const dx = t.clientX - swipeStartX;145+    const dy = Math.abs(t.clientY - swipeStartY);146+    if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {147+      swipeCaptured = true;148+    }149+  }150+151+  function handleSwipeEnd() {152+    if (swipeCaptured) {153+      handleBack();154+    }155+    swipeCaptured = false;156+  }157+158+  const execFn: ToolbarExec = exec;159+160+  return (161+    <div162+      class="peach-note-editor-surface"163+      onTouchStart={handleSwipeStart}164+      onTouchMove={handleSwipeMove}165+      onTouchEnd={handleSwipeEnd}166+    >167+      <NoteRunes mode="editor" />168+169+      {/* Header */}170+      <div class="peach-notes-header">171+        <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>172+        <span class="peach-notes-header-title is-breadcrumb" onClick={handleBack}>notes</span>173+        <div class="peach-notes-header-actions">174+          <Show when={noteId()}>175+            <button176+              class="peach-notes-header-btn"177+              aria-label="Delete note"178+              onClick={() => {179+                const id = noteId();180+                if (!id) return;181+                // Find entry by id for callback182+                props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);183+              }}184+              title="Delete note"185+            >186+              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">187+                <polyline points="1,3.5 13,3.5" />188+                <path d="M5 3.5V2h4v1.5" />189+                <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />190+                <line x1="5.5" y1="6.5" x2="5.5" y2="10" />191+                <line x1="8.5" y1="6.5" x2="8.5" y2="10" />192+              </svg>193+            </button>194+          </Show>195+        </div>196+      </div>197+198+      {/* Scrollable content */}199+      <div200+        ref={contentScrollRef}201+        class="peach-note-editor-scroll"202+        style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}203+      >204+        {/* Desktop toolbar — above content */}205+        <Show when={!isNativePlatform()}>206+          <NoteToolbar exec={execFn} />207+        </Show>208+209+        <div210+          ref={titleRef}211+          class="peach-note-title-field"212+          contenteditable="true"213+          data-placeholder="Title"214+          onInput={() => {215+            setTitle(titleRef.textContent ?? "");216+            scheduleAutosave();217+          }}218+          onKeyDown={(e) => {219+            if (e.key === "Enter") {220+              e.preventDefault();221+              bodyRef?.focus();222+            }223+          }}224+          aria-label="Note title"225+        />226+227+        <div class="peach-note-hairline" />228+229+        <div230+          ref={bodyRef}231+          class="peach-note-body-field"232+          contenteditable="true"233+          data-placeholder="Begin writing…"234+          onInput={() => {235+            setBody(bodyRef.innerHTML ?? "");236+            scheduleAutosave();237+          }}238+          onKeyDown={(e) => {239+            const mod = e.metaKey || e.ctrlKey;240+            if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }241+            if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }242+            if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }243+          }}244+          aria-label="Note body"245+          aria-multiline="true"246+        />247+      </div>248+249+      {/* Saved indicator */}250+      <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>251+        saved.252+      </div>253+254+      {/* Mobile keyboard toolbar */}255+      <Show when={isNativePlatform()}>256+        <NoteToolbar257+          exec={execFn}258+          keyboardVisible={keyboardVisible()}259+          onDismissKeyboard={() => {260+            titleRef?.blur();261+            bodyRef?.blur();262+          }}263+        />264+      </Show>265+    </div>266+  );267+}

view file

src/popup/views/NotesMode.tsx+158 −0
@@ -0,0 +1,158 @@1+import { createSignal, onCleanup, onMount, Show } from "solid-js";2+import type { VaultEntry } from "../../shared/types";3+import { sendMessage } from "../../shared/bridge";4+import { NotesTOC } from "./NotesTOC";5+import { NotesEditor } from "./NotesEditor";6+7+interface NotesModeProps {8+  onBack: () => void;9+  onNoteListChanged: () => void;10+  initialNoteId?: string;11+  notes: VaultEntry[];12+}13+14+type Phase = "toc" | "expanding" | "editor" | "collapsing";15+16+interface ExpansionOrigin {17+  noteId: string;18+  top: number;19+  fontSize: number;20+}21+22+let containerEl: HTMLDivElement | null = null;23+24+export function NotesMode(props: NotesModeProps) {25+  let modeEl!: HTMLDivElement;26+  let flyingTitleEl: HTMLSpanElement | undefined;27+28+  const [phase, setPhase] = createSignal<Phase>("toc");29+  const [activeNote, setActiveNote] = createSignal<VaultEntry | null>(null);30+  const [isNewNote, setIsNewNote] = createSignal(false);31+  const [origin, setOrigin] = createSignal<ExpansionOrigin | null>(null);32+  const [editorVisible, setEditorVisible] = createSignal(false);33+34+  onMount(() => {35+    containerEl = modeEl;36+37+    // If an initialNoteId was passed (from global search), open it immediately38+    if (props.initialNoteId) {39+      const note = props.notes.find((n) => n.id === props.initialNoteId);40+      if (note) {41+        // Open without expansion animation — just direct to editor42+        setActiveNote(note);43+        setPhase("editor");44+        setEditorVisible(true);45+      }46+    }47+48+    onCleanup(() => { containerEl = null; });49+  });50+51+  function handleNoteRowTap(note: VaultEntry, rowEl: HTMLElement) {52+    if (phase() !== "toc") return;53+    const rect = rowEl.getBoundingClientRect();54+    const containerRect = modeEl.getBoundingClientRect();55+    setOrigin({56+      noteId: note.id,57+      top: rect.top - containerRect.top,58+      fontSize: 18,59+    });60+    setActiveNote(note);61+    setIsNewNote(false);62+    startExpansion();63+  }64+65+  function handleNewNote() {66+    if (phase() !== "toc") return;67+    // New note expands from the bottom68+    const containerRect = modeEl.getBoundingClientRect();69+    setOrigin({70+      noteId: "",71+      top: containerRect.height - 60,72+      fontSize: 14,73+    });74+    setActiveNote(null);75+    setIsNewNote(true);76+    startExpansion();77+  }78+79+  function startExpansion() {80+    setPhase("expanding");81+    // RAF to let the flying title render at its start position82+    requestAnimationFrame(() => {83+      requestAnimationFrame(() => {84+        setEditorVisible(true);85+        setPhase("editor");86+      });87+    });88+  }89+90+  function handleEditorBack() {91+    if (phase() !== "editor") return;92+    setPhase("collapsing");93+    setEditorVisible(false);94+    setTimeout(() => {95+      setActiveNote(null);96+      setIsNewNote(false);97+      setOrigin(null);98+      setPhase("toc");99+    }, 350);100+  }101+102+  function handleSaved(note: VaultEntry) {103+    // If this was a new note, now it has an id — update active note104+    if (activeNote()?.id !== note.id) {105+      setActiveNote(note);106+    }107+    props.onNoteListChanged();108+  }109+110+  function handleDeleteNote(note: VaultEntry) {111+    void sendMessage({ type: "DELETE_NOTE", payload: { id: note.id } });112+    props.onNoteListChanged();113+    handleEditorBack();114+  }115+116+  return (117+    <div ref={modeEl} class="peach-notes-mode" data-phase={phase()}>118+      {/* TOC — always mounted, hidden during editor phase */}119+      <div120+        class="peach-notes-toc-wrapper"121+        classList={{122+          "is-visible": phase() === "toc",123+          "is-expanding": phase() === "expanding",124+          "is-collapsing": phase() === "collapsing",125+        }}126+        aria-hidden={phase() !== "toc"}127+      >128+        <NotesTOC129+          notes={props.notes}130+          onBack={props.onBack}131+          onOpenNote={handleNoteRowTap}132+          onNewNote={handleNewNote}133+          onRefresh={props.onNoteListChanged}134+        />135+      </div>136+137+      {/* Editor — mounted only when a note is active */}138+      <Show when={activeNote() !== null || isNewNote()}>139+        <div140+          class="peach-notes-editor-wrapper"141+          classList={{142+            "is-visible": editorVisible(),143+            "is-entering": phase() === "expanding",144+            "is-leaving": phase() === "collapsing",145+          }}146+        >147+          <NotesEditor148+            note={activeNote()}149+            onBack={handleEditorBack}150+            onSaved={handleSaved}151+            onDelete={handleDeleteNote}152+            autoFocusTitle={isNewNote()}153+          />154+        </div>155+      </Show>156+    </div>157+  );158+}

view file

src/popup/views/NotesTOC.tsx+116 −0
@@ -0,0 +1,116 @@1+import { createSignal, For, Show } from "solid-js";2+import type { VaultEntry } from "../../shared/types";3+import { sendMessage } from "../../shared/bridge";4+import { NoteRunes } from "../components/NoteRunes";5+import { NoteRow } from "../components/NoteRow";6+7+interface NotesTOCProps {8+  notes: VaultEntry[];9+  onBack: () => void;10+  onOpenNote: (note: VaultEntry, rowEl: HTMLElement) => void;11+  onNewNote: () => void;12+  onRefresh: () => void;13+}14+15+export function NotesTOC(props: NotesTOCProps) {16+  const [searchVisible, setSearchVisible] = createSignal(false);17+  const [searchQuery, setSearchQuery] = createSignal("");18+19+  let searchInputRef!: HTMLInputElement;20+21+  function filteredNotes() {22+    const q = searchQuery().trim().toLowerCase();23+    if (!q) return props.notes;24+    return props.notes.filter((n) =>25+      (n.title ?? "").toLowerCase().includes(q) ||26+      (n.notes ?? "").toLowerCase().includes(q)27+    );28+  }29+30+  function showSearch() {31+    setSearchVisible(true);32+    setTimeout(() => searchInputRef?.focus(), 50);33+  }34+35+  function clearSearch() {36+    setSearchQuery("");37+    setSearchVisible(false);38+  }39+40+  async function handleDelete(note: VaultEntry) {41+    await sendMessage({ type: "DELETE_NOTE", payload: { id: note.id } });42+    props.onRefresh();43+  }44+45+  async function handleArchive(note: VaultEntry) {46+    // Archive = soft delete with a tag — for now just delete to trash47+    await sendMessage({ type: "DELETE_ENTRY", payload: { id: note.id } });48+    props.onRefresh();49+  }50+51+  return (52+    <div class="peach-notes-toc">53+      <NoteRunes mode="toc" />54+55+      {/* Header */}56+      <div class="peach-notes-header">57+        <Show58+          when={!searchVisible()}59+          fallback={60+            <>61+              <input62+                ref={searchInputRef}63+                class="peach-notes-search-input"64+                placeholder="search notes…"65+                value={searchQuery()}66+                onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}67+                aria-label="Search notes"68+              />69+              <button class="peach-notes-header-btn" onClick={clearSearch} aria-label="Clear search">✕</button>70+            </>71+          }72+        >73+          <button class="peach-notes-back" onClick={props.onBack} aria-label="Back to vault">‹</button>74+          <span class="peach-notes-header-title">notes.</span>75+          <button class="peach-notes-header-btn" onClick={showSearch} aria-label="Search">76+            <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">77+              <circle cx="6.5" cy="6.5" r="4.25" />78+              <path d="M10 10L13 13" />79+            </svg>80+          </button>81+        </Show>82+      </div>83+84+      {/* Note list */}85+      <div class="peach-notes-list-container">86+        <Show87+          when={filteredNotes().length > 0}88+          fallback={89+            <div class="peach-notes-empty">90+              <p class="peach-notes-empty-text">91+                {searchQuery() ? "No notes match." : "No notes yet.\nStart writing."}92+              </p>93+            </div>94+          }95+        >96+          <For each={filteredNotes()}>97+            {(note, i) => (98+              <NoteRow99+                note={note}100+                index={i()}101+                searchQuery={searchQuery()}102+                onTap={(n, el) => props.onOpenNote(n, el)}103+                onDelete={handleDelete}104+                onArchive={handleArchive}105+              />106+            )}107+          </For>108+        </Show>109+110+        <button class="peach-notes-new-btn" onClick={props.onNewNote}>111+          + new note112+        </button>113+      </div>114+    </div>115+  );116+}

view file

src/shared/types.ts+2 −0
@@ -311,6 +311,8 @@311311   | "TEST_SUBNET_SCAN"312312   | "TEST_WS_LOCAL"313313   | "TEST_SUBNET_DETECT"314+  | "SAVE_NOTE"315+  | "DELETE_NOTE"314316   // Content-script-bound message types — sent BY the background TO content315317   // scripts. They never reach messages.ts's switch. Kept in this union so316318   // existing call sites type-check; do not add cases for them in the

view file