From 8518868ee671c4bc99b27fbda47bb93a1e366eff Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Wed, 18 Feb 2026 19:40:46 -0800 Subject: Improve scroll-to-read robustness and add visual feedback for read items across all themes --- frontend-vanilla/public/themes/codex.css | 15 ++++++++--- frontend-vanilla/public/themes/refined.css | 12 ++++++++- frontend-vanilla/public/themes/sakura.css | 19 +++++++++++--- frontend-vanilla/public/themes/terminal.css | 26 ++++++++++++------- frontend-vanilla/src/main.ts | 39 ++++++++++++++++------------- frontend-vanilla/src/style.css | 12 ++++++++- 6 files changed, 87 insertions(+), 36 deletions(-) (limited to 'frontend-vanilla') diff --git a/frontend-vanilla/public/themes/codex.css b/frontend-vanilla/public/themes/codex.css index 50942e6..fcf4e33 100644 --- a/frontend-vanilla/public/themes/codex.css +++ b/frontend-vanilla/public/themes/codex.css @@ -170,7 +170,7 @@ body { padding: 2.5rem 2rem; } -.main-content > * { +.main-content>* { max-width: 33em; } @@ -182,10 +182,19 @@ body { border-radius: 0; padding-bottom: 2.5rem; border-bottom: none; + transition: opacity 0.4s ease; +} + +.feed-item.read { + opacity: 0.5; +} + +.feed-item.read .item-title { + color: var(--codex-muted); } /* Decorative separator between items -- a subtle fleuron */ -.feed-item + .feed-item::before { +.feed-item+.feed-item::before { content: '\2766'; display: block; text-align: center; @@ -451,4 +460,4 @@ select:focus { .theme-dark .sidebar-backdrop { background: rgba(28, 26, 23, 0.6); -} +} \ No newline at end of file diff --git a/frontend-vanilla/public/themes/refined.css b/frontend-vanilla/public/themes/refined.css index fab2b96..191a5a2 100644 --- a/frontend-vanilla/public/themes/refined.css +++ b/frontend-vanilla/public/themes/refined.css @@ -86,6 +86,16 @@ body { .feed-item { padding: var(--spacing-md) var(--spacing-sm); margin-top: var(--spacing-xl); + transition: opacity 0.3s ease; +} + +.feed-item.read { + opacity: 0.45; +} + +.feed-item.read .item-title { + font-weight: 500; + opacity: 0.8; } .item-title { @@ -269,4 +279,4 @@ select:focus { padding: 2px 8px; border-radius: 4px; letter-spacing: 0.04em; -} +} \ No newline at end of file diff --git a/frontend-vanilla/public/themes/sakura.css b/frontend-vanilla/public/themes/sakura.css index 48a1c0a..948e22e 100644 --- a/frontend-vanilla/public/themes/sakura.css +++ b/frontend-vanilla/public/themes/sakura.css @@ -184,7 +184,7 @@ body { padding: 2.5rem 2rem; } -.main-content > * { +.main-content>* { max-width: 34em; } @@ -195,10 +195,20 @@ body { margin-top: 2.5rem; border-radius: 0; border-bottom: none; + transition: opacity 0.4s ease; +} + +.feed-item.read { + opacity: 0.4; +} + +.feed-item.read .item-title { + color: var(--sakura-stone); + font-weight: 400; } /* Subtle separator -- a single thin rule, Japanese-style restraint */ -.feed-item + .feed-item { +.feed-item+.feed-item { border-top: 1px solid var(--sakura-shadow); padding-top: 2.5rem; } @@ -462,6 +472,7 @@ select:focus { } /* ---- Loading/Empty ---- */ -.loading, .empty { +.loading, +.empty { color: var(--sakura-stone); -} +} \ No newline at end of file diff --git a/frontend-vanilla/public/themes/terminal.css b/frontend-vanilla/public/themes/terminal.css index 48164c9..484d3ff 100644 --- a/frontend-vanilla/public/themes/terminal.css +++ b/frontend-vanilla/public/themes/terminal.css @@ -68,13 +68,11 @@ body { height: 100%; pointer-events: none; z-index: 9999; - background: repeating-linear-gradient( - to bottom, - transparent, - transparent 2px, - rgba(0, 0, 0, 0.03) 2px, - rgba(0, 0, 0, 0.03) 4px - ); + background: repeating-linear-gradient(to bottom, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px); will-change: transform; } } @@ -217,6 +215,15 @@ body { padding: 1.25rem 0.5rem; margin-top: 1.5rem; border-bottom: 1px solid var(--border-color); + transition: opacity 0.4s ease; +} + +.feed-item.read { + opacity: 0.35; +} + +.feed-item.read .item-title { + text-decoration: none; } .feed-item.selected { @@ -482,8 +489,9 @@ select:focus { } /* ---- Loading/Empty States ---- */ -.loading, .empty { +.loading, +.empty { color: var(--text-color); opacity: 0.4; font-family: inherit; -} +} \ No newline at end of file diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 0a67dfe..b59d185 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -325,8 +325,8 @@ export function renderItems() { const scrollRoot = document.getElementById('main-content'); if (scrollRoot) { let readTimeoutId: number | null = null; - scrollRoot.onscroll = () => { - // Infinite scroll check (container only) + const scrollHandler = () => { + // Infinite scroll check if (!store.loading && store.hasMore && scrollRoot.scrollHeight > scrollRoot.clientHeight) { if (scrollRoot.scrollHeight - scrollRoot.scrollTop - scrollRoot.clientHeight < 200) { loadMore(); @@ -336,40 +336,40 @@ export function renderItems() { // Mark-as-read: debounced if (readTimeoutId === null) { readTimeoutId = window.setTimeout(() => { - debugLog('onscroll trigger checkReadItems'); checkReadItems(scrollRoot); readTimeoutId = null; }, 250); } }; + + scrollRoot.onscroll = scrollHandler; + // Fallback for cases where main-content doesn't capture the scroll + window.onscroll = scrollHandler; } } -function checkReadItems(scrollRoot: HTMLElement) { - const containerRect = scrollRoot.getBoundingClientRect(); +function checkReadItems(scrollRoot?: HTMLElement) { + const root = scrollRoot || document.getElementById('main-content') || document.documentElement; + const containerRect = root.getBoundingClientRect(); debugLog('checkReadItems start', { containerTop: containerRect.top }); - // Batch DOM query: select all feed items at once instead of O(n) individual - // querySelector calls with attribute selectors per scroll tick. - const allItems = scrollRoot.querySelectorAll('.feed-item'); - for (const el of allItems) { + // Use faster DOM query for only unread items + const unreadItems = document.querySelectorAll('.feed-item.unread'); + for (const el of unreadItems) { const idAttr = el.getAttribute('data-id'); if (!idAttr) continue; const id = parseInt(idAttr); + + // Safety check: skip if store already says it's read (though unread class implies not) const item = store.items.find(i => i._id === id); - if (!item || item.read) continue; + if (item?.read) continue; const rect = el.getBoundingClientRect(); - // Use a small buffer (5px) to be more robust + // Mark as read if the bottom of the item is above the top of the container (with 5px buffer) const isPast = rect.bottom < (containerRect.top + 5); if (DEBUG) { - debugLog(`Item ${id} check`, { - rectTop: rect.top, - rectBottom: rect.bottom, - containerTop: containerRect.top, - isPast - }); + debugLog(`Item ${id} check`, { rectBottom: rect.bottom, containerTop: containerRect.top, isPast }); } if (isPast) { @@ -714,7 +714,10 @@ export async function updateItem(id: number | string, updates: Partial) { // Selective DOM update to avoid full re-render const el = document.querySelector(`.feed-item[data-id="${id}"]`); if (el) { - if (updates.read !== undefined) el.classList.toggle('read', updates.read); + if (updates.read !== undefined) { + el.classList.toggle('read', updates.read); + el.classList.toggle('unread', !updates.read); + } if (updates.starred !== undefined) { const starBtn = el.querySelector('.star-btn'); if (starBtn) { diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index fd51eff..f6e7f2f 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -450,7 +450,17 @@ select:focus { margin-top: 2rem; border-bottom: none; border-radius: 8px; - transition: background-color 0.2s ease; + transition: background-color 0.2s ease, opacity 0.3s ease; +} + +.feed-item.read { + opacity: 0.5; +} + +.feed-item.read .item-title { + font-weight: normal; + color: var(--text-color); + opacity: 0.8; } -- cgit v1.2.3