diff --git a/config.php b/config.php
index 79377c1..8c32ba3 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
and inherits the dim color from the td. */
+.shortcut-table kbd {
+ display:inline-block;
+ min-width:20px; text-align:center;
+ padding:3px 7px;
+ background: linear-gradient(180deg, color-mix(in srgb, var(--t-raised) 90%, white 10%), var(--t-bg));
+ border:1px solid var(--t-border);
+ border-bottom-width:2px;
+ border-radius:4px;
+ font-family:var(--t-font-content, 'JetBrains Mono', monospace);
+ font-size:10px;
+ color:var(--t-bright);
+ line-height:1.4;
+ box-shadow: 0 1px 0 color-mix(in srgb, var(--t-border) 80%, black 20%),
+ inset 0 1px 0 color-mix(in srgb, var(--t-raised) 60%, white 15%);
+}
+.shortcut-table .mouse {
+ font-family:var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size:9px; color:var(--t-dim);
+}
+@media (max-width: 900px) {
+ #shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr 1fr !important; }
+}
+@media (max-width: 640px) {
+ #shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
+}
+
/* Tag chips */
.tag-chip {
display: inline-flex; align-items: center; padding: 1px 6px;
diff --git a/public/index.php b/public/index.php
index 03e2c19..f22ddae 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1322,10 +1322,120 @@ if (str_starts_with($route, '/api/')) {
+
+
+
+
+
+
+
keyboard shortcuts
+
+
+
+
+
+
+
+ navigation
+
+ | A | all cards |
+ | F | favorites |
+ | J | today's journal |
+ | N | new card |
+ | T | tag quick-pick |
+ | C | calendar |
+ | M | mail |
+ | 1 – 9 | Nth user bucket |
+ | ⇥ | next bucket |
+ | ⇧ ⇥ | prev bucket |
+ | / | search |
+
+
+
+ global
+
+ | ⌃ K | search |
+ | ⌃ N | quick capture (in bucket) |
+ | ⌃ Z | undo |
+ | Esc | close overlay |
+ | F1 | help |
+ | ? | this screen |
+ | Del | delete selected |
+
+
+
+
+
+
+ editor
+
+ | ⌃ S | save |
+ | ⌃ ⇧ S | save + close |
+ | ⌃ ↵ | save + close |
+ | ⇥ | indent / title→body |
+ | Alt ↑ ↓ | move line |
+ | ⇧ Alt ↑ ↓ | duplicate line |
+ | ↵ | auto-continue list |
+ | [[ | link autocomplete |
+ | # | tag autocomplete |
+ | ?snippet | snippet expand |
+
+
+
+ search
+
+ | ↑ ↓ | navigate results |
+ | ↵ | open selected result |
+ | Esc | close search |
+
+
+
+
+
+
+ board + detail
+
+ | ↑ ↓ ← → | focus card |
+ | ↵ | focused → preview |
+ | ↵ in preview | → editor |
+ | ← → in preview | group prev / next |
+ | click | preview / split |
+ | dbl-click | editor |
+ | right-click | context menu |
+ | ⌃ click | multi-select |
+ | drag → edge | reorder |
+ | drag → center | group |
+
+
+
+ canvas (ascii editor)
+
+ | B | brush |
+ | E | eraser |
+ | L | line |
+ | R | rectangle |
+ | O | circle |
+ | F | fill |
+ | T | text cursor |
+ | ⌫ | delete char |
+
+
+
+ mail
+
+ | ⌫ | delete selected mails |
+ | ⇧ ⌫ | delete (skip confirm) |
+
+
+
+
+
+
+
@@ -1351,13 +1461,7 @@ if (str_starts_with($route, '/api/')) {
shortcuts
-
- | Ctrl+K | open / close search |
- | Ctrl+N | quick capture — type title, Enter to save |
- | Ctrl+Z | undo last action |
- | F1 | open / close help |
- | Escape | close any overlay |
-
+ Press ? for the full keyboard reference.
diff --git a/public/js/app.js b/public/js/app.js
index 825875d..34215ac 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -112,8 +112,16 @@ function closeHelp() { helpOverlay?.classList.remove('open'); }
document.getElementById('help-btn')?.addEventListener('click', openHelp);
document.getElementById('help-close')?.addEventListener('click', closeHelp);
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
+
+const shortcutsOverlay = document.getElementById('shortcuts');
+function openShortcuts() { shortcutsOverlay?.classList.add('open'); }
+function closeShortcuts() { shortcutsOverlay?.classList.remove('open'); }
+document.getElementById('shortcuts-btn')?.addEventListener('click', openShortcuts);
+document.getElementById('shortcuts-close')?.addEventListener('click', closeShortcuts);
+shortcutsOverlay?.addEventListener('click', e => { if (e.target === shortcutsOverlay) closeShortcuts(); });
document.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
+ if (e.key === '?' && document.activeElement === document.body) { e.preventDefault(); shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts(); }
});
// ── Export / Import ──
@@ -228,12 +236,48 @@ document.addEventListener('keydown', e => {
else if (tasks?.classList.contains('open')) closeTasks();
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar();
+ else if (shortcutsOverlay?.classList.contains('open')) closeShortcuts();
return;
}
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
+ // Bare "n" → open editor for a new card (same as clicking the + button).
+ if (e.key === 'n' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
+ const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
+ if (blockers.some(el => el?.classList.contains('open'))) return;
+ e.preventDefault();
+ openModal(null);
+ return;
+ }
+ // Bare "a" → bucket all, "f" → bucket favorites. Same guards as the
+ // other bare-key shortcuts.
+ if ((e.key === 'a' || e.key === 'f') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
+ const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
+ if (blockers.some(el => el?.classList.contains('open'))) return;
+ e.preventDefault();
+ activateBucket(e.key === 'a' ? 'all' : 'favorites');
+ return;
+ }
+ // Bare "j" → open today's journal card (or create one if it doesn't
+ // exist). Journal cards have a YYYY-MM-DD title prefix.
+ if (e.key === 'j' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
+ const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
+ if (blockers.some(el => el?.classList.contains('open'))) return;
+ e.preventDefault();
+ const today = new Date().toISOString().substring(0, 10);
+ const existing = state.cards.find(c => (c.title || '').startsWith(today));
+ if (existing) {
+ openModal(existing);
+ } else {
+ // Switch to journal view so openModal picks up the journal
+ // context (auto-fills the date title).
+ activateBucket('journal');
+ openModal(null);
+ }
+ return;
+ }
// Bare "c" toggles the calendar from the board. Same guards as "/":
// no modifier, focus on (so it doesn't intercept text typing),
// and no full-screen overlay already in front.
@@ -255,6 +299,36 @@ document.addEventListener('keydown', e => {
openTagQuickPick();
return;
}
+ // Tab / Shift+Tab cycles through ALL bucket tiles in bar order
+ // (specials + user buckets + super-buckets). Wraps around at both
+ // ends. Same focus + overlay guards as the other bare-key shortcuts.
+ if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && document.activeElement === document.body) {
+ const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
+ if (blockers.some(el => el?.classList.contains('open'))) return;
+ const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
+ if (!tiles.length) return;
+ // Find which tile is currently active
+ const curIdx = tiles.findIndex(t => {
+ const bid = t.dataset.bucket || t.dataset.bucketSuper;
+ if (bid == state.activeBucket) return true;
+ // Super-bucket: active if any child matches
+ if (t.dataset.bucketSuper) {
+ return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket);
+ }
+ return false;
+ });
+ const dir = e.shiftKey ? -1 : 1;
+ const nextIdx = (curIdx + dir + tiles.length) % tiles.length;
+ const next = tiles[nextIdx];
+ if (next) {
+ e.preventDefault();
+ const target = next.dataset.bucketSuper
+ ? (next.dataset.superShown || next.dataset.bucketSuper)
+ : next.dataset.bucket;
+ activateBucket(target);
+ }
+ return;
+ }
// Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters
// (all/current/favorites/journal/none) are skipped, super-buckets
// count as one slot and activate via activateBucket() so the
@@ -287,8 +361,93 @@ document.addEventListener('keydown', e => {
e.preventDefault();
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
}
+
+ // Enter in detail view → close preview, then open editor
+ if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && detail.classList.contains('open') && state.viewingCard) {
+ e.preventDefault();
+ const card = state.viewingCard;
+ closeDetail();
+ openModal(card);
+ return;
+ }
+
+ // ── Board arrow-key card navigation ──
+ // Arrow keys select a card on the board (different from Ctrl+click
+ // multi-select — this is a single-focus cursor). Enter on a focused
+ // card opens the detail view. Escape clears the focus. The focus
+ // indicator is a `.card-focused` outline (accent2, distinct from the
+ // multi-select accent outline).
+ const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
+ if ((isArrow || e.key === 'Enter') && document.activeElement === document.body) {
+ const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
+ if (blockers.some(el => el?.classList.contains('open'))) return;
+
+ const board = document.getElementById('board');
+ if (!board) return;
+ const cards = [...board.querySelectorAll('.board-card[data-id]')];
+ if (!cards.length) return;
+
+ if (e.key === 'Enter') {
+ const focused = board.querySelector('.card-focused');
+ if (focused) {
+ e.preventDefault();
+ window._openDetail(focused.dataset.id);
+ clearCardFocus();
+ }
+ return;
+ }
+
+ e.preventDefault();
+ const focused = board.querySelector('.card-focused');
+ let curIdx = focused ? cards.indexOf(focused) : -1;
+
+ if (curIdx < 0) {
+ // No focus yet → select the first card
+ setCardFocus(cards[0]);
+ return;
+ }
+
+ // Compute columns per row from the grid layout
+ const firstTop = cards[0].offsetTop;
+ let cols = cards.length;
+ for (let i = 1; i < cards.length; i++) {
+ if (cards[i].offsetTop !== firstTop) { cols = i; break; }
+ }
+
+ let next = curIdx;
+ if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1);
+ else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0);
+ else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1);
+ else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0);
+
+ if (next !== curIdx) setCardFocus(cards[next]);
+ }
});
+let focusedCardId = null;
+function setCardFocus(el) {
+ clearCardFocus();
+ if (!el) return;
+ el.classList.add('card-focused');
+ focusedCardId = el.dataset.id;
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+}
+function clearCardFocus() {
+ document.querySelectorAll('.card-focused').forEach(c => c.classList.remove('card-focused'));
+ focusedCardId = null;
+}
+// Clear focus when clicking anywhere on the board (a card click opens
+// detail/editor and focus is no longer meaningful), or when any overlay
+// opens. Also re-apply after a board re-render if the focused card is
+// still in the DOM.
+document.addEventListener('click', () => { if (focusedCardId) clearCardFocus(); });
+window._restoreCardFocus = function () {
+ if (!focusedCardId) return;
+ const el = document.querySelector(`#board .board-card[data-id="${focusedCardId}"]`);
+ if (el) el.classList.add('card-focused');
+ else clearCardFocus();
+};
+
// ── Quick Capture ──
document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => {
@@ -318,7 +477,12 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
if (!cardTitle) return;
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
- await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1 } });
+ // Place the card in the currently active bucket (if it's a real
+ // user bucket). Special filters (all/current/favorites/journal/none)
+ // don't map to a bucket_id — cards captured there stay unsorted.
+ const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none'];
+ const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket;
+ await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
e.target.value = '';
document.getElementById('quick-capture').classList.add('hidden');
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index 225ad65..6131a30 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -714,6 +714,9 @@ export function renderBoard() {
});
}
+ // Restore arrow-key card focus if the focused card is still rendered
+ window._restoreCardFocus?.();
+
// Restore selection state
selectedIds.forEach(id => {
const el = board.querySelector(`[data-id="${id}"]`);
diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js
index 0c97f48..66a5211 100644
--- a/public/plugins/tests/runner.js
+++ b/public/plugins/tests/runner.js
@@ -171,7 +171,7 @@ export function csrf() {
}
export async function closeAll() {
- for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
+ for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) el.classList.remove('open');
}