shortcuts: central registry + auto-generated help, test stabilization

New module public/js/modules/shortcuts.js:
* registerShortcut({key, ctrl, shift, alt, match, guard, priority,
  section, label, keys, handler}) — declarative shortcut registration
  with priority ordering, custom match predicates, and runtime guards.
* Single document-level keydown dispatcher iterates the registry
  (sorted by priority desc), matches key+modifiers or custom match(),
  checks guard(), fires handler with preventDefault.
* guards object: board(), editor(), detail(), search(), email(),
  canvas(), bodyFocused(), noOverlay(), isOpen(id) — reusable
  predicates that replace the repeated blocker-array patterns.
* renderShortcutHelp() auto-generates the #shortcuts overlay content
  from the registry, grouped by section. New shortcuts added via
  registerShortcut appear in the help screen automatically.
* Shortcuts with guard: () => false are help-screen-only entries for
  element-level shortcuts handled by their own modules (editor
  textarea, search input, canvas overlay, etc.).

Migration (public/js/app.js):
* The ~200-line monolithic keydown handler is replaced by ~80
  declarative registerShortcut() calls.
* Escape chain: 10 separate registrations with priorities 100→20,
  each with its own guard (quick-capture, detail/zen, modal, search,
  tasks, gallery, calendar, shortcuts, help). A hidden: true flag
  suppresses them from the help screen; one visible entry with
  guard: () => false shows "Esc — close any overlay".
* Global combos: Ctrl+K (search), Ctrl+N (quick capture), Ctrl+Z
  (undo), F1 (help), ? (shortcuts).
* Editor: Ctrl+S (save), Ctrl+Shift+S (save+close), Ctrl+Enter
  (save+close). Element-level shortcuts (Tab indent, Alt+arrows,
  autocomplete triggers) registered as help-screen-only.
* Board bare keys: a/f/j/n/t/c//, Tab/Shift+Tab, 1-9, arrow nav,
  Enter → preview, Delete/Backspace selected.
* Detail: ←/→ group navigation, Enter → editor.
* Help-screen-only: search ↑↓/Enter, board mouse actions, canvas
  tool shortcuts (B/E/L/R/O/F/T/⌫), mail ⌫/⇧⌫, m (plugin).

Removed from board.js: the document-level Delete/Backspace listener
(now in the central registry with the same logic).

Help screen (public/index.php):
* The hardcoded 3-column shortcut HTML is replaced by an empty
  .shortcut-help-content div that renderShortcutHelp() fills at boot.
* CSS: .shortcut-help-content section gets break-inside:avoid +
  margin-bottom for clean column flow.

Test stabilization:
* card-interactions.test.js: due-pill, overdue-border, and
  child-hidden-on-board tests now set state.activeBucket='all'
  before _reloadAll and poll for the DOM element (40×50ms) instead
  of a fixed sleep(200). Eliminates the flaky "card not found" when
  activeBucket was leaked by a prior test.
* editor-features.test.js: "save creates card and closes editor"
  polls for #modal to lose .open class (40×50ms) instead of a fixed
  sleep(400). doSave(true) does 2-3 serial API calls before
  closeModal which sometimes exceeded the old budget.

