Files
hyprcrd/public/js/app.js
T
thandClaude Opus 4.6 923de0cdd6 hotkeys, gitea ticket-from-card, collab unread, runner auto-retry
board hotkeys (public/js/app.js):
* "c" toggles the calendar from the board (no modifier, body focus,
  no full-screen overlay in front).
* "t" opens a tag quick-pick popover that lists every unique tag from
  state.cards. Filter input + arrow-key/enter pick + ESC/click-outside
  close. Picking sets state.activeTags = [tag] and re-renders the
  board + tag bar. Toggle: a second "t" press closes the picker.
* "1"-"9" picks the Nth user bucket in the bar. The fixed filters
  (all/current/favorites/journal/none) are skipped. Super-buckets
  count as one slot and activate via the new activateBucket() helper
  in buckets.js — that bypasses the click handler so the dropdown
  popover does NOT open; the currently shown member (parent or
  active child) is selected directly.
* ESC chain in the existing handler now also closes #calendar (was
  missing — calendar.js only handles ESC for the day-popup, not the
  whole overlay).

buckets (public/js/modules/buckets.js):
* New exported activateBucket(idOrSpecial) helper: setActiveBucket
  + clear activeGroup + renderBar + window._renderBoard. Used by
  the number-key hotkey to select super-buckets without triggering
  their dropdown.

board split panel (public/js/modules/board.js):
* closeBoardPreview() now removes the .active class from the
  toolbar's #board-split-toggle. The previous code removed
  .text-t-accent which was a no-op since toggleBoardSplit() uses
  .active — the X button on the panel closed the split but left
  the toolbar showing the toggle as still on.

email plugin (public/plugins/email/email.js):
* ESC closes the overlay reliably: the previous handler was on
  #email-overlay, which only fires when focus is *inside* the
  overlay (rare after open). Moved to a document-level keydown.
* Bare "m" toggles the mail overlay open/closed. Suppressed when
  Ctrl/Meta/Alt is held, focus is in an input/textarea/contenteditable,
  or any other full-screen overlay is in front.

collab-buckets (public/plugins/collab-buckets/backend.php):
* When a card change is applied in the pull phase from a foreign
  author (insert / update / tombstone), bump
  collab_buckets.updated_at to NOW for that bucket. has_unread is
  computed as updated_at > last_seen_at and was previously never
  triggered for incoming cards — the bucket-bar tab now gets the
  unread dot + 3-pulse when the user is looking at a different
  bucket. Own pushes are skipped (the relay echoes them back too).

gitea plugin: make ticket from card (public/plugins/gitea/gitea.js):
* New window._giteaMakeIssueFromCard(card) helper. Opens the gitea
  overlay, switches to the create-issue form, fetches the card body
  if it's lazy/null, and pre-fills #gitea-create-title and
  #gitea-create-body. Does NOT submit — the user picks the repo and
  reviews before clicking submit. Soft-bails on the welcome view
  if no gitea account is configured yet.

context-menu (public/js/modules/context-menu.js):
* New "Make ticket" entry in the card right-click menu, only rendered
  when window._giteaMakeIssueFromCard is exposed. Action handler
  hides the menu and calls the helper. Sits next to "Copy text".
* joinSelectedCards() now dedupes hashtag mentions in the merged body
  (first occurrence wins, case-insensitive) so a tag that appeared on
  multiple source cards isn't repeated in the resulting body. Followed
  by a tab/space/blank-line cleanup pass.

test runner auto-retry (public/plugins/tests/runner.js):
* runAll() takes an optional Set<string> of `${suite}::${test}` keys
  and runs only those when set. Progress bar denominator reflects
  the filtered count.
* run() collects every failed test after the first pass, restores the
  backup so the retry runs against the same baseline state, then
  re-runs only those via runAll(failedKeys). Retry results are merged
  into the original results array with `retried: true` and the
  original error preserved on `originalError`.
* renderResults distinguishes three states: passed first try (green ✓),
  pass-on-retry (amber ⚠ "flaky — passed on retry" with the first-run
  error shown), failed twice (red ✗ "failed twice"). Summary line
  carries a "N flaky" count and goes amber if only flaky tests
  appeared in the run.

tests (public/plugins/tests/context-menu.test.js):
* Two new tests for the make-ticket action:
  - action exists in the menu when window._giteaMakeIssueFromCard
    is exposed (soft-skip otherwise)
  - clicking it opens the gitea overlay, surfaces the create-issue
    form, and pre-fills title + body without submitting. Soft-skips
    the assertion when no gitea account is configured (welcome view).

