From 22a43fdef70df561c7e45f6f3c6f6e7485d68504 Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 13:11:52 +0200 Subject: [PATCH] help: add relay, context menu, find/replace, assets, super-buckets docs; fix flaky tests Updates help screen with all undocumented features. Fixes F1 blocked by input suppression filter (allow function keys). Stabilizes card-join tests with poll loop instead of fixed sleep. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/index.php | 50 +++++++++++++++++++++++--- public/js/modules/shortcuts.js | 4 +-- public/plugins/tests/card-join.test.js | 26 +++++++++----- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/config.php b/config.php index 30a2378..8c456fb 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ concepts

cards are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.

-

buckets organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: all, current, favorites, journal, unsorted. Drag a card onto a bucket tab to move it. Drag to to create a new bucket.

+

buckets organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: all, current, favorites, journal, unsorted. Drag a card onto a bucket tab to move it. Drag to to create a new bucket. Buckets can be nested one level deep — drag a bucket onto another to create a super-bucket with a dropdown for its children.

groups let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use "dissolve group" to ungroup all at once.

tags are written as #tagname in the card body and extracted automatically. They support hierarchy: #project/frontend. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > tags. Each segment of a hierarchical tag is independently clickable.

links connect cards. Write [[Card Title]] in the body to create a link. Linked cards are clickable in preview and show up as backlinks on the target card. The split view shows related cards based on shared tags, links, and content overlap.

