Skip to main content

max / balanced_breakfast

Frontend audit: 35 items resolved — race conditions, a11y, theme-aware CSS, security hardening Fixes race conditions (search/sort requestId guards, star/read inFlight guards, double-submit prevention), accessibility (ARIA dialog, sr-only labels, keyboard nav, nav landmark, prefers-reduced-motion), theme compliance (all rgba() replaced with color-mix(), shadow CSS vars, update banner), security (vbscript: scheme blocking, sanitizeHtml normalization, detail.js toggle error handling), and cleanup (inline event handlers removed, stale User-Agent version, reader plugin hardcoded path, test data circuit_broken field).
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 22:15 UTC
Commit: 0ca2621db0d49d6aed6d18056043196af76b5401
Parent: 8ed6061
19 files changed, +385 insertions, -162 deletions
@@ -12,8 +12,10 @@
12 12 <header class="header">
13 13 <h1>Balanced Breakfast</h1>
14 14 <div class="header-actions">
15 + <label for="search-input" class="sr-only">Search items</label>
15 16 <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input">
16 17 <span id="search-spinner" class="search-spinner" aria-hidden="true"></span>
18 + <label for="sort-select" class="sr-only">Sort order</label>
17 19 <select id="sort-select" class="sort-select" title="Sort order">
18 20 <option value="chronological">Newest First</option>
19 21 <option value="score">By Score</option>
@@ -22,7 +24,7 @@
22 24 </select>
23 25 <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button>
24 26 <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button>
25 - <button class="btn btn-small" title="Settings" onclick="BB.app.showSettings()">&#9881;</button>
27 + <button id="settings-btn" class="btn btn-small" title="Settings">&#9881;</button>
26 28 </div>
27 29 </header>
28 30
@@ -31,14 +33,16 @@
31 33 <!-- Sources sidebar -->
32 34 <aside class="sidebar">
33 35 <h2>Sources</h2>
34 - <ul id="sources-list" class="sources-list">
35 - <li class="source-item active" data-source="" onclick="BB.sources.select('')">
36 - <span class="source-name">All</span>
37 - <span class="source-count">0</span>
38 - </li>
39 - </ul>
36 + <nav aria-label="Feed sources">
37 + <ul id="sources-list" class="sources-list">
38 + <li class="source-item active" data-source="">
39 + <span class="source-name">All</span>
40 + <span class="source-count">0</span>
41 + </li>
42 + </ul>
43 + </nav>
40 44 <div class="sidebar-footer">
41 - <button id="sync-settings-btn" class="btn btn-small" title="Cloud Sync Settings" onclick="BB.sync.openSettings()">Sync</button>
45 + <button id="sync-settings-btn" class="btn btn-small" title="Cloud Sync Settings">Sync</button>
42 46 </div>
43 47 </aside>
44 48
@@ -64,7 +68,7 @@
64 68
65 69 <!-- Modal overlay -->
66 70 <div id="modal-overlay" class="modal-overlay">
67 - <div class="modal-content">
71 + <div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title">
68 72 <div class="modal-header">
69 73 <h2 id="modal-title">Modal</h2>
70 74 <button id="close-modal" class="btn btn-small" aria-label="Close">&times;</button>
@@ -806,6 +806,7 @@
806 806 "consecutive_failures": 0,
807 807 "last_error": null,
808 808 "last_success_at": null,
809 + "circuit_broken": 0,
809 810 });
810 811
811 812 apply_upsert(&pool, "feeds", &feed_id, &data).await.unwrap();
@@ -91,7 +91,7 @@
91 91 .search-input:focus {
92 92 outline: none;
93 93 border-color: var(--yolk);
94 - box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.15);
94 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 15%, transparent);
95 95 }
96 96
97 97 .sort-select {
@@ -237,9 +237,9 @@
237 237 .item:hover { background-color: var(--bg-secondary); }
238 238 .item.unread {
239 239 border-left: 3px solid var(--accent);
240 - background-color: rgba(201, 75, 75, 0.03);
240 + background-color: color-mix(in srgb, var(--accent) 6%, var(--bg-primary));
241 241 }
242 - .item.read { opacity: 0.75; }
242 + .item.read { color: var(--text-secondary); }
243 243 .item.selected { background-color: var(--bg-tertiary); }
244 244 .item.empty-state {
245 245 text-align: center;
@@ -387,7 +387,7 @@
387 387 .modal-overlay {
388 388 position: fixed;
389 389 top: 0; left: 0; right: 0; bottom: 0;
390 - background-color: rgba(61, 50, 37, 0.4);
390 + background-color: color-mix(in srgb, var(--text-primary) 40%, transparent);
391 391 display: none;
392 392 align-items: center;
393 393 justify-content: center;
@@ -401,7 +401,7 @@
401 401 max-width: 500px;
402 402 max-height: 80vh;
403 403 overflow-y: auto;
404 - box-shadow: 0 10px 40px rgba(61, 50, 37, 0.2);
404 + box-shadow: 0 10px 40px color-mix(in srgb, var(--text-primary) 20%, transparent);
405 405 }
406 406
407 407 .modal-header {
@@ -460,7 +460,7 @@
460 460 .form-input:focus {
461 461 outline: none;
462 462 border-color: var(--yolk);
463 - box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.15);
463 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 15%, transparent);
464 464 }
465 465
466 466 .form-actions {
@@ -495,8 +495,8 @@
495 495 font-weight: 500;
496 496 font-size: 0.875rem;
497 497 }
498 - .toast.success { border-left: 4px solid var(--success); background-color: rgba(107, 155, 90, 0.08); }
499 - .toast.error { border-left: 4px solid var(--accent); background-color: rgba(201, 75, 75, 0.08); }
498 + .toast.success { border-left: 4px solid var(--success); background-color: color-mix(in srgb, var(--success) 8%, var(--bg-primary)); }
499 + .toast.error { border-left: 4px solid var(--accent); background-color: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); }
500 500
501 501 @keyframes slideIn {
502 502 from { transform: translateX(100%); opacity: 0; }
@@ -637,14 +637,14 @@
637 637 .toast-action:focus-visible {
638 638 outline: 2px solid var(--yolk);
639 639 outline-offset: 2px;
640 - box-shadow: 0 0 0 4px rgba(232, 168, 65, 0.2);
640 + box-shadow: 0 0 0 4px color-mix(in srgb, var(--yolk) 20%, transparent);
641 641 }
642 642
643 643 .search-input:focus-visible,
644 644 .form-input:focus-visible {
645 645 outline: none;
646 646 border-color: var(--yolk);
647 - box-shadow: 0 0 0 3px rgba(232, 168, 65, 0.25);
647 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--yolk) 25%, transparent);
648 648 }
649 649
650 650 /* Tag filter bar above sources list */
@@ -835,6 +835,68 @@
835 835 font-weight: 500;
836 836 }
837 837
838 + /* Sidebar footer */
839 + .sidebar-footer {
840 + margin-top: auto;
841 + padding: 0.5rem 0.75rem;
842 + border-top: 1px solid var(--border);
843 + }
844 +
845 + /* Screen-reader only */
846 + .sr-only {
847 + position: absolute;
848 + width: 1px;
849 + height: 1px;
850 + padding: 0;
851 + margin: -1px;
852 + overflow: hidden;
853 + clip: rect(0, 0, 0, 0);
854 + white-space: nowrap;
855 + border: 0;
856 + }
857 +
858 + /* Responsive breakpoints */
859 + @media (max-width: 768px) {
860 + .sidebar { width: 180px; min-width: 180px; }
861 + .detail-panel { width: 300px; min-width: 300px; }
862 + .search-input { width: 140px; }
863 + .help-shortcuts { grid-template-columns: 1fr; }
864 + }
865 +
866 + @media (max-width: 600px) {
867 + .header { flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem 0.75rem; }
868 + .header h1 { font-size: 1.1rem; }
869 + .header-actions { flex-wrap: wrap; gap: 0.4rem; }
870 + .search-input { width: 100%; order: 10; }
871 + .sidebar { width: 160px; min-width: 160px; }
872 + .detail-panel { width: 260px; min-width: 260px; }
873 + .modal-content { width: 95%; }
874 + }
875 +
876 + /* Reduced motion */
877 + @media (prefers-reduced-motion: reduce) {
878 + *, *::before, *::after {
879 + animation-duration: 0.01ms !important;
880 + animation-iteration-count: 1 !important;
881 + transition-duration: 0.01ms !important;
882 + }
883 + }
884 +
885 + /* Update banner */
886 + .update-banner {
887 + position: fixed;
888 + bottom: 1rem;
889 + right: 1rem;
890 + background: var(--bg-secondary);
891 + border: 1px solid var(--border);
892 + border-radius: 8px;
893 + padding: 0.75rem 1rem;
894 + z-index: 9999;
895 + max-width: 320px;
896 + box-shadow: 0 4px 12px var(--shadow);
897 + font-size: 0.875rem;
898 + }
899 +
838 900 /* Scrollbar */
839 901 ::-webkit-scrollbar { width: 6px; height: 6px; }
840 902 ::-webkit-scrollbar-track { background: var(--bg-secondary); }
@@ -63,6 +63,8 @@
63 63 setTags: (busserId, tags) => invoke('set_feed_tags', { busserId, tags }),
64 64 /** List all distinct tags across all feeds. */
65 65 listAllTags: () => invoke('list_all_tags'),
66 + /** Reset circuit breaker and retry fetch. */
67 + resetCircuitBreaker: (busserId) => invoke('reset_circuit_breaker', { busserId }),
66 68 },
67 69
68 70 // --- OPML: import/export feed subscriptions ---
@@ -10,40 +10,63 @@
10 10
11 11 const { invoke } = window.__TAURI__.core;
12 12
13 + /** Guard to prevent registering event listeners multiple times. */
14 + let initialized = false;
15 +
16 + /** Monotonic request ID so concurrent search/sort loads resolve in order. */
17 + let loadRequestId = 0;
18 +
13 19 /**
14 20 * Initialize the application: load theme and data, wire up UI, show welcome.
15 21 */
16 22 async function init() {
23 + if (initialized) return;
24 + initialized = true;
25 +
17 26 // Load theme before rendering content
18 27 await BB.themes.init();
19 28
20 - // Load data
21 - await BB.sources.load();
22 - await BB.items.load();
23 -
24 - // First-run welcome
25 - const welcomed = await invoke('get_config', { key: 'bb-welcomed' });
26 - if (!welcomed) {
27 - showWelcome();
29 + // Load data — catch so a backend error doesn't leave a blank screen
30 + try {
31 + await BB.sources.load();
32 + await BB.items.load();
33 + } catch (err) {
34 + BB.ui.showErrorWithRetry(
35 + 'Failed to load data: ' + BB.utils.getErrorMessage(err),
36 + () => { BB.sources.load(); BB.items.load(); }
37 + );
28 38 }
29 39
30 - // Search input — debounced at 300ms to avoid hammering the backend
31 - // on every keystroke while still feeling responsive.
40 + // First-run welcome
41 + try {
42 + const welcomed = await invoke('get_config', { key: 'bb-welcomed' });
43 + if (!welcomed) {
44 + showWelcome();
45 + }
46 + } catch (_) {
47 + // Non-critical — skip welcome on error
48 + }
49 +
50 + // Search input — debounced at 300ms, with request ID so last request wins
32 51 const searchInput = document.getElementById('search-input');
33 52 const searchSpinner = document.getElementById('search-spinner');
34 53 searchInput.addEventListener('input', BB.utils.debounce(async () => {
35 54 BB.state.set('currentSearch', searchInput.value);
36 55 BB.state.resetPagination();
56 + const myId = ++loadRequestId;
37 57 searchSpinner.classList.add('active');
38 58 await BB.items.load();
39 - searchSpinner.classList.remove('active');
59 + if (loadRequestId === myId) {
60 + searchSpinner.classList.remove('active');
61 + }
40 62 }, 300));
41 63
42 - // Sort select
43 - document.getElementById('sort-select').addEventListener('change', (e) => {
64 + // Sort select — guarded so rapid changes don't interleave results
65 + document.getElementById('sort-select').addEventListener('change', async (e) => {
44 66 BB.state.set('currentOrder', e.target.value);
45 67 BB.state.resetPagination();
46 - BB.items.load();
68 + ++loadRequestId;
69 + await BB.items.load();
47 70 });
48 71
49 72 // Button handlers
@@ -51,6 +74,8 @@
51 74 document.getElementById('add-feed-btn').addEventListener('click', BB.feeds.openAddFeed);
52 75 document.getElementById('close-detail').addEventListener('click', BB.detail.close);
53 76 document.getElementById('load-more-btn').addEventListener('click', BB.items.loadMore);
77 + document.getElementById('settings-btn').addEventListener('click', showSettings);
78 + document.getElementById('sync-settings-btn').addEventListener('click', BB.sync.openSettings);
54 79
55 80 // Modal close on overlay click
56 81 document.getElementById('modal-overlay').addEventListener('click', (e) => {
@@ -155,7 +180,26 @@
155 180
156 181 listen('auto-fetch-error', (event) => {
157 182 const pluginId = event.payload?.pluginId || 'unknown';
158 - BB.ui.showToast('Failed to fetch ' + pluginId, 'error');
183 + const error = event.payload?.error || '';
184 + BB.ui.showToast('Failed to fetch ' + pluginId + (error ? ': ' + error : ''), 'error');
185 + });
186 +
187 + listen('feed-circuit-broken', (event) => {
188 + const pluginId = event.payload?.pluginId || 'unknown';
189 + BB.ui.showErrorWithRetry(
190 + 'Feed "' + pluginId + '" disabled after repeated failures',
191 + async () => {
192 + try {
193 + await BB.api.feeds.resetCircuitBreaker(pluginId);
194 + BB.ui.showToast('Feed re-enabled');
195 + BB.sources.load();
196 + BB.items.load();
197 + } catch (err) {
198 + BB.ui.showToast('Failed to reset: ' + BB.utils.getErrorMessage(err), 'error');
199 + }
200 + }
201 + );
202 + BB.sources.load();
159 203 });
160 204
161 205 listen('menu:refresh', () => BB.feeds.refresh());
@@ -187,18 +231,31 @@
187 231 const body = document.getElementById('modal-body');
188 232 const title = document.getElementById('modal-title');
189 233 title.textContent = 'Settings';
190 - body.innerHTML = `
191 - <div class="settings-content">
192 - <div class="form-group">
193 - <label>Theme</label>
194 - <div id="settings-theme-container"></div>
195 - </div>
196 - <div class="form-actions">
197 - <button class="btn" onclick="BB.ui.closeModal();">Close</button>
198 - </div>
199 - </div>
200 - `;
201 - BB.themes.buildSelector(document.getElementById('settings-theme-container'));
234 + body.innerHTML = '';
235 + const content = document.createElement('div');
236 + content.className = 'settings-content';
237 +
238 + const group = document.createElement('div');
239 + group.className = 'form-group';
240 + const label = document.createElement('label');
241 + label.textContent = 'Theme';
242 + const themeContainer = document.createElement('div');
243 + themeContainer.id = 'settings-theme-container';
244 + group.appendChild(label);
245 + group.appendChild(themeContainer);
246 + content.appendChild(group);
247 +
248 + const actions = document.createElement('div');
249 + actions.className = 'form-actions';
250 + const closeBtn = document.createElement('button');
251 + closeBtn.className = 'btn';
252 + closeBtn.textContent = 'Close';
253 + closeBtn.addEventListener('click', BB.ui.closeModal);
254 + actions.appendChild(closeBtn);
255 + content.appendChild(actions);
256 +
257 + body.appendChild(content);
258 + BB.themes.buildSelector(themeContainer);
202 259 BB.ui.openModal();
203 260 }
204 261
@@ -240,21 +297,33 @@
240 297 const body = document.getElementById('modal-body');
241 298 const title = document.getElementById('modal-title');
242 299 title.textContent = 'Welcome to Balanced Breakfast';
243 - body.innerHTML = `
244 - <div class="welcome-content">
245 - <p>Your personal feed reader for RSS, Atom, podcasts, and more.</p>
246 - <h3>Getting Started</h3>
247 - <p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File &gt; Import OPML</strong>.</p>
248 - <h3>Keyboard Shortcuts</h3>
249 - <p>Press <kbd>?</kbd> anytime to see all shortcuts. Navigate with <kbd>j</kbd>/<kbd>k</kbd>, star with <kbd>s</kbd>, toggle read with <kbd>r</kbd>.</p>
250 - <h3>Themes &amp; Settings</h3>
251 - <p>Click the gear icon (<strong>&#9881;</strong>) in the header to change themes and configure preferences.</p>
252 - <div class="welcome-cta">
253 - <button class="btn btn-success" onclick="BB.feeds.openAddFeed();">Add Your First Feed</button>
254 - <button class="btn" onclick="BB.ui.closeModal();">Explore First</button>
255 - </div>
256 - </div>
257 - `;
300 + body.innerHTML = '';
301 + const content = document.createElement('div');
302 + content.className = 'welcome-content';
303 + content.innerHTML =
304 + '<p>Your personal feed reader for RSS, Atom, podcasts, and more.</p>' +
305 + '<h3>Getting Started</h3>' +
306 + '<p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File &gt; Import OPML</strong>.</p>' +
307 + '<h3>Keyboard Shortcuts</h3>' +
308 + '<p>Press <kbd>?</kbd> anytime to see all shortcuts. Navigate with <kbd>j</kbd>/<kbd>k</kbd>, star with <kbd>s</kbd>, toggle read with <kbd>r</kbd>.</p>' +
309 + '<h3>Themes &amp; Settings</h3>' +
310 + '<p>Click the gear icon (<strong>&#9881;</strong>) in the header to change themes and configure preferences.</p>';
311 +
312 + const cta = document.createElement('div');
313 + cta.className = 'welcome-cta';
314 + const addBtn = document.createElement('button');
315 + addBtn.className = 'btn btn-success';
316 + addBtn.textContent = 'Add Your First Feed';
317 + addBtn.addEventListener('click', BB.feeds.openAddFeed);
318 + const exploreBtn = document.createElement('button');
319 + exploreBtn.className = 'btn';
320 + exploreBtn.textContent = 'Explore First';
321 + exploreBtn.addEventListener('click', BB.ui.closeModal);
322 + cta.appendChild(addBtn);
323 + cta.appendChild(exploreBtn);
324 + content.appendChild(cta);
325 +
326 + body.appendChild(content);
258 327 BB.ui.openModal();
259 328 invoke('set_config', { key: 'bb-welcomed', value: '1' });
260 329 }
@@ -151,6 +151,9 @@
151 151
152 152 form.onsubmit = async (e) => {
153 153 e.preventDefault();
154 + submitBtn.disabled = true;
155 + const savedLabel = submitBtn.textContent;
156 + submitBtn.textContent = 'Saving...';
154 157 const formData = new FormData(form);
155 158 const data = {};
156 159 for (const [k, v] of formData.entries()) {
@@ -161,7 +164,9 @@
161 164 await opts.onSubmit(data);
162 165 closeModal();
163 166 } catch (err) {
164 - showToast('Error: ' + (err.message || err), 'error');
167 + submitBtn.disabled = false;
168 + submitBtn.textContent = savedLabel;
169 + showToast('Error: ' + BB.utils.getErrorMessage(err), 'error');
165 170 }
166 171 }
167 172 };
@@ -11,7 +11,7 @@
11 11 (function() {
12 12 'use strict';
13 13
14 - const { escapeHtml, sanitizeHtml } = BB.utils;
14 + const { escapeHtml, sanitizeHtml, getErrorMessage } = BB.utils;
15 15
16 16 /** @type {Object|null} Currently displayed item (mutable local copy). */
17 17 let currentItem = null;
@@ -21,8 +21,10 @@
21 21 * @param {string} id - Item external_id.
22 22 */
23 23 async function load(id) {
24 + // Skip if already showing this item
25 + if (currentItem && currentItem.id === id) return;
26 +
24 27 const panel = document.getElementById('detail-panel');
25 - const detail = document.getElementById('item-detail');
26 28
27 29 try {
28 30 const item = await BB.api.items.get(id);
@@ -30,7 +32,7 @@
30 32 panel.style.display = 'flex';
31 33 renderDetail(item);
32 34 } catch (err) {
33 - BB.ui.showErrorWithRetry('Failed to load item: ' + err, () => load(id));
35 + BB.ui.showErrorWithRetry('Failed to load item: ' + getErrorMessage(err), () => load(id));
34 36 }
35 37 }
36 38
@@ -47,7 +49,11 @@
47 49 if (!currentItem) return;
48 50
49 51 const updated = items.find(i => i.id === currentItem.id);
50 - if (!updated) return;
52 + if (!updated) {
53 + // Item was deleted or filtered out — close the stale detail panel
54 + close();
55 + return;
56 + }
51 57
52 58 // Merge summary-level fields that may have changed externally
53 59 const summaryFields = [
@@ -86,17 +92,38 @@
86 92 </div>
87 93 ${tags}
88 94 <div class="detail-body">${sanitizeHtml(item.body || item.text)}</div>
89 - <div class="detail-actions">
90 - <button class="btn" onclick="BB.detail.toggleRead()">
91 - ${item.isRead ? 'Mark Unread' : 'Mark Read'}
92 - </button>
93 - <button class="btn" onclick="BB.detail.toggleStar()">
94 - ${item.isStarred ? 'Unstar' : 'Star'}
95 - </button>
96 - ${item.url ? `<button class="btn" onclick="BB.detail.readerView()">Reader View</button>` : ''}
97 - ${item.url ? `<button class="btn btn-primary" onclick="BB.detail.openUrl()">Open</button>` : ''}
98 - </div>
99 95 `;
96 +
97 + const actions = document.createElement('div');
98 + actions.className = 'detail-actions';
99 +
100 + const readBtn = document.createElement('button');
101 + readBtn.className = 'btn';
102 + readBtn.textContent = item.isRead ? 'Mark Unread' : 'Mark Read';
103 + readBtn.addEventListener('click', toggleRead);
104 + actions.appendChild(readBtn);
105 +
106 + const starBtn = document.createElement('button');
107 + starBtn.className = 'btn';
108 + starBtn.textContent = item.isStarred ? 'Unstar' : 'Star';
109 + starBtn.addEventListener('click', toggleStar);
110 + actions.appendChild(starBtn);
111 +
112 + if (item.url) {
113 + const readerBtn = document.createElement('button');
114 + readerBtn.className = 'btn';
115 + readerBtn.textContent = 'Reader View';
116 + readerBtn.addEventListener('click', readerView);
117 + actions.appendChild(readerBtn);
118 +
119 + const openBtn = document.createElement('button');
120 + openBtn.className = 'btn btn-primary';
121 + openBtn.textContent = 'Open';
122 + openBtn.addEventListener('click', openUrl);
123 + actions.appendChild(openBtn);
124 + }
125 +
126 + detail.appendChild(actions);
100 127 }
101 128
102 129 /** Close the detail panel and deselect the current item. */
@@ -113,19 +140,37 @@
113 140 */
114 141 async function toggleRead() {
115 142 if (!currentItem) return;
116 - const newState = !currentItem.isRead;
117 - await BB.items.toggleRead(currentItem.id, currentItem.isRead);
118 - currentItem.isRead = newState;
119 - renderDetail(currentItem);
143 + try {
144 + const wasRead = currentItem.isRead;
145 + if (wasRead) {
146 + await BB.api.items.markUnread(currentItem.id);
147 + } else {
148 + await BB.api.items.markRead(currentItem.id);
149 + }
150 + currentItem.isRead = !wasRead;
151 + BB.items.updateItemField(currentItem.id, 'isRead', !wasRead);
152 + renderDetail(currentItem);
153 + } catch (err) {
154 + BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
155 + }
120 156 }
121 157
122 - /** Toggle star on the current item (same optimistic-update pattern as toggleRead). */
158 + /** Toggle star on the current item. Only mutates local state on success. */
123 159 async function toggleStar() {
124 160 if (!currentItem) return;
125 - const newState = !currentItem.isStarred;
126 - await BB.items.toggleStar(currentItem.id, currentItem.isStarred);
127 - currentItem.isStarred = newState;
128 - renderDetail(currentItem);
161 + try {
162 + const wasStarred = currentItem.isStarred;
163 + if (wasStarred) {
164 + await BB.api.items.unstar(currentItem.id);
165 + } else {
166 + await BB.api.items.star(currentItem.id);
167 + }
168 + currentItem.isStarred = !wasStarred;
169 + BB.items.updateItemField(currentItem.id, 'isStarred', !wasStarred);
170 + renderDetail(currentItem);
171 + } catch (err) {
172 + BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
173 + }
129 174 }
130 175
131 176 /** Open the current item's URL in the system browser via Tauri shell API. */
@@ -147,7 +192,7 @@
147 192 if (result.title) currentItem.title = result.title;
148 193 renderDetail(currentItem);
149 194 } catch (err) {
150 - BB.ui.showToast('Reader view failed: ' + err, 'error');
195 + BB.ui.showToast('Reader view failed: ' + getErrorMessage(err), 'error');
151 196 }
152 197 }
153 198
@@ -46,7 +46,7 @@
46 46 body.appendChild(list);
47 47 overlay.style.display = 'flex';
48 48 } catch (err) {
49 - BB.ui.showToast('Failed to load plugins: ' + err, 'error');
49 + BB.ui.showToast('Failed to load plugins: ' + BB.utils.getErrorMessage(err), 'error');
50 50 }
51 51 }
52 52
@@ -61,7 +61,7 @@
61 61 const schema = await BB.api.plugins.schema(pluginId);
62 62 showPluginForm(schema);
63 63 } catch (err) {
64 - BB.ui.showToast('Failed to load plugin schema: ' + err, 'error');
64 + BB.ui.showToast('Failed to load plugin schema: ' + BB.utils.getErrorMessage(err), 'error');
65 65 }
66 66 }
67 67
@@ -135,7 +135,7 @@
135 135 BB.items.load();
136 136 } catch (err) {
137 137 progress.set(100);
138 - BB.ui.showErrorWithRetry('Failed to refresh: ' + err, refresh);
138 + BB.ui.showErrorWithRetry('Failed to refresh: ' + BB.utils.getErrorMessage(err), refresh);
139 139 } finally {
140 140 setTimeout(() => progress.remove(), 400);
141 141 btn.disabled = false;
@@ -158,7 +158,7 @@
158 158 URL.revokeObjectURL(url);
159 159 BB.ui.showToast('Feeds exported');
160 160 } catch (err) {
161 - BB.ui.showToast('Export failed: ' + err, 'error');
161 + BB.ui.showToast('Export failed: ' + BB.utils.getErrorMessage(err), 'error');
162 162 }
163 163 }
164 164
@@ -185,7 +185,7 @@
185 185 BB.sources.load();
186 186 BB.items.load();
187 187 } catch (err) {
188 - BB.ui.showToast('Import failed: ' + err, 'error');
188 + BB.ui.showToast('Import failed: ' + BB.utils.getErrorMessage(err), 'error');
189 189 }
190 190 };
191 191 input.click();
@@ -7,7 +7,10 @@
7 7 (function() {
8 8 'use strict';
9 9
10 - const { escapeHtml, escapeAttr } = BB.utils;
10 + const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils;
11 +
12 + /** Set of item IDs with in-flight star/read toggles to prevent double-clicks. */
13 + const inFlight = new Set();
11 14
12 15 /**
13 16 * Fetch items from the backend and update state.
@@ -39,7 +42,7 @@
39 42 }
40 43 } catch (err) {
41 44 clearSkeletons();
42 - BB.ui.showErrorWithRetry('Failed to load items: ' + err, () => load(append));
45 + BB.ui.showErrorWithRetry('Failed to load items: ' + getErrorMessage(err), () => load(append));
43 46 }
44 47 }
45 48
@@ -120,7 +123,14 @@
120 123 </div>
121 124 `;
122 125
126 + li.setAttribute('tabindex', '0');
123 127 li.onclick = () => selectItem(item.id);
128 + li.onkeydown = (e) => {
129 + if (e.key === 'Enter' || e.key === ' ') {
130 + e.preventDefault();
131 + selectItem(item.id);
132 + }
133 + };
124 134 list.appendChild(li);
125 135 });
126 136
@@ -152,6 +162,8 @@
152 162 * @param {boolean} isStarred - Current starred state.
153 163 */
154 164 async function toggleStar(id, isStarred) {
165 + if (inFlight.has('star-' + id)) return;
166 + inFlight.add('star-' + id);
155 167 try {
156 168 if (isStarred) {
157 169 await BB.api.items.unstar(id);
@@ -161,7 +173,9 @@
161 173 updateItemField(id, 'isStarred', !isStarred);
162 174 BB.ui.showToast(isStarred ? 'Unstarred' : 'Starred');
163 175 } catch (err) {
164 - BB.ui.showToast('Failed to update star: ' + err, 'error');
176 + BB.ui.showToast('Failed to update star: ' + getErrorMessage(err), 'error');
177 + } finally {
178 + inFlight.delete('star-' + id);
165 179 }
166 180 }
167 181
@@ -171,6 +185,8 @@
171 185 * @param {boolean} isRead - Current read state.
172 186 */
173 187 async function toggleRead(id, isRead) {
188 + if (inFlight.has('read-' + id)) return;
189 + inFlight.add('read-' + id);
174 190 try {
175 191 if (isRead) {
176 192 await BB.api.items.markUnread(id);
@@ -180,7 +196,9 @@
180 196 updateItemField(id, 'isRead', !isRead);
181 197 BB.ui.showToast(isRead ? 'Marked unread' : 'Marked read');
182 198 } catch (err) {
183 - BB.ui.showToast('Failed to update: ' + err, 'error');
199 + BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
200 + } finally {
201 + inFlight.delete('read-' + id);
184 202 }
185 203 }
186 204
@@ -200,5 +218,5 @@
200 218
201 219 BB.state.subscribe('items', render);
202 220
203 - BB.items = { load, render, selectItem, toggleStar, toggleRead, loadMore };
221 + BB.items = { load, render, selectItem, toggleStar, toggleRead, loadMore, updateItemField };
204 222 })();
@@ -182,7 +182,7 @@
182 182 load();
183 183 BB.sources.load();
184 184 } catch (err) {
185 - BB.ui.showToast('Failed to save: ' + err, 'error');
185 + BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error');
186 186 }
187 187 }
188 188
@@ -218,7 +218,7 @@
218 218 load();
219 219 BB.sources.load();
220 220 } catch (err) {
221 - BB.ui.showToast('Failed to delete: ' + err, 'error');
221 + BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error');
222 222 }
223 223 }
224 224