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>
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>
Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.
Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins
The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.
Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.
Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.
Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
first activation; clearSelection clears it. New select-join click
handler dynamic-imports joinSelectedCards from context-menu.js to
keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
data-action="join" into the menu when the right-clicked card is in
a ≥2-card selection. Action handler dispatches to joinSelectedCards
before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
icon, sits between Group and Bucket.
Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
selection; hidden for solo selection; hidden when right-click target
isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.
undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
state.cards every 30ms until the in-memory state reflects the
expected change. Replaces fragile fixed `sleep(300)` waits between
chained user actions where the second action reads from state.cards
before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
state.cards to actually contain the pink color before doing the
second rightClick. Without this the second action's contextmenu
handler reads the stale card and pushes the wrong undo snapshot,
collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
for state.cards to reflect it before opening the editor — the
editor's snapshot for the undo entry reads from state.cards.find()
and would otherwise capture due_date=null.
chore: bump version to 0.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Super-buckets: a bucket can act as a dropdown of nested children, 1-level
deep, created by dragging one bucket onto the center of another. The
parent shows up as a single tile in the bar with a stack icon and the
active member's name; clicking opens a popover that lists parent + all
children. Selecting an entry sets state.activeBucket. Implementation:
* src/Database.php — ALTER TABLE buckets ADD COLUMN parent_id + index.
* public/index.php — PATCH /api/buckets accepts parent_id with depth /
cycle / collab guards. DELETE on a parent un-nests its children
rather than cascade-deleting. The /api/import handler does a second
pass to remap parent_id through the new bucket id map so backup +
restore round-trips the hierarchy.
* public/js/modules/buckets.js renderBar() — top-level buckets only;
buckets with ≥1 child render as a `.bucket-super` tile with
data-bucket-super=<parent.id> and data-super-shown=<active member>.
Three drop zones per tile: left/right 25% → reorder via insert
cursor, center 50% → nest (PATCH parent_id) with .bucket-nest-target
outline. canNestInto() mirrors the backend rules client-side.
openSuperDropdown() renders a popover under the tile with parent +
children as real .bucket-tab[data-bucket=…] entries (so the collab
plugin's MutationObserver still picks them up); appended inside
#bucket-bar but `position:fixed` so the bar's overflow:auto can't
clip it. showBucketCtx() now scopes move-left/right to siblings
(top-level peers OR same parent_id) so the action works in both
the bar and the dropdown.
Drag-and-drop insertion cursor for cards (matches the bucket fix):
* public/js/modules/board.js bindCardEvents() — single
#card-drop-cursor element inside #board, repositioned by per-card
dragover. Three zones (left/right 25% → cursor in gap, center 50% →
card-drop-group highlight). dragleave does NOT hide the cursor (was
flickering as the mouse moved between adjacent cards); only dragend
clears it. handleCardDrop recomputes the drop zone from e.clientX
since the cursor approach no longer tags the target with
.card-drop-before / .card-drop-after.
* "Vor der ersten Card" works automatically: the first card's left
25% positions the cursor at offsetLeft - 6, which sits inside
#board's px-6 padding and stays visible.
* public/css/style.css — old .card-drop-before::before /
.card-drop-after::after pseudo-elements removed in favour of the
#card-drop-cursor rule.
Drag ghost survives background polls:
* board.js draggingCardIds: Set<string> at module scope. dragstart
fills it; dragend clears. renderBoard() re-applies the
.card-dragging class to every member of the set after every render
so caldav / collab polls calling _reloadAll mid-drag don't
visually pop the source card back to its normal state.
Tests (public/plugins/tests/super-buckets.test.js):
* API parent_id: nest, un-nest, self-parent rejected, level-2
rejected (bucket with own children, target is itself a child),
DELETE un-nests, export → import preserves parent_id.
* Bar rendering: super-tile DOM markers, label = parent name when
no child active, label = active child name when selected, popover
opens with parent + children entries, click child sets activeBucket,
click super-tile twice toggles popover closed, plain bucket stays
plain.
* Drop cursor element exists.
* Both describe blocks reset state.activeBucket = 'all' in afterEach
— the rendering tests set it to a child id and the leak was breaking
downstream Card: Due Date & Done / Card: Grouping tests by filtering
every newly-created card with bucket_id=null off the board.
chore: bump version to 0.9.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* version: every span that shows v<APP_VERSION> in the page chrome now
carries data-app-version, and checkAuth() patches them in from the
/api/auth/status response so the GUI version doesn't go stale when
PHP is cached behind anything that revalidates lazily.
* undo: tailwind v4 compiles `-translate-x-1/2` to the *new* CSS
`translate` property, which then stacks with the inline `transform`
the toast set — the box ended up shifted ~ -100% and looked left-
anchored. Drop the tailwind utility, set `translate` directly inline
so there's one source of truth for the offset.
* editor: ctrl/cmd+enter while the editor modal is open now invokes
saveCard() (= save + close), pairing with the existing ctrl+s
(save only) and ctrl+shift+s (save+close) shortcuts.
* collab buckets: drop the inset left edge from .bucket-tab-collab and
the matching pulse-keyframe shadow — the bar tab now reads as a
normal bucket tab, just with the unread dot + 3-cycle pulse on update.
* collab create dialog: drop the box border / background from
.collab-toggle-row and .collab-members so the toggle and the user
list sit directly on the modal background instead of looking like
nested input boxes.
* test progress strip: ensureProgressBar() now also pushes the body
down by 30px AND shrinks it via height: calc(100vh - 30px) so the
fixed-top bar doesn't clip the footer (body is h-screen +
overflow-hidden in the core layout). hideProgress() clears both
inline styles when the run finishes.
chore: bump version to 0.9.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.
Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
color, which caused the test runner's auto-restore to wipe custom
bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
collab_cards, collab_card_locks tables and prunes tombstones / expired
locks on every request
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the cursor-blink class to "no X" / "select a Y" placeholders across
core (calendar popup, board preview, search preview, detail split, notifs)
and plugins (chat, email, gitea, slideshow). Visual consistency with the
rest of the pixel-font UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix card drag reorder in favorites/journal/current (filter logic
now mirrors renderBoard)
- Split view shows tag-matched cards as own group alongside related
- Remove extract/tree-view buttons from detail preview (edit-only)
- Context menu tag input: Enter assigns tag directly
- Tag autocomplete closes on ESC/click-outside globally
- Close modal cleans up both autocomplete popups
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>