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>
* 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>
Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>