diff --git a/CHANGELOG.md b/CHANGELOG.md index 940e075..037992b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ ## Unreleased +### Capture polish + view-level text-size + +- **feat(ui): unified capture surface refinements + view-level text-size + control (0.3.15-rc.6).** Two items from `design/2026-05-12-notes-ui-audit.md` + §3 (north-star "Apple-Notes-grade ease"): #12 (unified capture) and + #11 (text-size knob). Both behind one PR per the audit's sequencing. + - **More fields panel (audit §3 #12).** Adds a collapsible + `
` to `Capture` exposing a path override + summary input — + the audit's "structured form when wanted, hidden when not". Defaults + to closed so the textarea stays the unfocused-friction default; + operators who need to set an explicit path (e.g. capturing into + `Daily/2026-05-12`) get the form without leaving Capture. Empty + path means "let the vault auto-assign"; empty summary means "no + metadata.summary". Path override wins over the audio-only memo + auto-path. + - **Inactivity autosave (audit §3 #12).** Capture now flushes + save() after 5 seconds of editing inactivity in addition to the + existing unmount-flush — protects against browser crashes and + accidental closes. Skipped while audio is staged (manual Capture + click only), while recording/saving, and while body is empty. + - **Escape hatch to NoteNew.** A "Need to attach a file? Open the + full editor" link in the More-fields panel points at `/new`, which + still renders `NoteNew` for the file-drop / file-picker / + `link-on-create` flow. Capture is canonical for the 95% quick path; + the heavy editor stays available for the 5% that needs attachments. + Cmd+K keeps both entries for discoverability. + - **View-level text-size knob (audit §3 #11).** New `lib/text-size.ts` + mirrors `lib/theme.ts` shape — three steps (Default / Larger / + Largest), per-device localStorage at `notes:textSize`, applied via + a `data-text-size` attribute on ``. `styles/index.css` + defines `--font-size-prose` + `--font-size-editor` CSS variables; + `.prose-note` reads the prose one (markdown reader), `CodeMirror` + reads the editor one. Settings gains a `TextSizeSection` with + three radio buttons that apply + persist in one motion. Markdown + on disk is unaffected — pure view preference. + - **Tests.** 4 new in `text-size.test.ts` (round-trip, default + handling, data-attribute application, labels). 8 new in + `Capture.test.tsx` covering disclosure default-closed, path/summary + override payload shape, empty-path fallback, path-override winning + over memo auto-path, autosave-after-5s, edit-resets-timer, + empty-content-no-autosave, audio-staged-suppresses-autosave. + - **Unmount-flush hardening.** The unmount enqueue now swallows IDB + teardown rejections (SyncProvider closing its handle in the same + tick is a known race documented in `SyncProvider.tsx:60`). No + user-visible surface to report failures during nav-away anyway. + ### Multi-vault hubs — consume per-vault services keys - **feat(oauth): prefer `services["vault:"].url` in OAuthCallback diff --git a/package.json b/package.json index 60056be..38a66b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openparachute/notes", - "version": "0.3.15-rc.5", + "version": "0.3.15-rc.6", "private": false, "type": "module", "description": "Parachute Notes — the default frontend for Parachute. Browse, edit, and capture in any Parachute Vault.", diff --git a/src/app/App.tsx b/src/app/App.tsx index 1889bd4..5f043ea 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,13 +4,14 @@ import { QuickSwitchMount } from "@/components/QuickSwitchMount"; import { Toaster } from "@/components/Toaster"; import { UpdateBanner } from "@/components/UpdateBanner"; import { VaultStatusBanner } from "@/components/VaultStatusBanner"; +import { applyTextSize, readStoredTextSize } from "@/lib/text-size"; import { useVaultStore } from "@/lib/vault"; import { useCrossTabVaultSync } from "@/lib/vault/cross-tab-sync"; import { useActiveVaultClient } from "@/lib/vault/queries"; import { useReachabilityProbe } from "@/lib/vault/reachability-probe"; import { QueryProvider } from "@/providers/QueryProvider"; import { SyncProvider } from "@/providers/SyncProvider"; -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router"; import { Home } from "./routes/Home"; import { Notes } from "./routes/Notes"; @@ -95,6 +96,12 @@ export function App() { // outlives every route transition. Same vault state surfaces in every tab // without a refresh. useCrossTabVaultSync(); + // Apply the stored text-size on mount. Wired here rather than inline in + // Settings so the preference takes effect on every route — Settings is + // where you change it, App is where it lives. + useEffect(() => { + applyTextSize(readStoredTextSize()); + }, []); return ( diff --git a/src/app/routes/Capture.test.tsx b/src/app/routes/Capture.test.tsx index 02bf487..1bd21ca 100644 --- a/src/app/routes/Capture.test.tsx +++ b/src/app/routes/Capture.test.tsx @@ -563,6 +563,426 @@ describe("Capture (unified)", () => { }); }); +describe("Capture — More fields panel (path + summary overrides)", () => { + let restoreOnline: (() => void) | null = null; + + beforeEach(async () => { + const db = await freshDb(); + db.close(); + localStorage.clear(); + useVaultStore.setState({ vaults: {}, activeVaultId: null }); + useToastStore.setState({ toasts: [] }); + seedStore(); + fakeState.controller = null; + fakeState.pickResult = "audio/webm;codecs=opus"; + fakeState.requestMic = vi.fn(async () => ({ getTracks: () => [] }) as unknown as MediaStream); + vi.stubGlobal( + "URL", + Object.assign(URL, { + createObjectURL: vi.fn(() => "blob:fake"), + revokeObjectURL: vi.fn(), + }), + ); + const desc = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine"); + Object.defineProperty(navigator, "onLine", { configurable: true, get: () => false }); + restoreOnline = () => { + if (desc) Object.defineProperty(navigator, "onLine", desc); + }; + }); + afterEach(() => { + restoreOnline?.(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("More fields disclosure is collapsed by default", async () => { + renderAt("/capture"); + await waitForReady(); + // The Path input lives inside
; jsdom keeps it in the DOM, but + // the parent's `open` attribute is what governs visibility. Assert on + // the attribute so we test the actual behavior, not a CSS detail. + const summary = screen.getByText(/^more fields$/i); + const details = summary.closest("details"); + expect(details).toBeTruthy(); + expect(details?.open).toBe(false); + }); + + it("Path override → enqueued create-note carries `path`; tags + content unchanged", async () => { + renderAt("/capture"); + await waitForReady(); + + // Open the disclosure first — jsdom doesn't dispatch toggle on click of + // the summary alone (it's a quirk), so set the open prop directly via + // the user-facing affordance. + const detailsEl = screen.getByText(/^more fields$/i).closest("details")!; + await act(async () => { + detailsEl.open = true; + detailsEl.dispatchEvent(new Event("toggle")); + }); + + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement; + const summaryInput = screen.getByLabelText(/^summary$/i) as HTMLInputElement; + + await act(async () => { + fireEvent.change(textarea, { target: { value: "lab notes #wip" } }); + fireEvent.change(pathInput, { target: { value: "Daily/2026-05-12" } }); + fireEvent.change(summaryInput, { target: { value: "first pass" } }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^capture$/i })); + }); + + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true); + }); + + const db = await openLensDB(); + const rows = await listPending(db, "dev"); + expect(rows.length).toBe(1); + if (rows[0]?.mutation.kind !== "create-note") throw new Error("expected create-note"); + const payload = rows[0].mutation.payload; + expect(payload.path).toBe("Daily/2026-05-12"); + expect(payload.metadata).toEqual({ summary: "first pass" }); + expect(payload.tags).toEqual(["quick", "wip"]); + expect(payload.content).toBe("lab notes #wip"); + db.close(); + }); + + it("Empty path override → payload omits `path` (vault auto-assigns)", async () => { + renderAt("/capture"); + await waitForReady(); + const detailsEl = screen.getByText(/^more fields$/i).closest("details")!; + await act(async () => { + detailsEl.open = true; + detailsEl.dispatchEvent(new Event("toggle")); + }); + + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "no path here" } }); + // Whitespace-only is treated as empty per the trim(). + fireEvent.change(pathInput, { target: { value: " " } }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^capture$/i })); + }); + + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true); + }); + + const db = await openLensDB(); + const rows = await listPending(db, "dev"); + if (rows[0]?.mutation.kind !== "create-note") throw new Error("expected create-note"); + expect(rows[0].mutation.payload.path).toBeUndefined(); + expect(rows[0].mutation.payload.metadata).toBeUndefined(); + db.close(); + }); + + it("Path override wins over the audio-only memo path", async () => { + renderAt("/capture"); + await waitForReady(); + const detailsEl = screen.getByText(/^more fields$/i).closest("details")!; + await act(async () => { + detailsEl.open = true; + detailsEl.dispatchEvent(new Event("toggle")); + }); + + // Set path override BEFORE recording so it sticks through the save flow. + const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement; + await act(async () => { + fireEvent.change(pathInput, { target: { value: "Recordings/2026/may" } }); + }); + + // Record audio only (no text). + await act(async () => { + fireEvent.pointerDown(screen.getByRole("button", { name: /hold to record/i })); + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /recording/i })).toBeInTheDocument(); + }); + await act(async () => { + releasePointer(); + }); + await waitFor(() => { + expect(screen.getByText(/recorded /i)).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^capture$/i })); + }); + + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.tone === "success")).toBe(true); + }); + const db = await openLensDB(); + const rows = await listPending(db, "dev"); + const create = rows.find((r) => r.mutation.kind === "create-note")!; + if (create.mutation.kind !== "create-note") throw new Error("expected create-note"); + expect(create.mutation.payload.path).toBe("Recordings/2026/may"); + db.close(); + }); +}); + +describe("Capture — inactivity autosave (5s)", () => { + let restoreOnline: (() => void) | null = null; + + beforeEach(async () => { + const db = await freshDb(); + db.close(); + localStorage.clear(); + useVaultStore.setState({ vaults: {}, activeVaultId: null }); + useToastStore.setState({ toasts: [] }); + seedStore(); + fakeState.controller = null; + fakeState.pickResult = "audio/webm;codecs=opus"; + fakeState.requestMic = vi.fn(async () => ({ getTracks: () => [] }) as unknown as MediaStream); + vi.stubGlobal( + "URL", + Object.assign(URL, { + createObjectURL: vi.fn(() => "blob:fake"), + revokeObjectURL: vi.fn(), + }), + ); + const desc = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine"); + Object.defineProperty(navigator, "onLine", { configurable: true, get: () => false }); + restoreOnline = () => { + if (desc) Object.defineProperty(navigator, "onLine", desc); + }; + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + afterEach(() => { + vi.useRealTimers(); + restoreOnline?.(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("typing + 5s of inactivity fires save()", async () => { + renderAt("/capture"); + await waitForReady(); + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "auto-saved thought" } }); + }); + + // Advance just under the 5s threshold — should NOT have saved yet. + await act(async () => { + vi.advanceTimersByTime(4_000); + }); + let db = await openLensDB(); + expect((await listPending(db, "dev")).length).toBe(0); + db.close(); + + // Cross the threshold. + await act(async () => { + vi.advanceTimersByTime(2_000); + }); + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true); + }); + db = await openLensDB(); + const rows = await listPending(db, "dev"); + expect(rows.length).toBe(1); + if (rows[0]?.mutation.kind !== "create-note") throw new Error("expected create-note"); + expect(rows[0].mutation.payload.content).toBe("auto-saved thought"); + db.close(); + }); + + it("further edits within the 5s window reset the timer", async () => { + renderAt("/capture"); + await waitForReady(); + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "first" } }); + }); + await act(async () => { + vi.advanceTimersByTime(4_000); + }); + // Edit again before the timer fires — counter should restart. + await act(async () => { + fireEvent.change(textarea, { target: { value: "first plus more" } }); + }); + await act(async () => { + vi.advanceTimersByTime(4_500); + }); + // 8.5s elapsed, but only 4.5s since the last keystroke — should NOT have + // saved yet. + let db = await openLensDB(); + expect((await listPending(db, "dev")).length).toBe(0); + db.close(); + + // Cross the 5s window from the last edit. + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true); + }); + db = await openLensDB(); + const rows = await listPending(db, "dev"); + if (rows[0]?.mutation.kind !== "create-note") throw new Error("expected create-note"); + expect(rows[0].mutation.payload.content).toBe("first plus more"); + db.close(); + }); + + it("empty content → autosave does NOT fire", async () => { + renderAt("/capture"); + await waitForReady(); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + const db = await openLensDB(); + expect((await listPending(db, "dev")).length).toBe(0); + db.close(); + }); + + it("staged audio suppresses autosave (manual Capture click only)", async () => { + renderAt("/capture"); + await waitForReady(); + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "with audio attached" } }); + }); + // Record + release so phase enters `have-audio`. + await act(async () => { + fireEvent.pointerDown(screen.getByRole("button", { name: /hold to record/i })); + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /recording/i })).toBeInTheDocument(); + }); + await act(async () => { + releasePointer(); + }); + await waitFor(() => { + expect(screen.getByText(/recorded /i)).toBeInTheDocument(); + }); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + const db = await openLensDB(); + expect((await listPending(db, "dev")).length).toBe(0); + db.close(); + }); + + it("second autosave after first fires (savingRef releases on success)", async () => { + // Regression for the data-loss bug reviewer caught on #123: the success + // path of save() never reset savingRef.current. With autosave, the user + // stays on the page after a save, so subsequent autosaves would always + // bail at the `if (savingRef.current) return` guard. Two autosaves means + // two enqueued notes, not one. + renderAt("/capture"); + await waitForReady(); + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + + // First autosave. + await act(async () => { + fireEvent.change(textarea, { target: { value: "first thought" } }); + }); + await act(async () => { + vi.advanceTimersByTime(5_500); + }); + await waitFor(() => { + expect(useToastStore.getState().toasts.length).toBeGreaterThanOrEqual(1); + }); + let db = await openLensDB(); + expect((await listPending(db, "dev")).length).toBe(1); + db.close(); + + // After save the textarea is cleared by reset(); type again. + await act(async () => { + fireEvent.change(textarea, { target: { value: "second thought" } }); + }); + await act(async () => { + vi.advanceTimersByTime(5_500); + }); + await waitFor(() => { + // Two captures means two success toasts. + expect(useToastStore.getState().toasts.filter((t) => t.message === "Captured.").length).toBe( + 2, + ); + }); + db = await openLensDB(); + const rows = await listPending(db, "dev"); + expect(rows.length).toBe(2); + const contents = rows + .filter((r) => r.mutation.kind === "create-note") + .map((r) => (r.mutation.kind === "create-note" ? r.mutation.payload.content : "")); + expect(contents).toContain("first thought"); + expect(contents).toContain("second thought"); + db.close(); + }); + + it("unmount-flush after a successful autosave still flushes new typed content", async () => { + // Companion regression: after an autosave succeeds, the user types more + // and navigates away. If savingRef leaks, the unmount-flush silently + // drops the new content. Uses the Toggler pattern (rather than RTL's + // `unmount()`) so the SyncProvider's IDB handle stays open while the + // unmount-flush enqueue runs — same shape as the existing + // "unmount with dirty text" test for the same reason. + function Toggler() { + const [mounted, setMounted] = useState(true); + return ( + <> + + {mounted ? :
unmounted
} + + ); + } + render( + + + , + { wrapper: Wrapper }, + ); + await waitFor(() => { + expect(screen.getByLabelText(/capture content/i)).toBeInTheDocument(); + }); + const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement; + + await act(async () => { + fireEvent.change(textarea, { target: { value: "first" } }); + }); + await act(async () => { + vi.advanceTimersByTime(5_500); + }); + await waitFor(() => { + expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true); + }); + + // Type more (post-autosave-reset), then unmount before the next timer fires. + await act(async () => { + fireEvent.change(textarea, { target: { value: "post-autosave draft" } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "unmount" })); + }); + await waitFor(() => { + expect(screen.getByText("unmounted")).toBeInTheDocument(); + }); + + await waitFor(async () => { + const db = await openLensDB(); + const rows = await listPending(db, "dev"); + db.close(); + expect(rows.length).toBe(2); + }); + const db = await openLensDB(); + const rows = await listPending(db, "dev"); + const contents = rows + .filter((r) => r.mutation.kind === "create-note") + .map((r) => (r.mutation.kind === "create-note" ? r.mutation.payload.content : "")); + expect(contents).toContain("first"); + expect(contents).toContain("post-autosave draft"); + db.close(); + }); +}); + describe("extractHashtags", () => { it("pulls #tag tokens from prose and dedups them", () => { expect(extractHashtags("got an #idea today and another #idea")).toEqual(["idea"]); diff --git a/src/app/routes/Capture.tsx b/src/app/routes/Capture.tsx index 832a53e..2a30a4b 100644 --- a/src/app/routes/Capture.tsx +++ b/src/app/routes/Capture.tsx @@ -13,7 +13,7 @@ import { useToastStore } from "@/lib/toast/store"; import { useTagRoles, useVaultStore } from "@/lib/vault"; import { useSync } from "@/providers/SyncProvider"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Navigate } from "react-router"; +import { Link, Navigate } from "react-router"; // Unified single-screen capture. The user can type, hold-to-record, or do // both — submit writes one note tagged for whichever inputs were used. @@ -62,7 +62,9 @@ function formatElapsed(ms: number): string { return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; } -export function Capture() { +export function Capture({ + moreFieldsOpenDefault = false, +}: { moreFieldsOpenDefault?: boolean } = {}) { const activeVault = useVaultStore((s) => s.getActiveVault()); const pushToast = useToastStore((s) => s.push); const { db, blobStore, engine } = useSync(); @@ -71,6 +73,15 @@ export function Capture() { const [content, setContent] = useState(""); const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(""); + // "More fields" — the audit's escape hatch from the quick-capture default. + // Hidden by default so the textarea stays the no-friction focus; an + // operator who needs to set an explicit path or one-line summary opens + // this and gets the structured form without leaving Capture. Empty path + // means "let the vault auto-assign" (the existing behavior); empty summary + // means "no metadata.summary" — both inputs are pure overrides. + const [moreFieldsOpen, setMoreFieldsOpen] = useState(moreFieldsOpenDefault); + const [pathOverride, setPathOverride] = useState(""); + const [summary, setSummary] = useState(""); const [phase, setPhase] = useState({ kind: "idle" }); const [elapsedMs, setElapsedMs] = useState(0); @@ -188,6 +199,10 @@ export function Capture() { setContent(""); setTags([]); setTagInput(""); + // Don't clear pathOverride / summary — the user opened "More fields" + // deliberately and may be capturing multiple notes into the same path + // (e.g. "Daily/2026-05-12"). They can close the panel or clear the + // inputs themselves; resetting silently every time would be surprising. discardAudio(); textareaRef.current?.focus(); }, [discardAudio]); @@ -217,6 +232,13 @@ export function Capture() { const localId = newLocalId(); + // "More fields" overrides: trim once here so empty-after-trim values + // don't end up as `path: ""` (vault would reject) or + // `metadata.summary: ""` (worthless metadata noise). + const pathOverrideValue = pathOverride.trim(); + const summaryValue = summary.trim(); + const metadata = summaryValue ? { summary: summaryValue } : undefined; + try { if (audio) { // Voice-bearing note. If the user typed too, keep their body verbatim @@ -229,7 +251,11 @@ export function Capture() { const body = hasText ? `${content.trim()}\n\n_Transcript pending._\n\n![[${filename}]]\n` : `_Transcript pending._\n\n![[${filename}]]\n`; - const path = hasText ? undefined : memoPath(recordedAt); + // Path precedence: explicit override > audio-only memo path > let + // the vault auto-assign. Override wins on every shape so the user + // can place an audio note anywhere they want, including the typed + // case (where today we'd let the vault pick). + const path = pathOverrideValue || (hasText ? undefined : memoPath(recordedAt)); if (!blobStore) throw new Error("blob store missing"); await blobStore.put(blobId, audio.data, audio.mimeType, activeVault.id); @@ -242,6 +268,7 @@ export function Capture() { content: body, ...(path ? { path } : {}), ...(finalTags.length ? { tags: finalTags } : {}), + ...(metadata ? { metadata } : {}), }, }, { vaultId: activeVault.id }, @@ -276,7 +303,9 @@ export function Capture() { localId, payload: { content, + ...(pathOverrideValue ? { path: pathOverrideValue } : {}), ...(finalTags.length ? { tags: finalTags } : {}), + ...(metadata ? { metadata } : {}), }, }, { vaultId: activeVault.id }, @@ -285,6 +314,12 @@ export function Capture() { void engine?.runOnce(); pushToast(audio ? "Captured — syncing audio." : "Captured.", "success"); reset(); + // Critical for autosave: the user stays on the page after a successful + // autosave, so this in-flight flag has to release or every subsequent + // autosave AND the unmount-flush silently bail. Pre-autosave the manual + // Capture click was the only entry point and a fresh mount handled the + // reset implicitly; with the 5s timer the mount is reused across saves. + savingRef.current = false; } catch (e) { pushToast(e instanceof Error ? `Capture failed: ${e.message}` : "Capture failed.", "error"); // Save failed — release the in-flight flag so the unmount-flush will @@ -315,6 +350,8 @@ export function Capture() { hasText, tags, content, + pathOverride, + summary, roles.captureText, roles.captureVoice, engine, @@ -340,12 +377,28 @@ export function Capture() { // savingRef is checked here so a teardown that fires in the same tick as a // Capture click (user hits Capture and immediately navigates) sees that // save() is already in flight and bails — otherwise we'd enqueue twice. - const latest = useRef({ db, activeVaultId: activeVault?.id ?? null, content, tags, roles }); - latest.current = { db, activeVaultId: activeVault?.id ?? null, content, tags, roles }; + const latest = useRef({ + db, + activeVaultId: activeVault?.id ?? null, + content, + tags, + pathOverride, + summary, + roles, + }); + latest.current = { + db, + activeVaultId: activeVault?.id ?? null, + content, + tags, + pathOverride, + summary, + roles, + }; useEffect(() => { return () => { if (savingRef.current) return; - const { db, activeVaultId, content, tags, roles } = latest.current; + const { db, activeVaultId, content, tags, pathOverride, summary, roles } = latest.current; const text = content.trim(); if (!text || !db || !activeVaultId) return; const explicit = tags.filter((t) => t.length > 0); @@ -353,21 +406,61 @@ export function Capture() { const all = Array.from( new Set([roles.captureText, ...explicit, ...extracted].filter((t) => t.length > 0)), ); - void enqueue( + const pathValue = pathOverride.trim(); + const summaryValue = summary.trim(); + // Swallow rejections here — we're in the unmount path, so there's no + // user-visible surface to report a failure (the toaster has already + // been torn down with the providers). The typical failure mode in + // tests is the SyncProvider closing its IDB handle in the same tick; + // in production the queue is more durable. Either way, no UI to + // notify. + enqueue( db, { kind: "create-note", localId: newLocalId(), payload: { content, + ...(pathValue ? { path: pathValue } : {}), ...(all.length ? { tags: all } : {}), + ...(summaryValue ? { metadata: { summary: summaryValue } } : {}), }, }, { vaultId: activeVaultId }, - ); + ).catch(() => { + // best-effort flush on nav-away + }); }; }, []); + // Inactivity autosave (5s) — fires save() after the user stops editing for + // 5 seconds so a long-typed note isn't lost to a browser crash or accidental + // close. Skipped while audio is staged (saving with attachment is the user's + // explicit Capture-click decision), while recording/saving (mid-state), and + // while body is empty (nothing to save). The timer resets on every content, + // tags, path, or summary change. Hardcoded 5s per the brief — short enough + // to feel snappy on long sessions, long enough not to spam the queue on + // every keystroke. Cleanup cancels any in-flight timer on unmount so we + // don't race the unmount-flush. + // + // `tags`, `pathOverride`, `summary` are intentional debounce triggers — a + // change in any of them must reset this 5s timer so autosave fires 5s + // after the LAST edit, not 5s after the last content edit only. They're + // read by save() via its own closure (hence Biome can't see them used). + // biome-ignore lint/correctness/useExhaustiveDependencies: see above + useEffect(() => { + if (phase.kind === "recording" || phase.kind === "requesting" || phase.kind === "saving") { + return; + } + if (phase.kind === "have-audio") return; + if (!hasText) return; + const id = setTimeout(() => { + if (savingRef.current) return; + void save(); + }, 5000); + return () => clearTimeout(id); + }, [phase.kind, hasText, content, tags, pathOverride, summary, save]); + if (!activeVault) return ; return ( @@ -436,6 +529,47 @@ export function Capture() { onRemove={(name) => setTags((prev) => prev.filter((x) => x !== name))} /> +
setMoreFieldsOpen((e.currentTarget as HTMLDetailsElement).open)} + > + + More fields + +
+ + +

+ Need to attach a file?{" "} + + Open the full editor + + . +

+
+
+
+ @@ -40,6 +49,51 @@ export function Settings() { ); } +// View-level text-size knob — per-device because eye-days vary independently +// of vault. The dropdown applies + persists in one motion via the helpers in +// lib/text-size.ts; App.tsx already applies the stored value on mount, so +// this section's job is just "change + save". +function TextSizeSection() { + // Lazy initializer reads localStorage during the first render, not in a + // useEffect afterward — without this the radio briefly renders "Default" + // before the effect overwrites with the stored value, which the reviewer + // on #123 flagged as a visible flash. + const [size, setSize] = useState(() => readStoredTextSize()); + + const onChange = (next: TextSize) => { + setSize(next); + writeStoredTextSize(next); + applyTextSize(next); + }; + + return ( +
+
+

Text size

+

+ Affects the editor and rendered notes on this device. Your markdown isn't changed. +

+
+
+ View text size + {TEXT_SIZES.map((s) => ( + + ))} +
+
+ ); +} + function InstallStateSection() { // matchMedia is only reliable at render time on some browsers, so sample // once on mount. diff --git a/src/components/CodeMirrorEditor.tsx b/src/components/CodeMirrorEditor.tsx index a3fe87e..c1dd9fb 100644 --- a/src/components/CodeMirrorEditor.tsx +++ b/src/components/CodeMirrorEditor.tsx @@ -20,7 +20,10 @@ const lensHighlight = HighlightStyle.define([ const lensTheme = EditorView.theme({ "&": { fontFamily: "var(--font-mono)", - fontSize: "14px", + // Reads from the text-size knob (lib/text-size.ts → styles/index.css) + // so editor scales together with the markdown preview. Falls back to + // 14px on legacy stylesheets that pre-date the variable. + fontSize: "var(--font-size-editor, 14px)", backgroundColor: "var(--color-card)", color: "var(--color-fg)", height: "100%", diff --git a/src/lib/text-size.test.ts b/src/lib/text-size.test.ts new file mode 100644 index 0000000..55bd696 --- /dev/null +++ b/src/lib/text-size.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// Tests touch `localStorage` + `document.documentElement` — the vitest config +// already defaults to jsdom for the whole project, but this pragma makes the +// dependency explicit so a stray `bun test` invocation (which bypasses +// vitest) or a per-test env override doesn't silently break with +// "localStorage is not defined". +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + TEXT_SIZE_STORAGE_KEY, + applyTextSize, + readStoredTextSize, + textSizeLabel, + writeStoredTextSize, +} from "./text-size"; + +describe("text-size", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-text-size"); + }); + afterEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-text-size"); + }); + + it("readStoredTextSize defaults to 'default' when unset or invalid", () => { + expect(readStoredTextSize()).toBe("default"); + localStorage.setItem(TEXT_SIZE_STORAGE_KEY, "huge"); + expect(readStoredTextSize()).toBe("default"); + }); + + it("writeStoredTextSize round-trips and removes the key for 'default'", () => { + writeStoredTextSize("larger"); + expect(readStoredTextSize()).toBe("larger"); + writeStoredTextSize("largest"); + expect(readStoredTextSize()).toBe("largest"); + writeStoredTextSize("default"); + expect(localStorage.getItem(TEXT_SIZE_STORAGE_KEY)).toBeNull(); + expect(readStoredTextSize()).toBe("default"); + }); + + it("applyTextSize sets or removes the data-text-size attribute", () => { + applyTextSize("larger"); + expect(document.documentElement.getAttribute("data-text-size")).toBe("larger"); + applyTextSize("largest"); + expect(document.documentElement.getAttribute("data-text-size")).toBe("largest"); + applyTextSize("default"); + expect(document.documentElement.hasAttribute("data-text-size")).toBe(false); + }); + + it("textSizeLabel returns a display-friendly label", () => { + expect(textSizeLabel("default")).toBe("Default"); + expect(textSizeLabel("larger")).toBe("Larger"); + expect(textSizeLabel("largest")).toBe("Largest"); + }); +}); diff --git a/src/lib/text-size.ts b/src/lib/text-size.ts new file mode 100644 index 0000000..3930b6f --- /dev/null +++ b/src/lib/text-size.ts @@ -0,0 +1,52 @@ +// View-level text-size knob — a per-device zoom preference for editor + read +// views. Stored separately from theme because eye-days vary independently of +// light/dark preference. Three steps deliberate: more would force tiny CSS +// distinctions; fewer would skip the middle ground that "larger" reaches. +// +// Affects only how things render — the markdown on disk is untouched. The +// implementation is a `data-text-size="…"` attribute on `` that gates +// three CSS-variable overrides in styles/index.css. Editor (CodeMirror) reads +// the variable through `font-size: var(--font-size-base)`; reader +// (MarkdownView) uses `.prose-note` whose `font-size: var(--font-size-prose)` +// scales together. +// +// Mirrors `theme.ts` in shape on purpose — same read/write/apply trio, same +// "default" sentinel-removes-attribute pattern. + +export type TextSize = "default" | "larger" | "largest"; + +export const TEXT_SIZE_STORAGE_KEY = "notes:textSize"; +export const TEXT_SIZES: TextSize[] = ["default", "larger", "largest"]; + +function isTextSize(v: unknown): v is TextSize { + return v === "default" || v === "larger" || v === "largest"; +} + +export function readStoredTextSize(): TextSize { + try { + const v = localStorage.getItem(TEXT_SIZE_STORAGE_KEY); + return isTextSize(v) ? v : "default"; + } catch { + return "default"; + } +} + +export function writeStoredTextSize(size: TextSize): void { + try { + if (size === "default") localStorage.removeItem(TEXT_SIZE_STORAGE_KEY); + else localStorage.setItem(TEXT_SIZE_STORAGE_KEY, size); + } catch { + // storage unavailable — caller still applies visually + } +} + +export function applyTextSize(size: TextSize, root: HTMLElement = document.documentElement): void { + if (size === "default") root.removeAttribute("data-text-size"); + else root.setAttribute("data-text-size", size); +} + +export function textSizeLabel(size: TextSize): string { + if (size === "larger") return "Larger"; + if (size === "largest") return "Largest"; + return "Default"; +} diff --git a/src/styles/index.css b/src/styles/index.css index 25fa980..7611df7 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -20,6 +20,24 @@ --color-border-light: #ece9e2; --color-card: #ffffff; --color-card-hover: #fefdfb; + + /* View-level text-size knob (lib/text-size.ts). Default values; the + `:root[data-text-size="…"]` blocks below override for the two larger + steps. `--font-size-editor` drives CodeMirror's hardcoded fontSize via + a CSS variable so the editor scales together with prose. */ + --font-size-prose: 1rem; + --font-size-editor: 14px; +} + +/* Explicit text-size overrides. `default` removes the attribute entirely + so we don't need a third block. */ +:root[data-text-size="larger"] { + --font-size-prose: 1.125rem; + --font-size-editor: 16px; +} +:root[data-text-size="largest"] { + --font-size-prose: 1.25rem; + --font-size-editor: 18px; } /* System dark — only when the user hasn't picked explicitly. */ @@ -77,7 +95,7 @@ body { .prose-note { color: var(--color-fg); - font-size: 1rem; + font-size: var(--font-size-prose); line-height: 1.7; } .prose-note h1,