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, +383 insertions, -160 deletions
@@ -13,6 +13,9 @@ use crate::url_cleaner;
13 13 /// HTTP request timeout for plugin host functions.
14 14 const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
15 15
16 + /// User-Agent sent with all plugin HTTP requests.
17 + const USER_AGENT: &str = concat!("BalancedBreakfast/", env!("CARGO_PKG_VERSION"), " (feed reader)");
18 +
16 19 /// Maximum response body size (2 MB). Prevents a plugin from consuming
17 20 /// unbounded memory on a large or malicious response.
18 21 const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
@@ -96,7 +99,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<
96 99 check_request_limit(&counter)?;
97 100
98 101 let response = ureq::get(url)
99 - .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)")
102 + .set("User-Agent", USER_AGENT)
100 103 .timeout(HTTP_TIMEOUT)
101 104 .call()
102 105 .map_err(|e| format!("HTTP request failed: {}", e))?;
@@ -119,7 +122,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<
119 122 check_request_limit(&counter)?;
120 123
121 124 let response = ureq::get(url)
122 - .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)")
125 + .set("User-Agent", USER_AGENT)
123 126 .timeout(HTTP_TIMEOUT)
124 127 .call()
125 128 .map_err(|e| format!("HTTP request failed: {}", e))?;
@@ -270,13 +270,13 @@ pub struct ReaderResult {
270 270
271 271 /// Run the reader extraction plugin on a URL.
272 272 ///
273 - /// Creates a one-off Rhai engine, loads `plugins/reader.rhai`, and calls
274 - /// `extract(url)`. Returns the extracted article title, HTML content, and
275 - /// plain text.
276 - pub fn run_reader_script(url: &str) -> Result<ReaderResult, RhaiPluginError> {
273 + /// Creates a one-off Rhai engine, loads `reader.rhai` from the given plugins
274 + /// directory, and calls `extract(url)`. Returns the extracted article title,
275 + /// HTML content, and plain text.
276 + pub fn run_reader_script(url: &str, plugins_dir: &Path) -> Result<ReaderResult, RhaiPluginError> {
277 277 let engine = create_engine();
278 278
279 - let plugin_path = std::path::Path::new("plugins/reader.rhai");
279 + let plugin_path = plugins_dir.join("reader.rhai");
280 280 let script = std::fs::read_to_string(plugin_path).map_err(|e| {
281 281 RhaiPluginError::CompileError(format!("Failed to read reader plugin: {}", e))
282 282 })?;
@@ -91,7 +91,7 @@ body {
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 @@ body {
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 @@ body {
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 @@ body {
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 @@ body {
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 @@ body {
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 @@ body {
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 @@ body {
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); }
@@ -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>
@@ -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();
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 + );
38 + }
23 39
24 40 // First-run welcome
25 - const welcomed = await invoke('get_config', { key: 'bb-welcomed' });
26 - if (!welcomed) {
27 - showWelcome();
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
28 48 }
29 49
30 - // Search input — debounced at 300ms to avoid hammering the backend
31 - // on every keystroke while still feeling responsive.
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
@@ -347,6 +347,7 @@
347 347 window.__TAURI__.event.listen('sync:changes-applied', () => {
348 348 BB.ui.showToast('Sync: changes applied', 'success');
349 349 if (BB.sources && BB.sources.load) BB.sources.load();
350 + if (BB.items && BB.items.load) BB.items.load();
350 351 });
351 352 }
352 353
@@ -7,7 +7,7 @@
7 7 (function() {
8 8 'use strict';
9 9
10 - const { escapeHtml } = BB.utils;
10 + const { escapeHtml, getErrorMessage } = BB.utils;
11 11
12 12 /** Fetch the source list, all tags, and query feeds from the backend. */
13 13 async function load() {
@@ -21,7 +21,7 @@
21 21 // Load query feeds in parallel (non-blocking).
22 22 BB.queryFeeds.load();
23 23 } catch (err) {
24 - BB.ui.showErrorWithRetry('Failed to load sources: ' + err, load);
24 + BB.ui.showErrorWithRetry('Failed to load sources: ' + getErrorMessage(err), load);
25 25 }
26 26 }
27 27
@@ -46,14 +46,22 @@
46 46 const totalUnread = sources.reduce((sum, s) => sum + s.unreadCount, 0);
47 47 const allCountText = totalUnread > 0 ? `${totalUnread}/${totalCount}` : `${totalCount}`;
48 48
49 - list.innerHTML = `
50 - <li class="source-item${current === '' ? ' active' : ''}" data-source=""
51 - role="option" aria-selected="${current === ''}" tabindex="0"
52 - onclick="BB.sources.select('')">
53 - <span class="source-name">All</span>
54 - <span class="source-count">${allCountText}</span>
55 - </li>
56 - `;
49 + list.innerHTML = '';
50 + const allLi = document.createElement('li');
51 + allLi.className = 'source-item' + (current === '' ? ' active' : '');
52 + allLi.dataset.source = '';
53 + allLi.setAttribute('role', 'option');
54 + allLi.setAttribute('aria-selected', current === '' ? 'true' : 'false');
55 + allLi.setAttribute('tabindex', '0');
56 + allLi.innerHTML = '<span class="source-name">All</span><span class="source-count">' + allCountText + '</span>';
57 + allLi.addEventListener('click', () => BB.sources.select(''));
58 + allLi.addEventListener('keydown', (e) => {
59 + if (e.key === 'Enter' || e.key === ' ') {
60 + e.preventDefault();
61 + BB.sources.select('');
62 + }
63 + });
64 + list.appendChild(allLi);
57 65
58 66 sources.forEach(source => {
59 67 const li = document.createElement('li');
@@ -172,16 +180,6 @@
172 180 }
173 181 list.appendChild(addQfBtn);
174 182
175 - // Add keyboard handler for the "All" entry
176 - const allItem = list.querySelector('[data-source=""]');
177 - if (allItem) {
178 - allItem.onkeydown = (e) => {
179 - if (e.key === 'Enter' || e.key === ' ') {
180 - e.preventDefault();
181 - BB.sources.select('');
182 - }
183 - };
184 - }
185 183 }
186 184
187 185 /**
@@ -191,6 +189,10 @@
191 189 function select(sourceId) {
192 190 BB.state.set('currentQueryFeed', null);
193 191 BB.state.set('currentSource', sourceId);
192 + // Clear search when switching sources so results aren't unexpectedly filtered
193 + BB.state.set('currentSearch', '');
194 + const searchInput = document.getElementById('search-input');
195 + if (searchInput) searchInput.value = '';
194 196 BB.state.resetPagination(true);
195 197 render(BB.state.sources);
196 198 BB.items.load();
@@ -234,7 +236,7 @@
234 236 load();
235 237 BB.items.load();
236 238 } catch (err) {
237 - BB.ui.showToast('Failed to restore feed: ' + err, 'error');
239 + BB.ui.showToast('Failed to restore feed: ' + getErrorMessage(err), 'error');
238 240 }
239 241 },
240 242 },
@@ -248,7 +250,7 @@
248 250 load();
249 251 BB.items.load();
250 252 } catch (err) {
251 - BB.ui.showToast('Failed to delete feed: ' + err, 'error');
253 + BB.ui.showToast('Failed to delete feed: ' + getErrorMessage(err), 'error');
252 254 }
253 255 }
254 256
@@ -331,7 +333,7 @@
331 333 BB.api.plugins.schema(source.id),
332 334 ]);
333 335 } catch (err) {
334 - BB.ui.showToast('Failed to load feed details: ' + err, 'error');
336 + BB.ui.showToast('Failed to load feed details: ' + getErrorMessage(err), 'error');
335 337 return;
336 338 }
337 339
@@ -434,8 +434,8 @@ describe('BB.sources.render', () => {
434 434 BB.state.set('queryFeeds', []);
435 435 BB.sources.render(sources);
436 436 const list = document.getElementById('sources-list');
437 - // 3 source items + 1 "+ Query Feed" button appended via appendChild
438 - assertEqual(list.children.length, 4);
437 + // 1 All + 3 source items + 1 "+ Query Feed" button
438 + assertEqual(list.children.length, 5);
439 439 });
440 440
441 441 test('source health indicator shows correct class for yellow', () => {
@@ -447,7 +447,7 @@ describe('BB.sources.render', () => {
447 447 BB.state.set('queryFeeds', []);
448 448 BB.sources.render(sources);
449 449 const list = document.getElementById('sources-list');
450 - const sourceItem = list.children[0]; // first appended child is the source
450 + const sourceItem = list.children[1]; // children[0] is "All", sources start at [1]
451 451 assert(sourceItem.innerHTML.includes('health-yellow'), 'Should have health-yellow class');
452 452 });
453 453
@@ -460,7 +460,7 @@ describe('BB.sources.render', () => {
460 460 BB.state.set('queryFeeds', []);
461 461 BB.sources.render(sources);
462 462 const list = document.getElementById('sources-list');
463 - const sourceItem = list.children[0];
463 + const sourceItem = list.children[1]; // children[0] is "All"
464 464 assert(sourceItem.innerHTML.includes('>5<'), 'Should show just total count');
465 465 assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format');
466 466 });
@@ -474,7 +474,7 @@ describe('BB.sources.render', () => {
474 474 BB.state.set('queryFeeds', []);
475 475 BB.sources.render(sources);
476 476 const list = document.getElementById('sources-list');
477 - const sourceItem = list.children[0];
477 + const sourceItem = list.children[1]; // children[0] is "All"
478 478 assert(sourceItem.innerHTML.includes('dns fail'), 'Should include error text');
479 479 });
480 480
@@ -484,9 +484,9 @@ describe('BB.sources.render', () => {
484 484 BB.state.set('queryFeeds', []);
485 485 BB.sources.render([]);
486 486 const list = document.getElementById('sources-list');
487 - assert(list.innerHTML.includes('All'), 'Should have All entry');
488 - // Only the "+ Query Feed" button is appended via appendChild
489 - assertEqual(list.children.length, 1);
487 + // All item + "+ Query Feed" button
488 + assertEqual(list.children.length, 2);
489 + assert(list.children[0].innerHTML.includes('All'), 'Should have All entry');
490 490 });
491 491 });
492 492
@@ -82,9 +82,8 @@
82 82 root.style.setProperty('--border-dark', darken(border, 10));
83 83
84 84 // Derived: shadow from foreground.primary at opacity
85 - const fg = hexToRgb(colors['foreground.primary']);
86 - root.style.setProperty('--shadow', `rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.08)`);
87 - root.style.setProperty('--shadow-hover', `rgba(${fg.r}, ${fg.g}, ${fg.b}, 0.12)`);
85 + root.style.setProperty('--shadow', `color-mix(in srgb, ${colors['foreground.primary']} 8%, transparent)`);
86 + root.style.setProperty('--shadow-hover', `color-mix(in srgb, ${colors['foreground.primary']} 12%, transparent)`);
88 87 }
89 88
90 89 /**
@@ -25,24 +25,11 @@
25 25
26 26 const banner = document.createElement('div');
27 27 banner.id = 'update-banner';
28 - banner.style.cssText = [
29 - 'position: fixed',
30 - 'bottom: 1rem',
31 - 'right: 1rem',
32 - 'background: var(--bg-secondary)',
33 - 'border: 1px solid var(--border)',
34 - 'border-radius: 8px',
35 - 'padding: 0.75rem 1rem',
36 - 'z-index: 9999',
37 - 'max-width: 320px',
38 - 'box-shadow: 0 4px 12px var(--shadow)',
39 - 'font-family: var(--font-body, sans-serif)',
40 - 'font-size: 0.875rem',
41 - ].join(';');
28 + banner.className = 'update-banner';
42 29
43 30 const title = document.createElement('div');
44 31 title.style.cssText = 'font-weight: 600; margin-bottom: 0.25rem;';
45 - title.textContent = 'Update Available: v' + BB.utils.escapeHtml(version);
32 + title.textContent = 'Update Available: v' + version;
46 33 banner.appendChild(title);
47 34
48 35 if (body) {
@@ -81,7 +68,7 @@
81 68 const { check } = window.__TAURI_PLUGIN_UPDATER__;
82 69 pendingUpdate = await check();
83 70 } catch (err) {
84 - BB.ui.showToast('Update check failed: ' + err, 'error');
71 + BB.ui.showToast('Update check failed: ' + BB.utils.getErrorMessage(err), 'error');
85 72 return;
86 73 }
87 74 }
@@ -128,7 +115,7 @@
128 115 BB.ui.showToast('You are running the latest version.');
129 116 }
130 117 } catch (err) {
131 - BB.ui.showToast('Update check failed: ' + err, 'error');
118 + BB.ui.showToast('Update check failed: ' + BB.utils.getErrorMessage(err), 'error');
132 119 }
133 120 });
134 121 }
@@ -69,10 +69,11 @@
69 69 continue;
70 70 }
71 71
72 - // Strip javascript: and data: URLs in href and src.
72 + // Strip dangerous URL schemes in href and src.
73 + // Normalize by removing whitespace/control chars to defeat obfuscation.
73 74 if (lower === 'href' || lower === 'src') {
74 - const value = (el.getAttribute(name) || '').trim().toLowerCase();
75 - if (value.startsWith('javascript:') || value.startsWith('data:')) {
75 + const value = (el.getAttribute(name) || '').replace(/[\s\x00-\x1f]/g, '').toLowerCase();
76 + if (value.startsWith('javascript:') || value.startsWith('data:') || value.startsWith('vbscript:')) {
76 77 el.removeAttribute(name);
77 78 }
78 79 }
@@ -108,5 +109,22 @@
108 109 };
109 110 }
110 111
111 - BB.utils = { escapeHtml, escapeAttr, sanitizeHtml, debounce };
112 + /**
113 + * Extract a human-readable message from a Tauri API error.
114 + * Tauri commands that return Result<T, ApiError> reject with the serialized
115 + * ApiError object ({code, message}), not a plain string.
116 + * @param {any} err - Error from a rejected invoke() promise.
117 + * @returns {string} The error message string.
118 + */
119 + function getErrorMessage(err) {
120 + if (!err) return 'Unknown error';
121 + if (typeof err === 'string') return err;
122 + if (err.message) return err.message;
123 + if (typeof err === 'object') {
124 + try { return JSON.stringify(err); } catch (_) { /* fall through */ }
125 + }
126 + return String(err);
127 + }
128 +
129 + BB.utils = { escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage };
112 130 })();
@@ -190,12 +190,19 @@ pub struct ReaderViewResponse {
190 190 #[tauri::command]
191 191 #[instrument(skip_all)]
192 192 pub async fn extract_reader_view(
193 - _state: State<'_, Arc<AppState>>,
193 + state: State<'_, Arc<AppState>>,
194 194 url: String,
195 195 ) -> Result<ReaderViewResponse, ApiError> {
196 + let plugins_dir = state
197 + .orchestrator
198 + .plugins()
199 + .read()
200 + .await
201 + .plugins_dir()
202 + .to_path_buf();
196 203 // Run in a blocking task since Rhai engine and HTTP are synchronous.
197 204 let result = tokio::task::spawn_blocking(move || {
198 - bb_core::rhai_plugin::run_reader_script(&url).map_err(|e| {
205 + bb_core::rhai_plugin::run_reader_script(&url, &plugins_dir).map_err(|e| {
199 206 ApiError::plugin(format!("Reader extraction failed: {}", e))
200 207 })
201 208 })
@@ -806,6 +806,7 @@ mod tests {
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();