chore: bump version to 0.9.13

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:50:49 +02:00
1 parent 7c113fd5ab
commit b459159b0f
8 files changed
+337 -348

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.9.12');
define('APP_VERSION', '0.9.13');
// Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
+6 -2
View File
@@ -838,11 +838,15 @@ body.anim-slow .board-card.card-pulse-new {
font-family:var(--t-font-pixel, 'Silkscreen', cursive);
font-size:9px; color:var(--t-dim);
}
/* The auto-generated shortcut help uses a 3-column grid on the
.shortcut-help-content div. Collapse to 2 then 1 on smaller screens. */
.shortcut-help-content { column-gap: 20px; }
.shortcut-help-content section { break-inside: avoid; margin-bottom: 16px; }
@media (max-width: 900px) {
#shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr 1fr !important; }
.shortcut-help-content { grid-template-columns: 1fr 1fr !important; }
}
@media (max-width: 640px) {
#shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
.shortcut-help-content { grid-template-columns: 1fr !important; }
}
/* Tag chips */
+4 -97
View File
@@ -1336,103 +1336,10 @@ if (str_starts_with($route, '/api/')) {
<div class="flex-1"></div>
<button id="shortcuts-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
</div>
<div class="p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<!-- Column 1 -->
<div class="space-y-5">
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">navigation</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>A</kbd></td><td>all cards</td></tr>
<tr><td><kbd>F</kbd></td><td>favorites</td></tr>
<tr><td><kbd>J</kbd></td><td>today's journal</td></tr>
<tr><td><kbd>N</kbd></td><td>new card</td></tr>
<tr><td><kbd>T</kbd></td><td>tag quick-pick</td></tr>
<tr><td><kbd>C</kbd></td><td>calendar</td></tr>
<tr><td><kbd>M</kbd></td><td>mail</td></tr>
<tr><td><kbd>1</kbd> – <kbd>9</kbd></td><td>Nth user bucket</td></tr>
<tr><td><kbd>⇥</kbd></td><td>next bucket</td></tr>
<tr><td><kbd>⇧</kbd> <kbd>⇥</kbd></td><td>prev bucket</td></tr>
<tr><td><kbd>/</kbd></td><td>search</td></tr>
</tbody></table>
</section>
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">global</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>⌃</kbd> <kbd>K</kbd></td><td>search</td></tr>
<tr><td><kbd>⌃</kbd> <kbd>N</kbd></td><td>quick capture (in bucket)</td></tr>
<tr><td><kbd>⌃</kbd> <kbd>Z</kbd></td><td>undo</td></tr>
<tr><td><kbd>Esc</kbd></td><td>close overlay</td></tr>
<tr><td><kbd>F1</kbd></td><td>help</td></tr>
<tr><td><kbd>?</kbd></td><td>this screen</td></tr>
<tr><td><kbd>Del</kbd></td><td>delete selected</td></tr>
</tbody></table>
</section>
</div>
<!-- Column 2 -->
<div class="space-y-5">
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">editor</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>⌃</kbd> <kbd>S</kbd></td><td>save</td></tr>
<tr><td><kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd></td><td>save + close</td></tr>
<tr><td><kbd>⌃</kbd> <kbd>↵</kbd></td><td>save + close</td></tr>
<tr><td><kbd>⇥</kbd></td><td>indent / title→body</td></tr>
<tr><td><kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd></td><td>move line</td></tr>
<tr><td><kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd></td><td>duplicate line</td></tr>
<tr><td><kbd>↵</kbd></td><td>auto-continue list</td></tr>
<tr><td><kbd>[</kbd><kbd>[</kbd></td><td>link autocomplete</td></tr>
<tr><td><kbd>#</kbd></td><td>tag autocomplete</td></tr>
<tr><td><kbd>?</kbd><em>snippet</em></td><td>snippet expand</td></tr>
</tbody></table>
</section>
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">search</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>↑</kbd> <kbd>↓</kbd></td><td>navigate results</td></tr>
<tr><td><kbd>↵</kbd></td><td>open selected result</td></tr>
<tr><td><kbd>Esc</kbd></td><td>close search</td></tr>
</tbody></table>
</section>
</div>
<!-- Column 3 -->
<div class="space-y-5">
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">board + detail</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd></td><td>focus card</td></tr>
<tr><td><kbd>↵</kbd></td><td>focused → preview</td></tr>
<tr><td><kbd>↵</kbd> <span class="mouse">in preview</span></td><td>→ editor</td></tr>
<tr><td><kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span></td><td>group prev / next</td></tr>
<tr><td><span class="mouse">click</span></td><td>preview / split</td></tr>
<tr><td><span class="mouse">dbl-click</span></td><td>editor</td></tr>
<tr><td><span class="mouse">right-click</span></td><td>context menu</td></tr>
<tr><td><kbd>⌃</kbd> <span class="mouse">click</span></td><td>multi-select</td></tr>
<tr><td><span class="mouse">drag → edge</span></td><td>reorder</td></tr>
<tr><td><span class="mouse">drag → center</span></td><td>group</td></tr>
</tbody></table>
</section>
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">canvas (ascii editor)</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>B</kbd></td><td>brush</td></tr>
<tr><td><kbd>E</kbd></td><td>eraser</td></tr>
<tr><td><kbd>L</kbd></td><td>line</td></tr>
<tr><td><kbd>R</kbd></td><td>rectangle</td></tr>
<tr><td><kbd>O</kbd></td><td>circle</td></tr>
<tr><td><kbd>F</kbd></td><td>fill</td></tr>
<tr><td><kbd>T</kbd></td><td>text cursor</td></tr>
<tr><td><kbd>⌫</kbd></td><td>delete char</td></tr>
</tbody></table>
</section>
<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">mail</h3>
<table class="shortcut-table"><tbody>
<tr><td><kbd>⌫</kbd></td><td>delete selected mails</td></tr>
<tr><td><kbd>⇧</kbd> <kbd>⌫</kbd></td><td>delete (skip confirm)</td></tr>
</tbody></table>
</section>
</div>
</div>
<!-- Filled by shortcuts.js renderShortcutHelp() from the registry.
Layout: 3 columns on desktop, 2 on tablet, 1 on mobile.
The CSS grid is set on .shortcut-help-content. -->
<div class="shortcut-help-content p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;"></div>
</div>
</div>
+139 -219
View File
@@ -13,6 +13,7 @@ import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
@@ -119,11 +120,6 @@ 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 ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
@@ -201,228 +197,151 @@ async function checkAuth() {
startBackgroundSync();
}
// ── Global keyboard ──
// ── Keyboard shortcuts (central registry) ──
//
// All document-level shortcuts are registered here via registerShortcut().
// The dispatcher in shortcuts.js handles key matching, guards, and
// priority ordering. Element-specific shortcuts (editor textarea, search
// input, canvas, dialog) stay on their respective DOM elements.
document.addEventListener('keydown', e => {
const modal = document.getElementById('modal');
const detail = document.getElementById('detail');
const search = document.getElementById('search');
const tasks = document.getElementById('tasks');
if (isDialogOpen()) return;
const { board: gBoard } = guards;
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
const qc = document.getElementById('quick-capture');
const qci = document.getElementById('quick-capture-input');
if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); }
else { qc.classList.add('hidden'); qci.value = ''; }
return;
}
if (e.key === 'Escape') {
const qc = document.getElementById('quick-capture');
if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; }
if (detail.classList.contains('open')) {
// If the user chose "exit zen first", ESC leaves zen mode before closing the card.
const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close';
if (escAction === 'exitZen' && detail.classList.contains('zen')) {
document.getElementById('detail-zen')?.click();
return;
}
closeDetail();
}
else if (modal.classList.contains('open')) closeModal();
else if (search.classList.contains('open')) closeSearch();
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.
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const calendar = document.getElementById('calendar');
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
if (calendar?.classList.contains('open')) closeCalendar();
else openCalendar();
return;
}
// Bare "t" opens the tag quick-pick on the board. Same focus +
// overlay guards as "c". Picking a tag sets state.activeTags = [tag].
if (e.key === 't' && !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();
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
// dropdown popover is NOT triggered.
if (/^[1-9]$/.test(e.key) && !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;
const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]
.filter(t => !SPECIAL.has(t.dataset.bucket));
const idx = parseInt(e.key, 10) - 1;
const tile = tiles[idx];
if (tile) {
e.preventDefault();
// Super-bucket: activate the currently shown member (parent or
// active child) directly instead of clicking, which would open
// the dropdown popover. Regular bucket: just take its data-bucket.
const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket;
activateBucket(target);
}
return;
}
// — Escape chain (highest priority first) —
registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() });
registerShortcut({ key: 'Escape', priority: 40, guard: () => !isDialogOpen() && guards.isOpen('calendar'), section: 'global', label: 'close calendar', hidden: true, handler: () => closeCalendar() });
registerShortcut({ key: 'Escape', priority: 30, guard: () => !isDialogOpen() && guards.isOpen('shortcuts'), section: 'global', label: 'close shortcuts', hidden: true, handler: () => closeShortcuts() });
registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() });
// Visible Escape entry for the help screen
registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: '<kbd>Esc</kbd>', label: 'close any overlay' });
// Arrow keys navigate group in detail view
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
const gc = getGroupCards(state.viewingCard);
if (!gc || gc.length < 2) return;
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
if (idx === -1) return;
e.preventDefault();
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
// — Global (work everywhere unless dialog is open) —
registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>K</kbd>', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>N</kbd>', label: 'quick capture (in bucket)', handler: () => { const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } } });
registerShortcut({ key: 'z', ctrl: true, guard: () => !isDialogOpen() && !guards.isOpen('modal') && canUndo(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>Z</kbd>', label: 'undo', handler: () => undo() });
registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>F1</kbd>', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '<kbd>?</kbd>', label: 'this screen', handler: () => shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts() });
// — Editor (modal open) —
registerShortcut({ key: 's', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>S</kbd>', label: 'save', handler: () => saveOnly() });
registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd>', label: 'save + close', handler: () => saveCard() });
registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>↵</kbd>', label: 'save + close', handler: () => saveCard() });
// Editor element-level shortcuts listed for help screen only (handled by editor.js)
registerShortcut({ key: 'Tab', guard: () => false, section: 'editor', keys: '<kbd>⇥</kbd>', label: 'indent / title→body' });
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'move line' });
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'duplicate line' });
registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<kbd>↵</kbd>', label: 'auto-continue list' });
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
// — Detail view —
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
const gc = getGroupCards(state.viewingCard);
if (!gc || gc.length < 2) return;
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
if (idx === -1) return;
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
}});
registerShortcut({ key: 'Enter', guard: () => !isDialogOpen() && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>↵</kbd> <span class="mouse">in preview</span>', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
// — Board navigation (body focus, no overlay) —
registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: '<kbd>A</kbd>', label: 'all cards', handler: () => activateBucket('all') });
registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: '<kbd>F</kbd>', label: 'favorites', handler: () => activateBucket('favorites') });
registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: '<kbd>N</kbd>', label: 'new card', handler: () => openModal(null) });
registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J</kbd>', label: 'today\'s journal', handler: () => {
const today = new Date().toISOString().substring(0, 10);
const existing = state.cards.find(c => (c.title || '').startsWith(today));
if (existing) openModal(existing);
else { activateBucket('journal'); openModal(null); }
}});
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => {
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
if (!tiles.length) return;
const curIdx = tiles.findIndex(t => {
const bid = t.dataset.bucket || t.dataset.bucketSuper;
if (bid == state.activeBucket) return true;
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 next = tiles[(curIdx + dir + tiles.length) % tiles.length];
if (next) activateBucket(next.dataset.bucketSuper ? (next.dataset.superShown || next.dataset.bucketSuper) : next.dataset.bucket);
}});
registerShortcut({ match: (e) => /^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && gBoard(), section: 'navigation', keys: '<kbd>1</kbd> – <kbd>9</kbd>', label: 'Nth user bucket', handler: (e) => {
const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')].filter(t => !SPECIAL.has(t.dataset.bucket));
const tile = tiles[parseInt(e.key, 10) - 1];
if (tile) activateBucket(tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket);
}});
// — Board arrow-key card navigation + Enter —
registerShortcut({ match: (e) => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && gBoard(), section: 'board + detail', keys: '<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd>', label: 'focus card', handler: (e) => {
const board = document.getElementById('board');
if (!board) return;
const cards = [...board.querySelectorAll('.board-card[data-id]')];
if (!cards.length) return;
const focused = board.querySelector('.card-focused');
let curIdx = focused ? cards.indexOf(focused) : -1;
if (curIdx < 0) { setCardFocus(cards[0]); return; }
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]);
}});
registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelector('#board .card-focused'), section: 'board + detail', keys: '<kbd>↵</kbd>', label: 'focused → preview', handler: () => {
const focused = document.querySelector('#board .card-focused');
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
}});
// Delete selected cards
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
// Delegated to board.js's existing handler via a synthetic event is
// impractical; re-implement the small logic here to avoid coupling.
const ids = getSelectedIds();
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
if (!ok) return;
}
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
clearSelection();
await window._reloadAll();
}});
// 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;
}
// — Search (help-screen-only, handled by search.js element listener) —
registerShortcut({ key: 'ArrowDown', guard: () => false, section: 'search', keys: '<kbd>↑</kbd> <kbd>↓</kbd>', label: 'navigate results' });
registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '<kbd>↵</kbd>', label: 'open selected result' });
registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: '<kbd>Esc</kbd>', label: 'close search' });
// ── 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;
// — Board mouse actions (help-screen-only) —
registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: '<span class="mouse">click</span>', label: 'preview / split' });
registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: '<span class="mouse">dbl-click</span>', label: 'editor' });
registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: '<span class="mouse">right-click</span>', label: 'context menu' });
registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '<kbd>⌃</kbd> <span class="mouse">click</span>', label: 'multi-select' });
registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → edge</span>', label: 'reorder' });
registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → center</span>', label: 'group' });
const board = document.getElementById('board');
if (!board) return;
const cards = [...board.querySelectorAll('.board-card[data-id]')];
if (!cards.length) return;
// — Canvas (help-screen-only, handled by canvas.js overlay listener) —
for (const [k, lbl] of [['B','brush'],['E','eraser'],['L','line'],['R','rectangle'],['O','circle'],['F','fill'],['T','text cursor']]) {
registerShortcut({ key: k.toLowerCase(), guard: () => false, section: 'canvas', keys: `<kbd>${k}</kbd>`, label: lbl });
}
registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '<kbd>⌫</kbd>', label: 'delete char' });
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]);
}
});
// — Mail (help-screen-only, handled by email.js document listener) —
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⌫</kbd>', label: 'delete selected mails' });
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⇧</kbd> <kbd>⌫</kbd>', label: 'delete (skip confirm)' });
let focusedCardId = null;
function setCardFocus(el) {
@@ -588,6 +507,7 @@ function openTagQuickPick() {
// ── Boot ──
renderShortcutHelp();
checkAuth();
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
+3 -15
View File
@@ -119,21 +119,9 @@ function clearSelection() {
export function getSelectedIds() { return [...selectedIds]; }
export { clearSelection };
// Delete/Backspace deletes selected cards
document.addEventListener('keydown', async e => {
if (!selectedIds.size) return;
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
e.preventDefault();
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const { showDialog } = await import('./dialog.js');
const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
if (!ok) return;
}
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
clearSelection();
await window._reloadAll();
});
// Delete/Backspace for selected cards is now in the central shortcuts
// registry (app.js registerShortcut). It uses getSelectedIds() /
// clearSelection() which are already exported.
// Clear selection on click outside board (header, footer, etc)
document.addEventListener('click', e => {
+155
View File
@@ -0,0 +1,155 @@
// ── Central keyboard shortcut registry ──
//
// Every keyboard shortcut — bare keys, combos, context-specific — is
// registered here via registerShortcut(). A single document-level
// keydown listener dispatches to the matching handler. The registry
// also drives the auto-generated #shortcuts help screen so the UI
// and the code always agree on what's available.
//
// Shortcuts that live on specific DOM elements (editor textarea,
// search input, canvas overlay, dialog) stay element-scoped because
// they depend on local module state (autocomplete visibility, etc.).
// Only document-level shortcuts are centralized here.
const registry = [];
const OVERLAY_IDS = ['modal', 'detail', 'search', 'calendar', 'gallery',
'settings-overlay', 'help', 'shortcuts', 'email-overlay', 'tasks',
'dialog', 'ascii-canvas-overlay'];
function isOpen(id) {
return document.getElementById(id)?.classList.contains('open');
}
function noOverlay() {
return !OVERLAY_IDS.some(isOpen);
}
function bodyFocused() {
return document.activeElement === document.body;
}
/**
* Register a keyboard shortcut.
*
* @param {object} opts
* @param {string} opts.key — e.key value to match (e.g. 'a', 'Enter', 'Escape', '?')
* @param {boolean} [opts.ctrl] — require Ctrl/Cmd
* @param {boolean} [opts.shift] — require Shift
* @param {boolean} [opts.alt] — require Alt
* @param {function} [opts.match] — custom predicate (e) => bool; overrides key/ctrl/shift/alt
* @param {function} [opts.guard] — extra runtime check; shortcut only fires when guard() is truthy
* @param {number} [opts.priority] — higher = checked first (default 0)
* @param {boolean} [opts.stop] — call e.stopPropagation() + e.stopImmediatePropagation()
* @param {string} opts.section — help-screen section header
* @param {string} opts.label — human description for the help screen
* @param {string} [opts.keys] — display string for the help screen (e.g. '⌃ S'); auto-built if omitted
* @param {boolean} [opts.hidden] — true to suppress from the help screen
* @param {function} opts.handler — (e: KeyboardEvent) => void
*/
export function registerShortcut(opts) {
registry.push({
key: opts.key ?? null,
ctrl: !!opts.ctrl,
shift: !!opts.shift,
alt: !!opts.alt,
match: opts.match ?? null,
guard: opts.guard ?? null,
priority: opts.priority ?? 0,
stop: !!opts.stop,
section: opts.section ?? 'misc',
label: opts.label ?? '',
keys: opts.keys ?? null,
hidden: !!opts.hidden,
handler: opts.handler,
});
// Keep sorted by priority desc so the dispatch loop can short-circuit.
registry.sort((a, b) => b.priority - a.priority);
}
/** Return the full registry for the auto-generated help screen. */
export function getShortcuts() {
return registry.filter(s => !s.hidden);
}
/** Utility guards for use in shortcut definitions. */
export const guards = {
board: () => bodyFocused() && noOverlay(),
editor: () => isOpen('modal'),
detail: () => isOpen('detail'),
search: () => isOpen('search'),
email: () => isOpen('email-overlay'),
canvas: () => isOpen('ascii-canvas-overlay'),
noOverlay,
bodyFocused,
isOpen,
};
// ── Dispatcher ──
document.addEventListener('keydown', (e) => {
for (const s of registry) {
// Key match
let matched;
if (s.match) {
matched = s.match(e);
} else {
if (s.key !== e.key) continue;
if (s.ctrl !== (e.ctrlKey || e.metaKey)) continue;
if (s.shift !== e.shiftKey) continue;
if (s.alt !== e.altKey) continue;
matched = true;
}
if (!matched) continue;
// Guard
if (s.guard && !s.guard()) continue;
// Fire
e.preventDefault();
if (s.stop) {
e.stopPropagation();
e.stopImmediatePropagation();
}
s.handler(e);
return;
}
});
// ── Auto-generated help screen ──
/** Render the #shortcuts overlay content from the registry.
* Called once after all modules have registered their shortcuts
* (typically at the end of app.js boot). */
export function renderShortcutHelp() {
const container = document.querySelector('#shortcuts .shortcut-help-content');
if (!container) return;
const visible = getShortcuts();
// Group by section, preserve registration order within each section
const sections = new Map();
for (const s of visible) {
if (!sections.has(s.section)) sections.set(s.section, []);
sections.get(s.section).push(s);
}
// Build HTML — each section is a block with a header + table.
let html = '';
for (const [name, shortcuts] of sections) {
html += `<section>
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">${esc(name)}</h3>
<table class="shortcut-table"><tbody>`;
for (const s of shortcuts) {
const keysHtml = s.keys
? s.keys // pre-formatted HTML (kbd tags etc.)
: `<kbd>${esc(s.key || '?')}</kbd>`;
html += `<tr><td>${keysHtml}</td><td>${esc(s.label)}</td></tr>`;
}
html += `</tbody></table></section>`;
}
container.innerHTML = html;
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
+22 -13
View File
@@ -59,10 +59,15 @@ describe('Card: Due Date & Done', () => {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2099-12-31' }),
});
state.activeBucket = 'all';
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
// Poll for the card to appear in the DOM instead of a fixed sleep
let cardEl = null;
for (let i = 0; i < 40; i++) {
cardEl = document.querySelector(`[data-id="${res.id}"]`);
if (cardEl) break;
await sleep(50);
}
const pill = cardEl?.querySelector('.due-pill');
expect(pill).toExist();
expect(pill.textContent).toContain('2099-12-31');
@@ -92,12 +97,15 @@ describe('Card: Due Date & Done', () => {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2020-01-01' }),
});
state.activeBucket = 'all';
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
let cardEl = null;
for (let i = 0; i < 40; i++) {
cardEl = document.querySelector(`[data-id="${res.id}"]`);
if (cardEl) break;
await sleep(50);
}
expect(cardEl).toExist();
// Overdue cards get danger border via inline style
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
});
});
@@ -259,13 +267,14 @@ describe('Card: Grouping', () => {
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
}).then(r => r.json());
state.activeBucket = 'all';
await window._reloadAll?.();
await sleep(200);
// Child should not be a standalone card on board
const childEl = document.querySelector(`[data-id="${child.id}"]`);
// It may or may not render depending on group view, but parent should exist
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
let parentEl = null;
for (let i = 0; i < 40; i++) {
parentEl = document.querySelector(`[data-id="${parent.id}"]`);
if (parentEl) break;
await sleep(50);
}
expect(parentEl).toExist();
});
});
+7 -1
View File
@@ -68,7 +68,13 @@ describe('Editor: Save Card', () => {
await sleep(50);
click('#modal-save-close');
await sleep(400);
// doSave(true) does API PATCH + _reloadAll (2-3 network calls)
// before closeModal — poll until the modal is actually hidden
// instead of a fixed sleep that races against server speed.
for (let i = 0; i < 40; i++) {
if (!$('#modal')?.classList.contains('open')) break;
await sleep(50);
}
expect($('#modal')).toBeHidden();
// Card should exist