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
↓ peach-browser-273bcb43.tar.gzatlas at this save
files changed13 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 −0manifest.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": {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",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": {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" };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>}>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+}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+}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+}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+}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+}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+}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+}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