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>
Drops the multi-column compact list in favour of a one-row-per-card
layout. Each row: color dot · optional cal/fav icon · title (flex-grows
+ ellipsis) · up-to-4 tag chips with overflow indicator · bucket name +
folder icon · right-aligned date column.
Date column picks (in order): due_date → leading YYYY-MM-DD in the
title → updated_at/created_at. Termin/geburtstag tags are filtered out
of the chip list since they're already represented by the icon + dot.
Title font goes from 11px → 14px so the list reads more like a real
list than a label cloud. Rows have 8px vertical padding, hover
background, and a thin bottom-border. Whole grid is capped at 1100px
and centered.
chore: bump version to 0.9.4
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>
Cursor blink, card-bar-pulse, sync spinner and the chat unread blink all
have deliberate cadences — letting body.anim-fast/anim-slow speed them up
turns them into unreadable strobes (or the sync icon spinning at 100ms).
Higher-specificity rules pin each one to its natural duration via
!important.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>