Commit Graph
70 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 d925070a36 fix(tests): shopping proxy-check test tolerates session-auth 401
/api/shopping/check is session-auth on the relay (phone-only), so
hitting it via the client proxy with client-auth credentials returns
{error: "unauthorized"} instead of a response with an ok field. The
test was over-specific — relax to either `ok:boolean` (relay not
configured, proxy's own fallback) or `error:string` (relay rejected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:43:14 +02:00
thandClaude Opus 4.6 e086db0c2c shopping: #shopping tag, help section, tests, client-side proxy
Three follow-ups:

  - Accept #shopping as an alias for #einkauf. Either tag now makes
    a card show up in the picker; the help blurb in the dialog says
    so explicitly.
  - Add a proper help-overlay section covering the trigger tags,
    how to pick an active list, the phone URL, sync cadence, and
    why phone is check-only.
  - Add a client-side proxy in public/index.php for /api/shopping
    and /api/shopping/check — shopping.js was calling api('api/...')
    which went to hyprcrd, but only the relay had handlers. Without
    this forwarder, push/pull just 404'd. Proxy mirrors the pattern
    used by /api/relay/push.
  - New shopping.test.js: asserts the proxy returns a JSON shape
    with `ok` (works whether or not a relay is configured), the
    hamburger menu toggles as expected, and the picker lists both
    #einkauf + #shopping cards while excluding untagged ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:35:52 +02:00
thandClaude Opus 4.6 b08bdbaeb2 tests: assert new appointments don't inherit the active bucket
Opens the editor twice against a real bucket as activeBucket: once
with a #termin-seeded body (should stay empty in inp-bucket) and
once with a plain body (should pick up the active bucket id).
Skipped on fresh DBs that have no buckets to inherit from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:10 +02:00
thandClaude Opus 4.6 d5ebbab6a5 tests: assert journal entries don't get the local pulse
Covers the board.js journal exemption on the 1× card-pulse-local
flash. Creates two probe cards via the raw API, flags both as
"just created locally" in state.recentlyCreatedIds, runs a render,
then asserts that the journal-format one (title 9999-12-31 — a
year we can't collide with real entries) skipped the pulse class
while the __prefixed__ control card still got it.

Both probes are deleted in a finally block so a failed assertion
doesn't leave them behind for later suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:53:49 +02:00
thandClaude Opus 4.6 64be9257f2 fix(tests): chat thread-settings test is idempotent
The row the test writes survives the backup/restore cycle (plugin
tables aren't in the export), so on the second run the GET returned
1 and the "starts at 0" assertion blew up. Explicitly reset the
flag at the start, then flip, then reset again — each run lands
the DB in the same state it started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:37:35 +02:00
thandClaude Opus 4.6 0022f64c50 chat: merge caret + title into one back trigger
Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:22 +02:00
thandClaude Opus 4.6 d2f7b98e6c chat: clicking the title goes back to the conversation list
Same path as the back-caret — a bigger, easier target than the
icon. No-op when already on the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:30:14 +02:00
thandClaude Opus 4.6 8020ddc787 tests: purge editor drafts after a run
Tests that opened the card modal left hyprcrd-editor-draft:* entries
in localStorage. The pre-run purge cleared them before the baseline
backup, but anything the tests wrote during the run stayed behind —
the user's next card open then fired "unsaved draft from 0 min ago,
restore?" for a non-edit they didn't make. Repeat the purge after
restore so the runner closes with a clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:28:29 +02:00
thandClaude Opus 4.6 87ce038b19 new-bucket dialog: Enter in name field submits
Typing a name and hitting Enter did nothing — users had to move to
the Create button. Wire Enter on the name input to click the submit
path so the common flow is keyboard-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:57 +02:00
thandClaude Opus 4.6 dce8fa4211 fix(tests): chat thread-settings test uses runner csrf helper
Was pulling CSRF from a meta tag that doesn't exist; the POST landed
without a valid token and the server dropped it before the plugin
handler could reply. Use the imported csrf() helper like the rest of
the suite does.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:04:02 +02:00
thandClaude Opus 4.6 8cd6015cad fix(chat): make group-chat author labels readable
Author name above group bubbles was 8px dim-grey pixel font — visible
in theory, invisible in practice. Bump to 10px semibold accent,
left-aligned above the incoming bubble it labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:00:03 +02:00
thandClaude Opus 4.6 6aa555cc6a fix(chat): group-message FK violation + receipt-cursor stall
Group inserts were using recipient_id=0 as a "no recipient" sentinel,
which fails the tenants FK (foreign_keys is ON) and 500s on every
group send. Use the sender's own id as the placeholder instead;
group GET queries already key off room_uid so there's no self-message
artefact from the value.

Also exclude group rows from /api/chat/sent so the client's
last_sent_relay_id cursor doesn't stall on group sends that never
reach seen=1 (group receipts aren't tracked in v1).

Client-side: surface a clearer error when a room create hits a 404
on the relay — almost always means the relay is running pre-group
code and needs a restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:57:18 +02:00
thandClaude Opus 4.6 0fd97e5d71 tests + version bump: group chat surface
Extends chat.test.js to cover the new conversation shape (kind/key
fields), the /api/chat/rooms endpoint, the thread-settings
round-trip, and the group-creation pick-view toggle. Room-info view
DOM is structurally checked. Bumps to 1.9.4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:55 +02:00
thandClaude Opus 4.6 9267c7c6a1 chat: group chat UI — room tabs, creation flow, member mgmt
Generalises the flyout from peer-only to a thread abstraction
({kind: 'peer'|'room', key, label}). Conversations list and peer
strip render rooms with a group icon alongside 1:1 peers; clicking
either opens the same thread view.

New UI surface:
  - Pick view gets a 1:1 / group mode switch. Group mode reveals a
    name field + multi-select member list and a Create button.
  - Room threads show an info icon in the flyout header that opens a
    member list; owners can add/remove members, rename-later, or
    delete the room; non-owners can leave.
  - 1:1 threads show a disposable toggle (ph-fire) in the header;
    flipping it tells the relay to drop future sends after delivery
    instead of persisting.
  - Group bubbles show the author's username above each message when
    it changes, matching common chat UX.

Thread open triggers a backfill fetch so cold caches / second
devices catch up on older history from the relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:50 +02:00
thandClaude Opus 4.6 52da9abd44 chat: client-side rooms, disposable flag, history backfill
Extends the local chat cache to mirror rooms + membership from the
relay. chat_messages grows a room_uid column; peer is reused as
author-username for group rows so a single table covers both kinds.

New local tables:
  chat_rooms            — uid, name, owner, synced_at
  chat_room_members     — room_uid + username
  chat_thread_settings  — per-peer disposable flag

New / extended endpoints:
  GET/POST/PATCH/DELETE /api/chat/rooms[/{uid}] — proxy to relay
  POST /api/chat/backfill — pulls older messages from relay
  GET/POST /api/chat/thread-settings — disposable toggle
  /api/chat/conversations — mixed list of peers + rooms
  /api/chat/messages — accepts peer OR room_uid
  /api/chat/send — accepts peer OR room_uid; forwards disposable
  /api/chat/poll — mirrors rooms on every tick; sets direction
                   from `mine` flag so multi-device group sends
                   render as out-bubbles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:38 +02:00
thandClaude Opus 4.6 618a787b36 tests: UI + API smoke tests for chat plugin
Covers the flyout lifecycle (footer button, toggle, header-back
visibility tracking thread/list view), the pick-view detour, DOM
structural invariants (peer strip inside thread view, form/input/send
present) and the three read-only chat endpoints. Send/receive requires
a real relay and is intentionally skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:43 +02:00
thandClaude Opus 4.6 81d9ce0e87 chat: peer-switch strip, back button moved to flyout header
Switching conversations used to require bouncing back to the list view
and picking from there. Now the thread view has a horizontal peer
strip at the top: one click hops to another conversation, active tab
is highlighted and auto-scrolls into view, unread peers show a dot.
Scrollbar is hidden — vertical wheel delta is redirected to horizontal
scroll so the user can slide the strip with the mouse wheel when it
overflows.

In-thread header is gone; the back button moved next to "chat" in the
flyout header and only shows while a thread is open. Peer name stays
visible via the active tab.

Bumps to 1.9.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:38 +02:00
thandClaude Opus 4.6 1a889ea435 tests: snapshot and reset view state around runs
Tests assume a normal board — no super-view, no bucket filter, manual
sort, no hides. When the runner fired with the app left in super-view
or with filters on, selectors like `.card[data-id]` missed the
`.superview-item` DOM and multi-select / due-date assertions read the
wrong initial values.

Snapshot the relevant bits of window.state before the run, force a
clean baseline, then restore the user's view afterwards so opening the
runner doesn't silently wipe their context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:58 +02:00
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +02:00
thandClaude Opus 4.6 b7f450e41f autosync toggle, configurable sync interval, 30-day session
- header Sync button now has a paired autosync toggle to its right —
  reads as one button with no inner seam (outer border only). Dot inside
  pulses once per actual sync (bursts on every backgroundSync /
  triggerSync / collab pollOnce via a new sync.pulse hook) so the user
  can see polls happen. Dot + data-autosync attribute persist the
  enabled state in localStorage; paused state stops both the
  CalDAV/relay background timer and the collab poll via a broadcast
  autosync.changed hook.
- hover polish: Sync regains a full accent border (the missing inner
  edge re-appears only on its hover); the toggle fills with solid
  accent as hover treatment so the pair never shows a half-outline.
- sync interval moved out of hardcode into a settings field under
  Settings → Relay: number input, 5s..3600s, default 15s (was 45s on
  caldav / 30s on collab). Changes broadcast via sync.interval.changed
  and restart both timers without reload.
- session cookie lifetime raised to 30 days with matching gc_maxlifetime
  and SameSite=Lax (was 0 / Strict — some users got kicked to login on
  every reload when gc cleaned up the /tmp/sess file or a cross-link
  return blocked the cookie).
- search button's kbd element was pushing the button ~3px taller than
  Sync; leading-none + tighter padding puts them on the same height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:57:24 +02:00
thandClaude Opus 4.6 6cd00f9660 diag: error_log sync-event POSTs to trace missing toasts
Temporary diagnostic scaffolding — B-side logs every POST to the relay
(status + body snippet), A-side already console.debugs the GET result
via sync_debug. Lets us narrow down where the sender-side toast gets
lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:24:35 +02:00
thandClaude Opus 4.6 1966f8f4c0 sync-event toasts, image pull ordering, stale banner field diff
- replace chat-bot sync notifications with a dedicated relay_sync_events
  queue: POST/GET /api/sync-events, drained by /api/relay/poll and
  surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed,
  bot=1 rows purged on bootstrap, orange bot rendering dropped from
  relay UI. Chat thread stays clean.
- collab pull: fetch attachments BEFORE applying the body so the moment
  state.cards reflects the new reference the file is on disk. Fixes the
  "preview shows broken/missing image, editor shows it" race where the
  browser cached the 404 between attachment landing and user re-render.
- detail stale banner now compares title/body/color/tags in addition to
  updated_at — robust against second-granularity timestamp collisions
  and any other case where updated_at doesn't tick but content differs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:00:30 +02:00
thandClaude Opus 4.6 26f95ba079 collab per-card unread, detail stale banner, image sync race fix
- per-card unread dot on board (collab-buckets): backend tracks
  last_seen_at per card, poll exposes unread_cards, frontend decorates
  matching board cards; cleared on any open path via detail.opened hook
- detail.opened + cards.reloaded hooks on the hyprcrd event bus
- stale banner in detail view with manual Reload when the currently
  viewed card got updated by a peer — no content swap under the user
- collab image sync: push attachments BEFORE card (race fix) + pull-side
  self-heal scans every collab card for missing uploads each poll
- relay user deletion now purges chat_messages sent by deleted user so
  ghost threads stop appearing for their peers
- calendar header nav buttons styled as square bordered buttons that
  honor --ui-radius-button; title bar widened to 280px so font variants
  don't wrap
- .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:39:32 +02:00
thandClaude Opus 4.6 39e94b8015 oumta dashboard plugin (mockup)
Fullscreen cluster dashboard view consuming the Oumta Go backend.
All data currently mocked — see plugin TODO.md for integration plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:24:07 +02:00
thandClaude Opus 4.6 69ad2c7e71 fixes: password form wrap, collab-buckets csrf race
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:23:48 +02:00
thandClaude Opus 4.6 3b7c7cf5ac tim plugin, chat bot sync notifications, test fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:32:17 +02:00
thandClaude Opus 4.6 ca47052768 collab attachments, multi-day events, calendar improvements
- Collab buckets sync attachments alongside cards (images/PDFs/audio).
  New /api/collab-attachments endpoint on relay, encrypted at rest,
  bucket-scoped for members.
- CalDAV multi-day events via **Bis:** body field. Calendar renders
  event on every day in range with day counter (1/6, 2/6, ...).
- Calendar preserves month across close/reopen (only jumps to today
  on first open).
- Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] —
  dimmed brackets, fixed width so nothing wiggles.
- Editor: scratchpad uses custom confirm, autobackup-draft dialog
  replaced with custom styled dialog.
- Scanlines theme feature removed (performance issue).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:22:02 +02:00
thandClaude Opus 4.6 3cca14a3c6 fixes: collab move, caldav abort, draft recovery, autobackup, test stabilization
- Collab: moving a shared card to a local bucket no longer deletes it
  (clear relay_uid before tombstone echo returns)
- CalDAV: abort sync on remote fetch failure instead of treating all
  local cards as remotely-deleted
- Editor: autobackup drafts to localStorage with restore prompt on
  re-open (skipped during tests)
- Cards POST: persist scratchpad and due_date (was dropped on create)
- Disable spellcheck/autocorrect on editor inputs
- Test runner: purge leftover __prefix__ cards/buckets + editor drafts
  before every run (cleans up from failed previous runs)
- Stabilize mark-done, group-delete, undo-body tests with poll loops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:01:07 +02:00
thandClaude Opus 4.6 7e2b130434 fixes: scratchpad, zen ESC, chat time, tooltip safety, collab sync race
- Scratchpad: card-scoped private notes in editor (toggle vs preview),
  smaller font, cleared via button, stored in cards.scratchpad
- Zen mode: ESC exits zen before closing detail, Z toggles zen
- Chat: always show absolute time (hh:mm, yesterday, or date+time)
- Backspace/Delete also deletes focused card (not just selected)
- Selected + focused cards use brand accent (not accent2)
- Tooltip: guard against non-element targets
- Collab sync: skip poll while editor is open, 2s push debounce
- Rrule test: only check geburtstag tag, not title format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:45:31 +02:00
thandClaude Opus 4.6 2169c1de1b terminal plugin, card edit history, bump script
Terminal: in-app command line with 22 commands (Shift+T toggle).
Navigation, CRUD, search, view toggles, tab completion, history.

Card history: card_history table tracks field changes on every PATCH.
GET /api/cards/{id}/history endpoint. Terminal `history` command.

bump.sh: standalone version bump for config.php, electron/package.json,
website footer. dist.sh no longer bumps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:10:43 +02:00
thandClaude Opus 4.6 9f85e484c4 example-content plugin, project website with screenshot
Example-content: footer button creates 20 realistic demo cards in a
"Project Alpha" bucket for screenshots and first-time exploration.

Website: full-page sections with large watermark labels, screenshot
hero with typing animation, email/spam section, Gitea detail,
relay encryption flow, keyboard keycaps, deploy options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:54:43 +02:00
thandClaude Opus 4.6 b9345a410f watch plugin: analog clock overlay with SVG watchface
Simple watchface with second-hand wobble animation, 5-min idle
auto-show, W key toggle, light/dark theme colors, backdrop blur
from theme settings. Grace period prevents instant dismiss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:16:25 +02:00
thandClaude Opus 4.6 f465306c1d global tooltip system, migrate all title attrs to data-tip/data-help
New tooltip.js module with styled tooltips (data-tip) and multi-line
inline help (data-help). Migrates all ~70 native title attributes
across index.php, JS modules, and all plugins. Adds inline help on
9 strategic UI elements (split, superview, zen, extract, assets, etc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:55:17 +02:00
thandClaude Opus 4.6 527305355c super-buckets: split tab click (activate) from caret click (dropdown)
Clicking the super-bucket tab now activates the last-shown child
directly. Only the caret arrow on the right opens the dropdown popover.
Caret has its own hit area with accent hover highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:37:38 +02:00
thandClaude Opus 4.6 04b52ecc19 gitea: default repo setting; editor: asset picker with thumbnail grid
Gitea plugin: default_repo field per account (stored in DB, pre-fills
create issue form but stays editable). Editor toolbar: asset picker
flyout with 4-column thumbnail grid and live search filter. Clicking
a tile inserts the matching markdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:26:04 +02:00
thandClaude Opus 4.6 d3d9355191 electron: external data dir, packaged path fix, footer responsive, test stabilization
- DB + uploads stored outside app bundle (survives updates)
- Router uses DOCUMENT_ROOT instead of __DIR__ (fixes AppImage paths)
- Footer count shifts right below 1250px, min-width 1150px
- Gallery opens gracefully on fetch errors
- Stabilized gallery, export, and find/replace tests with poll loops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:24:00 +02:00
th ee192a15e7 zwischenparkung 2026-04-12 16:09:34 +02:00
thandClaude Opus 4.6 22a43fdef7 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) <noreply@anthropic.com>
2026-04-12 13:11:52 +02:00
thandClaude Opus 4.6 d1b26e625a search: integrate find/replace into search overlay, add toolbar buttons
Moves board-wide find/replace from standalone overlay into the search
panel as a toggleable replace row. Adds editor toolbar find/replace
button. Ctrl+Shift+H now opens search in replace mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:53:11 +02:00
thandClaude Opus 4.6 3078906548 find/replace: editor (Ctrl+H/F) + board-wide (Ctrl+Shift+H)
Editor find/replace (public/js/modules/editor.js, public/index.php):
* New #editor-find-bar inside #editor-pane (above the textarea, pushes
  it down via flex-col). Two rows: find input + match count + prev/next
  + close, and replace input + replace-one + replace-all.
* Ctrl+H opens find+replace, Ctrl+F opens find-only (replace row
  hidden). Same shortcut toggles off if already open in the same mode.
* runFind(): case-insensitive indexOf scan, all match positions stored.
  jumpTo() sets textarea.setSelectionRange for the Nth match.
  findNext/findPrev cycle with wrap-around.
* replaceOne(): body.setRangeText at the current match position, re-scan.
  replaceAll(): regex-based global replace on body.value.
* Enter in find input = next (Shift+Enter = prev). Enter in replace
  input = replace one. Escape closes the bar + refocuses textarea.
* closeModal() calls closeFindBar() to clean up on editor close.

Board-wide find/replace (public/js/app.js):
* Ctrl+Shift+H opens a centered overlay that fetches all cards with
  full bodies (api('api/cards'), bypasses lazy-load).
* Live search: typing in the find input scans all card bodies case-
  insensitive. Matches shown as a scrollable list with color dot,
  card title, occurrence count, and a context snippet with <mark>
  highlight around the first match.
* Status line: "12 occurrences in 5 cards" or "no matches".
* Replace All button (danger-styled, disabled until both inputs have
  text and matches exist). Click opens a showDialog confirmation:
  "Replace 12 occurrences of 'foo' with 'bar' across 5 cards?"
  On confirm: regex replace per card body → PATCH → _reloadAll.
* Escape / click-outside / Close button dismiss the overlay.

Shortcuts registered in the central registry for the help screen:
* ⌃ H (editor), ⌃ F (editor), ⌃ ⇧ H (global board-wide).

Tests (public/plugins/tests/board-find-replace.test.js):
* Ctrl+Shift+H opens the overlay (polls for #board-find-replace).
* Typing in find shows matching cards (creates 3 cards, 2 match,
  asserts status count + list contains matching titles + excludes
  non-matching title).
* Replace all updates card bodies after confirmation (creates 2
  cards with "hello", replaces with "goodbye", confirms dialog,
  verifies via API that bodies are "goodbye world goodbye" and
  "goodbye there").
* Replace all button disabled without replace text.

detail.test.js: "closes on ESC" stabilized — guard that detail is
actually open before dispatching Escape, poll (20×50ms) instead of
fixed sleep(150) for the .open class to be removed.

chore: bump version to 0.9.21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:23:18 +02:00
thandClaude Opus 4.6 3eb5202aac fix: ESC doesn't kill panel mid-run, remove stale #inbox test
runner.js: the Escape handler now checks panel.style.display !== 'none'
before closing. During the test run the panel is hidden (display:none)
but the variable is truthy — synthetic Escape events dispatched by
tests (e.g. "closes on ESC") were triggering close() mid-run, removing
the panel entirely so results never showed.

quick-capture.test.js: the "Enter creates card with #inbox tag" test
was asserting found.tags contains 'inbox', but the #inbox auto-tag
was removed in 0.9.17. Renamed to "Enter creates card in active
bucket" and dropped the stale tags assertion.

chore: bump version to 0.9.20

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:10:56 +02:00
thandClaude Opus 4.6 088c225202 runner: bump RUNNER_V ?v=3 → ?v=4 so test-shield removal loads
The test-shield (#test-shield) was added in loader.js (always fresh
via plugin manager ?v=time()) but the removal lives in runner.js
hideProgress(). runner.js was still cached at ?v=3 in the browser,
so the shield stayed after the run. Bumping to ?v=4 in loader.js +
all 42 test files forces a fresh runner.js load.

chore: bump version to 0.9.15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:04:23 +02:00
thandClaude Opus 4.6 88108ecc1b focus: restore body focus on overlay close, test shield, ESC closes results
Focus restoration:
* editor.js closeModal(): blur activeElement after the close so board
  shortcuts (arrows, bare keys) work immediately without clicking.
* detail.js closeDetail(): same blur, skipped when openedFromSearch
  (search input should keep focus in that case).
* search.js closeSearch(): blur after removing .open.
* calendar.js closeCalendar(): blur after closeDayPopup.
All four check activeElement !== body before blurring to avoid no-ops.

Test shield (loader.js):
* ensureProgressBar() now also creates #test-shield — a transparent
  fixed overlay (z-index one below the progress bar) covering the
  viewport below the bar. Blocks all real mouse interaction while
  tests run; synthetic events from the test suite still work because
  they bypass pointer-events. cursor:not-allowed as visual feedback.
  Removed by hideProgress() alongside the progress bar.

ESC closes test results (runner.js):
* The existing keydown listener now also handles Escape when the
  test-panel is visible. stopPropagation prevents the app's Escape
  chain from firing a second close underneath.

Tests (board.test.js):
* New describe "Board: Focus restoration after overlay close":
  - closing the editor returns focus to body
  - closing the detail view returns focus to body
  - closing search returns focus to body
  - arrow keys work after editor round-trip (enter → enter → escape)
    — the exact flow the user reported as broken. Dispatches
    ArrowDown → Enter (detail) → Enter (editor) → Escape (close) →
    asserts body focus → ArrowDown → asserts .card-focused exists.
* Fixed .not.toBe() → (x !== y).toBe(true) since the custom runner
  doesn't have a .not chain.

chore: bump version to 0.9.14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:02:27 +02:00
thandClaude Opus 4.6 b459159b0f 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>
2026-04-12 10:50:49 +02:00
thandClaude Opus 4.6 7c113fd5ab shortcuts: board arrow-nav, enter→preview→edit, help screen polish, n/a/f/j hotkeys
Board arrow-key card navigation (public/js/app.js):
* ↑/↓/←/→ on the board (body focus, no overlay) move a single-card
  focus cursor (.card-focused, accent2 outline) through the visible
  card grid. Column count is computed live from the first row's
  offsetTop so it tracks the CSS grid columns at any viewport width.
* First arrow press focuses the first card; subsequent presses move
  in the grid. Focused card scrolls into view (smooth, nearest).
* Enter on a focused card → openDetail (preview). Focus cleared.
* Enter in detail view → closeDetail + openModal (editor). Card ref
  is captured before closeDetail nulls state.viewingCard.
* Click anywhere → focus cleared. Re-renders restore focus via
  window._restoreCardFocus() called from board.js renderBoard().

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

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

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

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

chore: bump version to 0.9.12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:32:31 +02:00
thandClaude Opus 4.6 c8835825e7 test fixes: dialog selector, closeAll scope, copy filter, runner ?v=3
* card-interactions.test.js: group delete tests used `.dialog-ok`
  (class selector) but the button has `id="dialog-ok"`. querySelector
  returned null, the click was skipped, the dialog stayed open and
  blocked every downstream test. Fixed to getElementById.
* runner.js closeAll(): added #dialog, #bucket-super-popover (remove),
  and #gitea-overlay to the cleanup list. A failed test that leaves any
  of these open no longer poisons the rest of the suite.
* runner.js copy handler: when there are failures or flaky tests, the
  "copy" button now builds the clipboard text from lastResults —
  only failed + flaky lines (suite name, test name, timing, error,
  original error for retried). When everything passed, copies the
  full output as before. lastResults is set in renderResults().
* bucket-context.test.js: move-left test used `[data-bucket]` to find
  user bucket tabs, which misses super-bucket tiles (they carry
  `data-bucket-super`). Changed to `[data-bucket-drag]` which is on
  both regular and super-bucket tiles but not on fixed filters.
* context-menu.test.js: make-ticket pre-fill assertion now polls up
  to 2s for the title input to be populated (async openCreateForm
  repo-fetch was racing against the immediate assertion).
* card-join.test.js: clickJoin sleep bumped from 400ms → 800ms.
  joinSelectedCards does 3+ serial API calls (fetch bodies, PATCH
  winner, N×DELETE losers, _reloadAll) which sometimes exceeded the
  old 400ms window on slow runs.
* Runner cache: RUNNER_V bumped from ?v=2 → ?v=3 in loader.js + all
  42 test files so the browser picks up the new runner.js content
  (copy handler, closeAll, retry progress bar) without a hard reload.

super-buckets: remember last selection per group

* buckets.js: module-scope lastSuperSelection Map tracks the last
  active member per super-bucket parent. Updated in renderBar()
  whenever activeBucket matches a group member. tileFor() uses it
  as a fallback when the user is on a different bucket — the tile
  shows the last-picked child's name and color instead of reverting
  to the parent. The dropdown popover also highlights the last
  selection when no member is currently active.

chore: bump version to 0.9.11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:05:38 +02:00
thandClaude Opus 4.6 56c90225cb group delete with confirmation + undo, retry progress bar fix
context-menu: group delete (public/js/modules/context-menu.js):
* When the delete action targets a card that has children (a group
  parent), the confirmation dialog is ALWAYS shown regardless of the
  hyprcrd-confirm-delete setting — group deletion is destructive
  enough that it should never happen accidentally. The dialog lists
  every child card by title and the OK button reads
  "Delete all N" (parent + children counted).
* Children are deleted first (avoids orphan flicker), then the parent.
* Undo: a single pushUndo entry recreates the parent via POST first,
  then all children with parent_id pointing at the new parent id.
  due_date is restored via a follow-up PATCH since POST doesn't
  accept it. Ctrl+Z fully restores the group hierarchy.
* Non-group cards keep the existing behavior (simple confirm if the
  setting is on, single delete, pushCardDelete for undo).

tests (public/plugins/tests/card-interactions.test.js):
* New describe "Card: Group Delete" with two tests:
  - "deleting a group removes parent AND all children": creates a
    parent + 2 children, right-clicks the parent, clicks delete,
    asserts the dialog text contains both child titles, confirms,
    verifies all 3 cards are gone from the API.
  - "Ctrl+Z after group delete restores parent + children": creates
    parent + 1 child, deletes the group, confirms both gone, fires
    Ctrl+Z, finds the restored cards by title, asserts
    child.parent_id === restoredParent.id.
  Both tests save/restore the hyprcrd-confirm-delete localStorage
  value and soft-skip if the parent isn't visible on the board.

runner: retry progress bar (public/plugins/tests/runner.js):
* hideProgress() was called inside runAll() at the end of the first
  pass, which removed the #test-progress element before the retry
  pass started. The retry ran invisibly — "retrying…" never showed.
  Moved hideProgress() out of runAll() into run(), after both the
  first pass and the retry pass are done. The bar now stays visible
  for the whole sequence: first pass fills to 100%, retry resets the
  fill to 0 (via updateProgress(0, total, 'retrying…', false)) and
  fills again for just the failed tests.

chore: bump version to 0.9.10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:34:31 +02:00
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
thandClaude Opus 4.6 285e3b4abe chat: read receipts (delivered + seen) and persisted active bucket
Read receipts on chat messages — single check for delivered (recipient
polled), double check for seen (recipient opened the thread). Pending
state shows a clock until the relay acks the post.

Relay (relay/):
* bootstrap.php: ALTER TABLE chat_messages ADD COLUMN seen + a
  (sender_id, id) index for the sender-side state poll. Migration is
  idempotent via PRAGMA table_info.
* routes/chat.php: two new routes
  - GET  /api/chat/sent?since=N — returns the receipt state (id,
    recipient_id, fetched, seen, created_at) for messages the caller
    sent. No body — sender already has the plaintext locally.
  - POST /api/chat/seen { peer } — marks every message FROM peer TO
    caller as seen=1. Idempotent. Frontend calls this when opening
    a thread; sender's next /sent poll picks the new state up.

Plugin backend (public/plugins/chat/backend.php):
* schema migration: chat_messages gets `delivered` and `seen` columns
  (idempotent), only meaningful for direction='out'.
* /api/chat/send: stores the relay's autoincrement id as `relay_id`
  on the local outgoing row so the receipt poll can match against it.
* /api/chat/poll: after the normal incoming pull, also calls
  GET /api/chat/sent?since=last_sent_relay_id and updates local
  outgoing rows with delivered/seen via UPDATE WHERE relay_id. The
  cursor (last_sent_relay_id state value) only advances past rows
  whose state is final (seen=1); rows still in flight stay re-pollable.
  Response now carries `receipts: <count>` so the frontend can refresh
  the open thread when state changes without new incoming.
* /api/chat/read: after the local is_read=1 update, fires a POST to
  the relay's /api/chat/seen so the sender gets the double-check.
  Silent on failure — the user already saw the messages locally.
* /api/chat/messages: SELECT now includes delivered, seen.

Plugin frontend (public/plugins/chat/chat.js):
* renderThread() appends a receipt icon to outgoing bubbles:
  - seen=1                      → ph-checks (accent, double check)
  - delivered=1, seen=0         → ph-check  (muted, single check)
  - both 0                       → ph-clock  (dim, pending)
  Incoming messages get nothing.
* pollOnce() refreshes the open thread when receipts > 0 even with
  no new incoming, so the icons update in real time while the user
  is looking at the thread.

CSS (public/plugins/chat/chat.css):
* .chat-bubble-time becomes flex with gap so timestamp + receipt
  icon sit on one line.
* .chat-receipt.pending / .delivered / .seen color tiers.

board: persist active bucket across reloads

* state.js: state.activeBucket initializes from
  localStorage.getItem('hyprcrd-active-bucket') || 'all'. New
  setActiveBucket(value) helper writes through to localStorage; new
  ensureActiveBucketValid() helper falls back to 'all' if the
  persisted id no longer exists in state.buckets.
* buckets.js: loadBuckets() calls ensureActiveBucketValid() right
  after the API fetch and before renderBar(). All four production
  assignments (delete-keep, delete-all, popover entry click, regular
  tab click) replaced with setActiveBucket() so the persisted value
  stays in sync. Tests still mutate state.activeBucket directly so
  they don't touch localStorage.

chore: bump version to 0.9.6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:43:28 +02:00
thandClaude Opus 4.6 9b4869812f join: merge multi-selected cards into one (right-click + toolbar)
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>
2026-04-11 22:25:42 +02:00