new-card unread dot + pulse fix, relay inbox readable, UI polish pass

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>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 09:09:29 +02:00
1 parent a67bedc6f2
commit c16659c2ad
9 files changed
+773 -246

No files matched your search

+45
View File
@@ -743,6 +743,51 @@ body.detail-image-fs-open { overflow: hidden; }
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
}
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
arrive while the user is viewing a different bucket. Cleared the
moment the user navigates back to all (renderBoard drains the
unseen set + pulses the cards). The dot sits *inside* the tab's
top-right corner because the parent #main-header has overflow:
hidden — anything positioned outside the tab gets clipped twice
(overflow-x-auto on bucket-bar + overflow:hidden on header). */
.bucket-tab.has-unread { position: relative; }
.bar-unread-dot {
position: absolute; top: 3px; right: 3px;
width: 7px; height: 7px; border-radius: 50%;
background: var(--t-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 75%, transparent);
pointer-events: none;
z-index: 1;
}
/* "Look at me" pulse on cards that just arrived. Triggered by
board.js when the all view is shown with unseen cards in the set —
either because new cards arrived while the user was already on
all, or because the user just navigated to all and the unseen ids
accumulated since the last visit. 3 short accent flashes, then
the class is stripped automatically by setTimeout. */
@keyframes card-pulse-new {
0%, 100% {
box-shadow: var(--ui-shadow-card, none),
0 0 0 0 color-mix(in srgb, var(--t-accent) 80%, transparent);
}
50% {
box-shadow: var(--ui-shadow-card, none),
0 0 18px 4px color-mix(in srgb, var(--t-accent) 75%, transparent),
inset 0 0 0 2px color-mix(in srgb, var(--t-accent) 70%, transparent);
}
}
.board-card.card-pulse-new {
animation: card-pulse-new 0.8s ease-in-out 3;
z-index: 5;
}
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
body.anim-fast .board-card.card-pulse-new,
body.anim-slow .board-card.card-pulse-new {
animation: card-pulse-new 0.8s ease-in-out 3 !important;
animation-duration: 0.8s !important;
}
/* Super-bucket dropdown popover. Positioned via inline styles by
openSuperDropdown() in buckets.js. Lives on document.body (not
inside #bucket-bar) so it can escape the bar's overflow:auto. */
+66
View File
@@ -30,6 +30,48 @@ let dragGhost = null;
// time a background poll completed mid-drag.
const draggingCardIds = new Set();
// Tracks every card id we've ever rendered. On the first render we just
// snapshot whatever's currently in state.cards (everything counts as
// "already known"); after that, the diff against this set tells us
// which cards just arrived from a poll / sync. New cards get pushed into
// state.unseenCardIds and either pulse immediately (if we're already on
// the all view) or wait for the next visit to the all tab to pulse.
let knownCardIds = null;
/** Add an accent pulse class to the matching board cards and strip it
* again after the animation finishes. The animation itself is in
* public/css/style.css `@keyframes card-pulse-new`. */
function pulseCardsNew(ids) {
if (!ids || !ids.size) return;
requestAnimationFrame(() => {
for (const id of ids) {
const el = board.querySelector(`[data-id="${id}"]`);
if (!el) continue;
el.classList.add('card-pulse-new');
setTimeout(() => el.classList.remove('card-pulse-new'), 2400);
}
});
}
/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden
* while the user is currently looking at the all view. Lives in
* board.js (rather than buckets.js) because the trigger is "new card
* arrived" — buckets.js doesn't know about that, board.js does. */
function decorateAllUnreadDot() {
const allTab = document.querySelector('#bucket-bar [data-bucket="all"]');
if (!allTab) return;
const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all';
allTab.classList.toggle('has-unread', hasUnread);
let dot = allTab.querySelector('.bar-unread-dot');
if (hasUnread && !dot) {
dot = document.createElement('span');
dot.className = 'bar-unread-dot';
allTab.appendChild(dot);
} else if (!hasUnread && dot) {
dot.remove();
}
}
// ── Multi-select ──
const selectedIds = state.selectedIds;
const selectBar = $('select-bar');
@@ -450,6 +492,19 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
// ── Render ──
export function renderBoard() {
// Diff state.cards against the last seen ids so we can flag arrivals
// as "unseen". The very first renderBoard runs with state.cards = []
// (initial empty state, before the API has returned), so we wait
// until knownCardIds is non-empty before doing the diff — otherwise
// the first real load looks like "every card just arrived" and the
// entire board pulses on page open.
const currentIds = new Set(state.cards.map(c => String(c.id)));
if (knownCardIds && knownCardIds.size > 0) {
for (const id of currentIds) {
if (!knownCardIds.has(id)) state.unseenCardIds.add(id);
}
}
knownCardIds = currentIds;
let cards;
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
@@ -686,6 +741,17 @@ export function renderBoard() {
// Notification banner
renderNotifs();
// Unread tracking: if we're on the all view, drain the unseen set
// by pulsing every matching card on the board (the "look at me"
// accent flash). If we're on a different view, the unseen ids stay
// accumulated and the all-tab gets a dot until the user opens it.
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
const toPulse = new Set(state.unseenCardIds);
state.unseenCardIds.clear();
pulseCardsNew(toPulse);
}
decorateAllUnreadDot();
}
// ── Notification providers ──
+7 -1
View File
@@ -264,8 +264,14 @@ function renderBar() {
return parent;
};
// Show an unread dot on the fixed `all` tab when state.unseenCardIds
// has anything in it AND the user isn't currently looking at the
// all view. board.js drives the unseen set; we just read it here so
// the dot survives bar re-renders that aren't followed by a board
// render (e.g. bucket reorder, plain renderBar() call).
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
+5
View File
@@ -32,6 +32,11 @@ export const state = {
// pick the title strategy: false → first selected wins, true →
// longest title wins (since "first" isn't meaningful for bulk).
selectionWasBulk: false,
// Card ids that have arrived since the user last entered the "all"
// view. Drives the unread dot on the all bucket-tab and the brief
// accent pulse on the cards themselves once the user looks at them.
// Updated by board.js renderBoard() — see the diff there.
unseenCardIds: new Set(),
// Detail / editor
editingId: null,