Compare commits

...
4 Commits
Author SHA1 Message Date
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 c9749352ec board: no new-card pulse for fresh journal entries
Closing a new journal entry briefly flashed the card with the
1× local-created pulse. Journal entries are daily-log noise, not
"look at this new thing" moments, so the flash reads as clutter.
Detect journals at pulse time (title starts YYYY-MM-DD, no
termin/geburtstag, no caldav_uid, no " — <summary>" suffix —
mirrors calendar.js isJournal) and drop them from the pulse set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:51:23 +02:00
thandClaude Opus 4.6 412dc527a3 help: let the dialog scroll
The shared modal-container rule slapped `overflow: hidden` on
`#help > div` to clip border-radius, which won over the Tailwind
`overflow-y-auto` on the same element and froze the scroll. Move
help to its own rule that keeps the rounded corners + shadow and
leaves overflow alone so the inline overflow-y-auto takes effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:06 +02:00
thandClaude Opus 4.6 27cc3f98ac tooltip: kill stuck tips with watchdog + pointermove sanity
Tooltips could strand on screen when pointerleave didn't fire — fast
flicks off the target, re-renders that yanked the element out mid-
hover, or the tab losing focus. Three guards:

  - 5s max-life watchdog on every show, so the worst case is a late
    fade rather than a permanent overlay
  - pointermove check: if the pointer is no longer over the tip's
    owning target (or the target got detached from the DOM), hide
  - window blur + visibilitychange also hide immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:43:12 +02:00
6 changed files with 100 additions and 4 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.12');
define('APP_VERSION', '1.9.15');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.12",
"version": "1.9.15",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+8 -1
View File
@@ -113,7 +113,6 @@ select[class*="border-t-accent"] {
#modal > div,
#detail > div,
#settings-overlay > div,
#help > div,
#search > div,
#tasks > div,
#calendar > div,
@@ -126,6 +125,14 @@ select[class*="border-t-accent"] {
overflow: hidden;
}
/* The help dialog scrolls its own body — keep the border-radius and
shadow but drop overflow:hidden so the Tailwind overflow-y-auto on
the same element actually takes effect. */
#help > div {
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
}
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
.overlay { backdrop-filter: blur(var(--ui-blur-overlay)); -webkit-backdrop-filter: blur(var(--ui-blur-overlay)); }
+16 -1
View File
@@ -768,9 +768,24 @@ export function renderBoard() {
// Pulse locally-created cards in ANY bucket (1× subtle pulse).
// These never enter unseenCardIds so they can't trigger the all-tab
// dot — the user just made them, there's nothing "unseen" about it.
// Journal entries are exempt from the pulse entirely — they're daily
// log jottings, not "here's a new thing" moments, so the flash
// reads as noise. Detection mirrors calendar.js isJournal: title
// begins with YYYY-MM-DD, no termin/geburtstag tag, no caldav_uid,
// and no " — <summary>" suffix (which denotes an appointment).
if (newLocalIds.size) {
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
pulseCards(newLocalIds, 'card-pulse-local');
const toPulse = new Set();
for (const id of newLocalIds) {
const card = state.cards.find(c => String(c.id) === id);
if (!card) continue;
const isJournal = /^\d{4}-\d{2}-\d{2}$/.test((card.title || '').trim())
&& !/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title || '')
&& !card.caldav_uid
&& !(card.tags || '').split(',').some(t => t === 'termin' || t === 'geburtstag');
if (!isJournal) toPulse.add(id);
}
if (toPulse.size) pulseCards(toPulse, 'card-pulse-local');
}
// Remote (relay-synced) cards: pulse 3× only when the user is on
// the all view. On other views, the unseen ids accumulate and
+27
View File
@@ -5,11 +5,13 @@
const SHOW_DELAY = 400;
const HIDE_DELAY = 100;
const MAX_LIFE = 5000; // watchdog: tooltip never outlives this
const OFFSET = 8;
let tipEl = null;
let showTimer = null;
let hideTimer = null;
let lifeTimer = null;
let currentTarget = null;
function create() {
@@ -63,10 +65,18 @@ function show(target) {
tipEl.style.top = top + 'px';
tipEl.style.left = left + 'px';
tipEl.style.visibility = '';
// Watchdog: if pointerleave never fires (fast flick off the
// element, target removed from the DOM mid-hover, focus taken by
// another window), the tooltip would sit forever. Force-hide
// after MAX_LIFE regardless.
clearTimeout(lifeTimer);
lifeTimer = setTimeout(hide, MAX_LIFE);
}
function hide() {
currentTarget = null;
clearTimeout(lifeTimer);
if (tipEl) tipEl.style.display = 'none';
}
@@ -91,3 +101,20 @@ document.addEventListener('pointerleave', onLeave, true);
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true);
// Belt-and-suspenders sanity check on every move: if the pointer is
// no longer over the tip-owning target (or the target has been
// detached from the DOM since show time), hide. Catches fast flicks
// that skip the expected pointerleave and DOM churn that yanks the
// element out from under us.
document.addEventListener('pointermove', (e) => {
if (!currentTarget) return;
if (!currentTarget.isConnected) { hide(); return; }
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || !currentTarget.contains(el)) hide();
}, true);
// Window losing focus / tab hidden — nothing to hover over while
// we're not there, kill any lingering tip.
window.addEventListener('blur', () => { clearTimeout(showTimer); hide(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) { clearTimeout(showTimer); hide(); } });
+47
View File
@@ -184,3 +184,50 @@ describe('Board: Focus restoration after overlay close', () => {
expect(document.querySelector('#board .card-focused')).toExist();
});
});
describe('Board: new-card pulse', () => {
// Journal entries shouldn't get the 1× card-pulse-local flash —
// they're daily-log jottings, not "new thing" moments. We use
// title "9999-12-31" for the journal probe to ensure we can't
// collide with a real user entry; a control card with a
// __prefixed__ title verifies the pulse still fires for normal
// local creations.
it('locally-created journal-format cards skip the pulse', async () => {
const token = csrf();
const hJson = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
const hCsrf = { 'X-CSRF-Token': token };
let journal, control;
try {
journal = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '9999-12-31', body: '' }) }).then(r => r.json());
control = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '__test_pulse_ctrl__', body: '' }) }).then(r => r.json());
expect(journal?.id).toExist();
expect(control?.id).toExist();
// Flag both as "just created locally" so renderBoard routes
// them through the local-pulse branch. The journal
// exemption then has to filter out the journal one.
window.state.recentlyCreatedIds.add(String(journal.id));
window.state.recentlyCreatedIds.add(String(control.id));
await window._reloadAll();
// Wait past the rAF tick so pulse classes have been
// applied, but well within the 900ms lifetime so we
// can still observe them.
await sleep(150);
const journalEl = document.querySelector(`.board-card[data-id="${journal.id}"]`);
const controlEl = document.querySelector(`.board-card[data-id="${control.id}"]`);
expect(journalEl).toExist();
expect(controlEl).toExist();
expect(journalEl.classList.contains('card-pulse-local')).toBe(false);
expect(controlEl.classList.contains('card-pulse-local')).toBe(true);
} finally {
// Clean up even on failure. Backup/restore would catch it
// eventually, but between tests we don't want a stray
// 9999-12-31 card floating around upsetting other suites.
if (journal?.id) await fetch(`api/cards/${journal.id}`, { method: 'DELETE', headers: hCsrf });
if (control?.id) await fetch(`api/cards/${control.id}`, { method: 'DELETE', headers: hCsrf });
await window._reloadAll?.();
}
});
});