chore: bump version to 0.9.7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:31:05 +02:00

430 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ══════════════════════════════════════
hyprcrd — app.js (orchestrator)
══════════════════════════════════════ */
import { api, setCsrf, getCsrf } from './modules/util.js';
import { state, getGroupCards } from './modules/state.js';
import { showDialog, isDialogOpen } from './modules/dialog.js';
import './modules/theme.js';
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
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 { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
import { openPluginSettings } from './modules/plugin-settings.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
window._openDetail = openDetail;
window._openModal = openModal;
window._openGallery = openGallery;
window._closeSearch = closeSearch;
window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
window._openPluginSettings = openPluginSettings;
window._loadCaldavSettings = loadCaldavSettings;
window._loadRelaySettings = loadRelaySettings;
window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
window.state = state;
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted';
const body = text.trim();
// Inherit context from the source card (viewing or editing)
const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null);
const data = { title, body, color: source?.color || '#38bdf8', position: -1 };
if (source?.bucket_id) data.bucket_id = source.bucket_id;
if (source?.parent_id) data.parent_id = source.parent_id;
else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id;
await api('api/cards', { method: 'POST', body: data });
await window._reloadAll();
};
// ── Data loading ──
const CARDS_CACHE_KEY = 'hyprcrd-cards-cache';
function getCachedCards() {
try { return JSON.parse(localStorage.getItem(CARDS_CACHE_KEY) || '[]'); } catch { return []; }
}
function setCachedCards(cards) {
try {
const slim = cards.map(c => ({ id: c.id, title: c.title, color: c.color, tags: c.tags, bucket_id: c.bucket_id, bucket_name: c.bucket_name, parent_id: c.parent_id, position: c.position, created_at: c.created_at, updated_at: c.updated_at, due_date: c.due_date, caldav_uid: c.caldav_uid, caldav_rrule: c.caldav_rrule }));
localStorage.setItem(CARDS_CACHE_KEY, JSON.stringify(slim));
} catch {}
}
async function loadCards() {
// Phase 0: Show cached cards instantly (from previous session)
const cached = getCachedCards();
if (cached.length) {
state.cards = cached.map(c => ({ ...c, body: null }));
updateSortUI();
renderBoard();
}
// Phase 1: Load metadata → update board with fresh titles/tags/dates
const meta = await api('api/cards/meta');
if (!meta) return;
state.cards = meta.map(c => ({ ...c, body: null }));
setCachedCards(meta);
updateSortUI();
renderBoard();
// Phase 2: Load full cards in batches → replace spinners with content
const batchSize = 25;
for (let i = 0; i < meta.length; i += batchSize) {
const batch = meta.slice(i, i + batchSize);
const ids = batch.map(c => c.id).join(',');
const full = await api('api/cards?ids=' + ids);
if (!full) continue;
for (const card of full) {
const idx = state.cards.findIndex(c => c.id === card.id);
if (idx >= 0) state.cards[idx] = card;
}
renderBoard();
}
}
// ── Help ──
const helpOverlay = document.getElementById('help');
function openHelp() { helpOverlay?.classList.add('open'); }
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(); });
document.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
});
// ── Export / Import ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; });
const importFile = document.getElementById('import-file');
document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click());
importFile?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
// Check if it's a selective export (merge) or ask for full replace
const form = new FormData(); form.append('file', file);
const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'Merge (add to existing)', cancelLabel: 'Replace (wipe all)' });
// mode: true = merge, null = replace
if (mode === null) {
const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'Replace' });
if (!sure) { e.target.value = ''; return; }
}
try {
const url = mode ? 'api/import?merge=1' : 'api/import';
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
} catch {}
e.target.value = '';
});
// ── Auth ──
document.getElementById('logout-btn')?.addEventListener('click', async () => {
const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'Logout', danger: true });
if (!ok) return;
await fetch('api/auth/logout', { method: 'POST' });
location.reload();
});
async function checkAuth() {
const res = await fetch('api/auth/status');
const data = await res.json();
if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); }
// Refresh version-displaying spans from the live API response so the
// GUI doesn't show a stale APP_VERSION when the PHP page is cached.
if (data.version) {
for (const el of document.querySelectorAll('[data-app-version]')) {
el.textContent = 'v' + data.version;
}
}
if (data.setup) {
let pw = null;
while (!pw || pw.length < 4) {
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
if (pw && pw.length < 4) pw = null;
}
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
const d = await r.json();
if (d.error) { location.reload(); return; }
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
} else if (!data.logged_in) {
let ok = false;
while (!ok) {
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
if (!pw) continue;
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
const d = await r.json();
if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
else { /* failed login: caller will retry */ }
}
}
loadBuckets();
loadCards();
startBackgroundSync();
}
// ── Global keyboard ──
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;
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();
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 "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;
}
// 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;
}
// 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);
}
});
// ── Quick Capture ──
document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => {
if (e.key === 'Escape') {
e.stopPropagation();
document.getElementById('quick-capture').classList.add('hidden');
e.target.value = '';
return;
}
if (e.key !== 'Enter') return;
const raw = e.target.value.trim();
if (!raw) return;
e.preventDefault();
// Split on first "|" — title before, body after.
// Also accept newlines as a fallback (first line = title, rest = body).
let cardTitle, body;
const pipeIdx = raw.indexOf('|');
if (pipeIdx >= 0) {
cardTitle = raw.slice(0, pipeIdx).trim();
body = raw.slice(pipeIdx + 1).trim();
} else {
const lines = raw.split(/\n/);
cardTitle = lines[0].trim();
body = lines.slice(1).join('\n').trim();
}
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 } });
e.target.value = '';
document.getElementById('quick-capture').classList.add('hidden');
await window._reloadAll();
// Toast
const n = document.createElement('div');
n.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);';
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-lightning"></i>captured: ${cardTitle.substring(0, 40)}</div>`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
});
// ── Tag quick-pick (bare "t" hotkey on the board) ──
/** Show a small popover listing every tag found on any card. Typing
* filters; arrow keys + enter pick; clicking outside or ESC closes.
* Picking a tag sets state.activeTags = [tag] and re-renders the board. */
function openTagQuickPick() {
const existing = document.getElementById('tag-quickpick');
if (existing) { existing.remove(); return; }
const tagSet = new Set();
for (const c of state.cards) {
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
}
const allTags = [...tagSet].sort();
if (!allTags.length) return;
const overlay = document.createElement('div');
overlay.id = 'tag-quickpick';
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
const box = document.createElement('div');
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;';
box.innerHTML = `
<div style="padding:10px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
<i class="ph ph-funnel text-t-accent"></i>
<input id="tag-quickpick-input" type="text" placeholder="filter tags…"
style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:6px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
</div>
<div id="tag-quickpick-list" style="flex:1;overflow-y:auto;padding:4px 0;"></div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const input = box.querySelector('#tag-quickpick-input');
const list = box.querySelector('#tag-quickpick-list');
let filtered = allTags;
let active = 0;
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function renderList() {
if (!filtered.length) {
list.innerHTML = '<div style="padding:14px 12px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no tags match</div>';
return;
}
list.innerHTML = filtered.map((t, i) => `
<button class="tag-quickpick-row" data-tag="${escHtml(t)}" data-idx="${i}"
style="display:block;width:100%;text-align:left;padding:6px 14px;font-size:11px;font-family:var(--t-font-pixel,'Silkscreen',cursive);color:${i === active ? 'var(--t-bright)' : 'var(--t-text)'};background:${i === active ? 'var(--t-raised)' : 'transparent'};border:none;cursor:pointer;">
#${escHtml(t)}
</button>
`).join('');
list.querySelectorAll('[data-tag]').forEach(btn => {
btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); });
btn.addEventListener('click', () => pick(btn.dataset.tag));
});
const activeEl = list.querySelector(`[data-idx="${active}"]`);
activeEl?.scrollIntoView({ block: 'nearest' });
}
function pick(tag) {
state.activeTags = [tag];
close();
window._renderTagBar?.();
window._renderBoard?.();
}
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey, true);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); }
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); }
}
input.addEventListener('input', () => {
const f = input.value.toLowerCase().replace(/^#/, '');
filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags;
active = 0;
renderList();
});
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
// Capture-phase keydown so ESC etc. don't bubble back to the rest of
// the page handlers (search, board) while the picker is open.
document.addEventListener('keydown', onKey, true);
renderList();
setTimeout(() => input.focus(), 0);
}
// ── Boot ──
checkAuth();
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──