Capture UX: "+ New" header button + type-to-compose (two-mode board keys)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 30s

Live-pass feedback: the wide "Take a note…" bar showed through behind the
compose modal and felt redundant. Per operator choice, remove the bar and
make capture header-button + keyboard driven.

- Removed the board's inline "Take a note…" trigger bar.
- AppShell gains a "+ New" header button (next to search) — navigates to the
  board if needed, then opens the compose modal. The `c` shortcut now does
  the same (unified with newNote()).
- Type-to-compose: on the board with no card focused, any other single
  printable key opens a new note SEEDED with that key (AppShell onKeydown
  fall-through, after the reserved / c ? g shortcuts). Seed travels via
  ui.composeSeed → NoteEditor's new `initialBody` prop; caret placed at end.
- Two-mode board keyboard (BoardView): RESTING = arrows enter browse, letters
  type-to-compose; BROWSING (a card focused) = j/k move, e/x/# act, Enter
  opens, Esc exits to resting. ui.boardCardFocused tells the global handler
  to stand down while browsing so it doesn't swallow card keys.
- Updated the shortcuts help + the empty-state copy ("Hit + New — or just
  start typing").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-23 13:59:23 -04:00
co-authored by Claude Opus 4.8
parent f9f1b77d37
commit 675b3aa248
4 changed files with 83 additions and 33 deletions
+30 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
@@ -42,18 +42,18 @@ let searchTimer: ReturnType<typeof setTimeout> | undefined;
const shortcuts = [ const shortcuts = [
{ label: "Command palette", keys: ["⌘/Ctrl", "K"] }, { label: "Command palette", keys: ["⌘/Ctrl", "K"] },
{ label: "New note", keys: ["c"] }, { label: "New note (or just start typing)", keys: ["c"] },
{ label: "Search", keys: ["/"] }, { label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] }, { label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] }, { label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] }, { label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Go to Timeline", keys: ["g", "t"] }, { label: "Go to Timeline", keys: ["g", "t"] },
{ label: "Move card focus", keys: ["j", "k"] }, { label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
{ label: "Open focused card", keys: ["Enter"] }, { label: "Move / open focused card", keys: ["j", "k", "Enter"] },
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] }, { label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
{ label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] }, { label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] }, { label: "Save & new (composing)", keys: ["Shift", "Enter"] },
{ label: "Close / back to Notes", keys: ["Esc"] }, { label: "Stop browsing / back to Notes", keys: ["Esc"] },
{ label: "This help", keys: ["?"] }, { label: "This help", keys: ["?"] },
]; ];
@@ -70,11 +70,14 @@ function focusSearch() {
searchInput.value?.select(); searchInput.value?.select();
} }
// `c` composes: jump to the board (which autofocuses its quick-add) or, if // New note (the "+ New" button and the `c` shortcut): ensure we're on the board,
// already there, ask the board to reopen the composer. // then open the compose modal. Type-to-compose seeds it instead (see onKeydown).
function compose() { async function newNote() {
if (route.name !== "board") void router.push("/"); if (route.name !== "board") {
else ui.requestCompose(); await router.push("/");
await nextTick();
}
ui.requestCompose();
} }
// Global keyboard shortcuts, active only inside the authed shell. Ignored while // Global keyboard shortcuts, active only inside the authed shell. Ignored while
@@ -137,7 +140,7 @@ function onKeydown(e: KeyboardEvent) {
} }
if (e.key === "c") { if (e.key === "c") {
e.preventDefault(); e.preventDefault();
compose(); void newNote();
return; return;
} }
if (e.key === "?") { if (e.key === "?") {
@@ -149,6 +152,13 @@ function onKeydown(e: KeyboardEvent) {
gPending = true; gPending = true;
clearTimeout(gTimer); clearTimeout(gTimer);
gTimer = setTimeout(() => (gPending = false), 800); gTimer = setTimeout(() => (gPending = false), 800);
return;
}
// Type-to-compose: any OTHER single printable key, on the board with no card
// focused (browse mode owns j/k/e/x/#), opens a new note seeded with that key.
if (route.name === "board" && !ui.boardCardFocused && e.key.length === 1 && /\S/.test(e.key)) {
e.preventDefault();
ui.requestCompose(e.key);
} }
} }
@@ -240,6 +250,14 @@ async function signOut() {
</div> </div>
<div class="flex shrink-0 items-center gap-3"> <div class="flex shrink-0 items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
title="New note (or just start typing)"
@click="newNote"
>
<Icon name="plus" /> <span class="hidden sm:inline">New</span>
</button>
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{ <span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
session.user?.display_name session.user?.display_name
}}</span> }}</span>
+7 -3
View File
@@ -18,8 +18,9 @@ import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
// or null to compose a new one. In compose mode there is no note id until a draft is // or null to compose a new one. In compose mode there is no note id until a draft is
// persisted — on commit-with-content, or on the first rich action (label / image / // persisted — on commit-with-content, or on the first rich action (label / image /
// reminder / checklist) — so we never litter empty notes. // reminder / checklist) — so we never litter empty notes.
const props = withDefaults(defineProps<{ note?: Note | null }>(), { const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: string }>(), {
note: null, note: null,
initialBody: "",
}); });
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>(); const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore(); const notes = useNotesStore();
@@ -28,7 +29,7 @@ const titles = useTitlesStore();
const noteId = ref<string | null>(props.note?.id ?? null); const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? ""); const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? ""); const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default"); const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []); const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
const createKind = ref<"text" | "list">("text"); // compose-only list toggle const createKind = ref<"text" | "list">("text"); // compose-only list toggle
@@ -206,7 +207,10 @@ onMounted(async () => {
void titles.load(); void titles.load();
void loadBacklinks(); void loadBacklinks();
await nextTick(); await nextTick();
bodyInput.value?.focus(); const el = bodyInput.value;
el?.focus();
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}); });
// ---- outgoing links (edit mode) ---- // ---- outgoing links (edit mode) ----
+18 -3
View File
@@ -15,11 +15,17 @@ interface Toast {
// Cross-component UI signals that don't belong to any single view (e.g. a // Cross-component UI signals that don't belong to any single view (e.g. a
// global shortcut in the app shell asking the board to act). // global shortcut in the app shell asking the board to act).
export const useUiStore = defineStore("ui", () => { export const useUiStore = defineStore("ui", () => {
// Bumped to ask the board to open + focus its quick-add composer. // Bumped to ask the board to open the compose modal; composeSeed is the initial
// body (a single char for type-to-compose, or "" for the + button / `c`).
const composeTick = ref(0); const composeTick = ref(0);
function requestCompose() { const composeSeed = ref("");
function requestCompose(seed = "") {
composeSeed.value = seed;
composeTick.value++; composeTick.value++;
} }
// The board publishes whether a card is keyboard-focused (browse mode), so the
// global handler knows NOT to type-to-compose while browsing cards.
const boardCardFocused = ref(false);
// Transient undo toast (e.g. after trash/archive). // Transient undo toast (e.g. after trash/archive).
const toast = ref<Toast | null>(null); const toast = ref<Toast | null>(null);
@@ -47,5 +53,14 @@ export const useUiStore = defineStore("ui", () => {
run?.(); run?.();
} }
return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction }; return {
composeTick,
composeSeed,
requestCompose,
boardCardFocused,
toast,
showToast,
dismissToast,
runToastAction,
};
}); });
+28 -15
View File
@@ -74,22 +74,32 @@ function moveFocus(delta: number) {
focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1); focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1);
} }
// Two-mode board keyboard. RESTING (no card focused): letters type-to-compose,
// handled globally in AppShell; here only the ARROWS enter browse mode. BROWSING (a
// card focused): j/k move, e/x/# act, Enter opens, Esc exits back to resting.
function onBoardKey(e: KeyboardEvent) { function onBoardKey(e: KeyboardEvent) {
if (editing.value) return; // the editor modal owns the keyboard while open if (editing.value || composing.value) return; // a modal owns the keyboard
const el = e.target as HTMLElement | null; const el = e.target as HTMLElement | null;
if (el && (["INPUT", "TEXTAREA", "BUTTON", "A"].includes(el.tagName) || el.isContentEditable)) return; if (el && (["INPUT", "TEXTAREA", "BUTTON", "A", "SELECT"].includes(el.tagName) || el.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key; const key = e.key;
if (key === "j" || key === "ArrowDown" || key === "ArrowRight") { const browsing = focusedIndex.value >= 0;
if (key === "ArrowDown" || key === "ArrowRight" || (browsing && key === "j")) {
e.preventDefault(); e.preventDefault();
moveFocus(1); moveFocus(1);
return; return;
} }
if (key === "k" || key === "ArrowUp" || key === "ArrowLeft") { if (key === "ArrowUp" || key === "ArrowLeft" || (browsing && key === "k")) {
e.preventDefault(); e.preventDefault();
moveFocus(-1); moveFocus(-1);
return; return;
} }
if (!browsing) return; // resting → let AppShell's type-to-compose take the key
if (key === "Escape") {
e.preventDefault();
focusedIndex.value = -1; // leave browse mode, back to type-to-capture
return;
}
const note = orderedNotes.value[focusedIndex.value]; const note = orderedNotes.value[focusedIndex.value];
if (!note) return; if (!note) return;
if (key === "Enter") { if (key === "Enter") {
@@ -107,6 +117,9 @@ function onBoardKey(e: KeyboardEvent) {
} }
} }
// Publish browse-mode state so AppShell's type-to-compose stands down while browsing.
watch(focusedIndex, (i) => (ui.boardCardFocused = i >= 0));
// Keep the selection valid as the list changes / the view switches. // Keep the selection valid as the list changes / the view switches.
watch( watch(
() => orderedNotes.value.length, () => orderedNotes.value.length,
@@ -122,7 +135,7 @@ const emptyState = computed(() => {
if (currentView.value === "archived") if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." }; return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." }; if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
return { title: "No notes yet", subtitle: "Capture your first thought in the box above." }; return { title: "No notes yet", subtitle: "Hit + New — or just start typing — to capture a thought." };
}); });
async function reload() { async function reload() {
@@ -138,7 +151,10 @@ onMounted(() => {
void reload(); void reload();
window.addEventListener("keydown", onBoardKey); window.addEventListener("keydown", onBoardKey);
}); });
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey)); onBeforeUnmount(() => {
window.removeEventListener("keydown", onBoardKey);
ui.boardCardFocused = false;
});
watch([currentView, currentLabel, facetKey], reload); watch([currentView, currentLabel, facetKey], reload);
function openEditor(note: Note) { function openEditor(note: Note) {
@@ -183,14 +199,6 @@ async function onDrop(target: Note) {
<template> <template>
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <div class="mx-auto w-full max-w-6xl px-4 py-6">
<button
v-if="isMainBoard"
type="button"
class="mx-auto mb-6 block w-full max-w-xl rounded-xl border border-neutral-200 bg-white px-4 py-3 text-left text-sm text-neutral-500 shadow-sm transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400"
@click="composing = true"
>
Take a note
</button>
<FilterBar v-if="isMainBoard" /> <FilterBar v-if="isMainBoard" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div> <div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
@@ -267,6 +275,11 @@ async function onDrop(target: Note) {
</div> </div>
<template v-if="composing || editing"> <template v-if="composing || editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" /> <NoteEditor
:note="editing"
:initial-body="composing ? ui.composeSeed : ''"
@close="closeEditor"
@navigate="onNavigate"
/>
</template> </template>
</template> </template>