shortcuts: board arrow-nav, enter→preview→edit, help screen polish, n/a/f/j hotkeys

Board arrow-key card navigation (public/js/app.js):
* ↑/↓/←/→ on the board (body focus, no overlay) move a single-card
  focus cursor (.card-focused, accent2 outline) through the visible
  card grid. Column count is computed live from the first row's
  offsetTop so it tracks the CSS grid columns at any viewport width.
* First arrow press focuses the first card; subsequent presses move
  in the grid. Focused card scrolls into view (smooth, nearest).
* Enter on a focused card → openDetail (preview). Focus cleared.
* Enter in detail view → closeDetail + openModal (editor). Card ref
  is captured before closeDetail nulls state.viewingCard.
* Click anywhere → focus cleared. Re-renders restore focus via
  window._restoreCardFocus() called from board.js renderBoard().

CSS (public/css/style.css):
* .card-focused: accent2 outline + glow, visually distinct from
  .card-selected (accent, multi-select) and .preview-active.
* .shortcut-table kbd keycaps: JetBrains Mono font (not pixel),
  10px, 3D raised look with gradient + bottom border + inner
  highlight + shadow.
* Responsive shortcut grid: 3 cols > 900px, 2 cols > 640px, 1 col.

New bare-key shortcuts (public/js/app.js):
* n → openModal(null) (new card, same as the + button)
* a → activateBucket('all')
* f → activateBucket('favorites')
* j → open today's journal card (find by YYYY-MM-DD prefix in
  state.cards), or create a new one (switch to journal view, open
  empty editor which auto-fills the date title)

Ctrl+N quick capture (public/js/app.js):
* Now sends bucket_id: state.activeBucket on the POST (unless the
  active view is a virtual filter like all/current/etc., in which
  case bucket_id stays null). Cards captured while viewing a user
  bucket land directly in that bucket.

Shortcuts help screen (public/index.php):
* max-w-4xl, 3-column grid layout.
* Complete shortcut inventory across all modules:
  - Navigation: a/f/j/n/t/c/m, 1-9, Tab/Shift+Tab, /
  - Global: Ctrl+K/N/Z, Esc, F1, ?, Del
  - Editor: Ctrl+S, Ctrl+Shift+S, Ctrl+Enter, Tab, Alt+↑↓,
    Shift+Alt+↑↓, Enter (list continue), [[ (link ac), # (tag ac),
    ?snippet
  - Search: ↑↓ navigate, Enter open, Esc close
  - Board+Detail: arrows focus, Enter→preview, Enter→editor,
    ←→ group nav, click/dbl/right/ctrl+click, drag edge/center
  - Canvas: b/e/l/r/o/f/t tool shortcuts, ⌫ delete
  - Mail: ⌫ delete selected, Shift+⌫ skip confirm
* kbd elements use normal font (JetBrains Mono) instead of pixel.
* Help overlay's old shortcuts section replaced with a one-liner
  pointing to ? for the full reference.
* #shortcuts added to runner.js closeAll() list.

chore: bump version to 0.9.12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 10:32:31 +02:00
1 parent c8835825e7
commit 7c113fd5ab
6 files changed
+322 -10

No files matched your search

+165 -1
View File
@@ -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 <body> (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');