@@ -1376,12 +1376,30 @@ if (str_starts_with($route, '/api/')) { - + + + + +
click card open preview (or side panel in split mode)
double-click open editor
right-click context menu (color, bucket, tags, ...)
right-click context menu (color, bucket, tags, copy text, mark done, join, ...)
Ctrl+click multi-select cards
drag card reorder, move to bucket, or group
navigate cards with arrow keys, Enter to open
click tag filter by tag
Ctrl+click tag add/remove tag filter (AND)
group badge expand group
Ctrl+N quick capture — create card in active bucket
1–9 switch to bucket by number
+ + +
+

context menu

+ + + + + + + + + +
colorchange card color
movemove to a different bucket
tagsassign tags via picker
copy textcopy card body as plain text (strips markdown)
mark donetoggle #done tag on any card
join cardsmerge multi-selected cards into one
duplicateduplicate card
save .mddownload as markdown + images
deletedelete card (with undo)
@@ -1402,11 +1420,14 @@ if (str_starts_with($route, '/api/')) { + + - + +
Ctrl+S save
Ctrl+Shift+S save and close
Ctrl+H find + replace in card
Ctrl+F find in card
Alt+/move line up/down
Shift+Alt+/duplicate line
Tabindent (4 spaces)
paste / drag upload and insert image
paste / drag upload image, audio, or PDF
extractselection to new card
PDFupload + link a PDF from toolbar
drag dividerresize editor / preview split
@@ -1464,6 +1485,9 @@ if (str_starts_with($route, '/api/')) { #tag wordtag filter + text search combined #tag1 #tag2 multiple tags (AND) bucket filter dropdown to restrict scope + Replacetoggle replace mode — bulk find + replace across all matching cards + Ctrl+Shift+H open search directly in replace mode + drag result drag onto another card to group @@ -1506,12 +1530,30 @@ if (str_starts_with($route, '/api/')) {
-

views

+

views & toolbar

+ + + +
splitside-by-side board + preview panel
superviewcompact multi-column title list with hover cards
hide donetoggle cards tagged #done on/off
hide terminetoggle appointment cards on/off
hide taggedtoggle tagged cards on/off
sortmanual / newest / oldest / alpha / tag
assetsgallery of all images, audio, and PDFs across cards — with orphan detection
+
+ +
+

relay

+
+

The relay is an optional self-hosted server that connects multiple hyprcrd instances. Configure the relay URL and your client secret in Settings > Relay. The relay has its own admin UI for user management.

+
+ + + + + + +
collab-bucketsshared buckets between tenants — exclusive card locking, unread indicators, E2E encrypted sync
chatencrypted chat between tenants — footer flyout, delivery + read receipts
composesend messages to other tenants via the relay inbox
inboxread received messages — synced badge shows delivery status
card lockingediting a collab card locks it for all others — takeover on first keystroke
client secretper-tenant E2E key — set in relay account settings, shared across devices
diff --git a/public/js/modules/shortcuts.js b/public/js/modules/shortcuts.js index f0761d8..bcbff63 100644 --- a/public/js/modules/shortcuts.js +++ b/public/js/modules/shortcuts.js @@ -114,8 +114,8 @@ document.addEventListener('keydown', (e) => { if (!matched) continue; // Suppress non-modifier shortcuts when focus is in an editable field - // (except Escape which should always close overlays) - if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape') continue; + // (allow Escape and function keys like F1 through) + if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape' && !e.key.startsWith('F')) continue; // Guard if (s.guard && !s.guard()) continue; diff --git a/public/plugins/tests/card-join.test.js b/public/plugins/tests/card-join.test.js index 1a52c88..41b4755 100644 --- a/public/plugins/tests/card-join.test.js +++ b/public/plugins/tests/card-join.test.js @@ -20,16 +20,24 @@ function selectIds(ids, { bulk = false } = {}) { for (const id of ids) state.selectedIds.add(String(id)); } -async function clickJoin(rightClickTargetEl) { +async function clickJoin(rightClickTargetEl, winnerId) { rightClick(rightClickTargetEl); await sleep(150); const joinBtn = $$('#ctx-items [data-action="join"]')[0]; if (!joinBtn) return false; click(joinBtn); // joinSelectedCards does: fetch full bodies → PATCH winner → N × DELETE - // losers → _reloadAll. That's 3+ serial API calls and a full board - // re-render, so 400ms was sometimes too tight on slower runs. - await sleep(800); + // losers → _reloadAll. Poll for the winner to have merged body. + for (let i = 0; i < 30; i++) { + await sleep(100); + if (winnerId) { + const c = await getCard(winnerId); + if (c?.body?.includes('## ')) return true; + } else { + await sleep(600); + return true; + } + } return true; } @@ -109,7 +117,7 @@ describe('Card: Join — sequential selection (first wins)', () => { if (!firstEl) return; selectIds([first.id, second.id, third.id], { bulk: false }); - const ok = await clickJoin(firstEl); + const ok = await clickJoin(firstEl, first.id); if (!ok) return; // Winner survives @@ -132,7 +140,7 @@ describe('Card: Join — sequential selection (first wins)', () => { if (!aEl) return; selectIds([a.id, b.id, c.id], { bulk: false }); - const ok = await clickJoin(aEl); + const ok = await clickJoin(aEl, a.id); if (!ok) return; const survivor = await getCard(a.id); @@ -159,7 +167,7 @@ describe('Card: Join — sequential selection (first wins)', () => { if (!aEl) return; selectIds([a.id, b.id], { bulk: false }); - const ok = await clickJoin(aEl); + const ok = await clickJoin(aEl, a.id); if (!ok) return; expect(state.selectedIds.size).toBe(0); @@ -187,7 +195,7 @@ describe('Card: Join — bulk selection (longest title wins)', () => { // join code uses longest-title strategy. selectIds([short.id, longer.id, medium.id], { bulk: true }); - const ok = await clickJoin(shortEl); + const ok = await clickJoin(shortEl, longer.id); if (!ok) return; // The longest-titled card should be the survivor @@ -220,7 +228,7 @@ describe('Card: Join — undo', () => { if (!winnerEl) return; selectIds([winner.id, loser.id], { bulk: false }); - const ok = await clickJoin(winnerEl); + const ok = await clickJoin(winnerEl, winner.id); if (!ok) return; // Sanity: loser is gone, winner has merged body