Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@

## Unreleased

### Text-size shortcuts + header control; Capture path pre-fill

- **feat(ui): accessible text-size (shortcuts + header) + locally-generated
capture path (0.3.15-rc.7).** Two follow-ups to rc.6 bundled because
they touch the same chrome / Capture surfaces. Closes notes#127 + #126.
- **Text-size keyboard shortcuts (notes#127).** Cmd+= / Cmd+Plus steps
up the ramp (default → larger → largest → default); Cmd+- /
Cmd+Underscore steps down; Cmd+0 resets to default. Bound at the
app root via `TextSizeShortcutsMount` so the listener lives in one
place — the visible `TextSizeControl` in the Header would otherwise
register twice (desktop + mobile menu). Ignored when Shift, Alt, or
neither modifier is held.
- **Header chrome control (notes#127).** Adds `TextSizeControl` — a
small "Aa" button next to the existing chrome (Install / Theme).
Click opens a 3-option popover (Default / Larger / Largest) with a
✓ on the active row. Same persist + apply path as the Settings
dropdown via `lib/text-size.ts`. A same-tab `CustomEvent` keeps the
popover's active indicator in sync when a shortcut or sibling
control changes the size (the `storage` event only fires
cross-tab). Lives on both desktop and mobile menu so phones (no
keyboard) keep the path.
- **`nextTextSize` / `previousTextSize` helpers.** Added to
`lib/text-size.ts` for the shortcut handlers; mirror `nextTheme`
in `theme.ts` shape. Each direction is explicit so the call site
doesn't have to think about wrap-around arithmetic.
- **Capture path pre-fill (notes#126).** Adds `quickPath()` helper
next to `memoPath()` in `lib/capture/recorder.ts`. Same
`<root>/YYYY/MM-DD/HH-MM-SS` shape under `Notes/`. Capture's
`pathOverride` state is now seeded with `quickPath()` on mount so
the operator sees the generated path the moment they expand More
fields — no more invisible "vault auto-assigns" magic.
- **Audio-only memos.** With the pre-fill non-empty, audio captures
also land under `Notes/` by default (parallel to the text case).
Operators who want the old `Memos/` rule can clear the path input
— the existing rc.6 fallback to `memoPath()` for audio-only with
no override is preserved as the escape valve. New test pins this.
- **Tests.** 2 new in `text-size.test.ts` (cycle direction helpers).
12 new in `TextSizeControl.test.tsx` (button + popover behavior,
keyboard handlers, same-tab sync). 1 new in `recorder.test.ts`
(quickPath shape). 2 new in `Capture.test.tsx` (pre-fill is
editable + saved; audio-only fallback to `memoPath` when path is
cleared). Existing tests updated where pre-fill changed observable
behavior (text-only payload now carries `Notes/...`, combined
text+voice payload now carries `Notes/...` instead of vault-picks).

### Capture polish + view-level text-size

- **feat(ui): unified capture surface refinements + view-level text-size
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openparachute/notes",
"version": "0.3.15-rc.6",
"version": "0.3.15-rc.7",
"private": false,
"type": "module",
"description": "Parachute Notes — the default frontend for Parachute. Browse, edit, and capture in any Parachute Vault.",
Expand Down
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BottomTabBar } from "@/components/BottomTabBar";
import { Header } from "@/components/Header";
import { QuickSwitchMount } from "@/components/QuickSwitchMount";
import { TextSizeShortcutsMount } from "@/components/TextSizeControl";
import { Toaster } from "@/components/Toaster";
import { UpdateBanner } from "@/components/UpdateBanner";
import { VaultStatusBanner } from "@/components/VaultStatusBanner";
Expand Down Expand Up @@ -106,6 +107,7 @@ export function App() {
<QueryProvider>
<SyncProvider>
<ReachabilityProbeMount />
<TextSizeShortcutsMount />
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, "") || undefined}>
<div className="min-h-dvh overflow-x-hidden bg-bg text-fg pb-16 md:pb-0">
<Toaster />
Expand Down
105 changes: 101 additions & 4 deletions src/app/routes/Capture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ describe("Capture (unified)", () => {
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("got an #idea on the bus");
expect(rows[0].mutation.payload.path).toBeUndefined();
// Pre-fill from notes#126: pathOverride is now seeded with `quickPath()`
// on mount, so the payload's path is `Notes/<YYYY>/<MM-DD>/<HH-MM-SS>`.
// Asserting on the prefix keeps the test stable across clock minutes.
expect(rows[0].mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(rows[0].mutation.payload.tags).toEqual(["quick", "idea"]);
db.close();
});
Expand Down Expand Up @@ -257,7 +260,12 @@ describe("Capture (unified)", () => {
) {
throw new Error("wrong mutation shape");
}
expect(create.mutation.payload.path).toMatch(/^Memos\//);
// With notes#126's pre-fill, pathOverride is seeded with `quickPath()`
// on mount and wins over the audio-only memoPath fallback. Audio-only
// memos now land under Notes/<date>/<time> by default. To keep them
// in Memos/, the user can clear the path input (then memoPath kicks
// in — see the "audio-only with cleared path" test below).
expect(create.mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(create.mutation.payload.tags).toEqual(["voice"]);
expect(create.mutation.payload.content).toContain("_Transcript pending._");
expect(create.mutation.payload.content).toContain("![[");
Expand Down Expand Up @@ -299,8 +307,10 @@ describe("Capture (unified)", () => {
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("wrong mutation shape");
// Combined notes don't pin to Memos/ — they belong with the user's other notes.
expect(create.mutation.payload.path).toBeUndefined();
// Pre-fill from notes#126: combined text+voice notes also carry the
// quickPath value (used to be `undefined` here, meaning vault picks).
// They no longer pin to Memos/ — they belong with the user's other notes.
expect(create.mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(create.mutation.payload.content).toContain("context for the recording #meeting");
expect(create.mutation.payload.content).toContain("![[");
expect(create.mutation.payload.tags).toEqual(["quick", "voice", "meeting"]);
Expand Down Expand Up @@ -725,6 +735,93 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
expect(create.mutation.payload.path).toBe("Recordings/2026/may");
db.close();
});

it("Pre-filled path is editable and the edit is what's saved (notes#126)", async () => {
// The path-override input is now pre-filled with `quickPath()` on
// mount (notes#126). This test confirms (a) the input renders with
// a non-empty value, and (b) editing replaces that value cleanly —
// no merging, no append.
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 pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
// Pre-fill is non-empty and matches the quickPath shape.
expect(pathInput.value).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);

// Edit the input to a custom value.
await act(async () => {
fireEvent.change(pathInput, { target: { value: "Projects/2026/q2/launch" } });
});

const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement;
await act(async () => {
fireEvent.change(textarea, { target: { value: "kickoff notes" } });
});
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).toBe("Projects/2026/q2/launch");
db.close();
});

it("Audio-only with manually cleared path falls back to memoPath (rc.6 escape valve)", async () => {
// notes#126's pre-fill is the new default, but clearing the path
// input is the operator's signal of "I want the historical rule".
// For audio-only captures, that rule is `memoPath()` → `Memos/`.
// This test pins the escape valve so a future refactor doesn't
// delete the fallback.
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 pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
await act(async () => {
fireEvent.change(pathInput, { target: { value: "" } });
});

// Audio-only — no text typed.
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).toMatch(/^Memos\//);
db.close();
});
});

describe("Capture — inactivity autosave (5s)", () => {
Expand Down
17 changes: 11 additions & 6 deletions src/app/routes/Capture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
memoFilename,
memoPath,
pickMimeType,
quickPath,
requestMic,
} from "@/lib/capture/recorder";
import { blobRef, enqueue, newBlobId, newLocalId } from "@/lib/sync";
Expand Down Expand Up @@ -75,12 +76,16 @@ export function Capture({
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.
// operator who needs to override the path or set a one-line summary
// opens this and gets the structured form without leaving Capture.
//
// pathOverride is now pre-filled with `quickPath()` (notes#126) — the
// generated path is Notes-side, deterministic from mount time, and
// visible to the operator the moment they expand More fields. They can
// accept it, edit it, or clear it. Clearing falls back to "let the
// vault auto-assign" (empty string preserves the rc.5 escape valve).
const [moreFieldsOpen, setMoreFieldsOpen] = useState(moreFieldsOpenDefault);
const [pathOverride, setPathOverride] = useState("");
const [pathOverride, setPathOverride] = useState(() => quickPath());
const [summary, setSummary] = useState("");
const [phase, setPhase] = useState<Phase>({ kind: "idle" });
const [elapsedMs, setElapsedMs] = useState(0);
Expand Down Expand Up @@ -544,7 +549,7 @@ export function Capture({
type="text"
value={pathOverride}
onChange={(e) => setPathOverride(e.target.value)}
placeholder="(blank → vault auto-assigns)"
placeholder="(blank → vault picks)"
aria-label="Path override"
className="rounded-md border border-border bg-card px-2.5 py-1.5 font-mono text-xs text-fg focus:border-accent focus:outline-none"
/>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InstallPrompt } from "@/components/InstallPrompt";
import { SyncStatusIndicator } from "@/components/SyncStatusIndicator";
import { TextSizeControl } from "@/components/TextSizeControl";
import { ThemeToggle } from "@/components/ThemeToggle";
import { VaultPopover } from "@/components/VaultPopover";
import { useVaultStore } from "@/lib/vault";
Expand Down Expand Up @@ -56,12 +57,14 @@ export function Header() {
</Link>
<SyncStatusIndicator />
<InstallPrompt />
<TextSizeControl />
<ThemeToggle />
</>
) : (
<>
<span className="text-sm text-fg-dim">No vault connected</span>
<InstallPrompt />
<TextSizeControl />
<ThemeToggle />
</>
)}
Expand Down Expand Up @@ -103,6 +106,7 @@ export function Header() {
</div>
<div className="mt-1 flex items-center gap-3">
<InstallPrompt />
<TextSizeControl />
<ThemeToggle />
</div>
</div>
Expand All @@ -111,6 +115,7 @@ export function Header() {
<p className="text-sm text-fg-dim">No vault connected</p>
<div className="flex items-center gap-3">
<InstallPrompt />
<TextSizeControl />
<ThemeToggle />
</div>
</div>
Expand Down
Loading