| 1 |
# Balanced Breakfast Frontend Architecture |
| 2 |
|
| 3 |
Vanilla JavaScript frontend for the Tauri 2 desktop feed aggregator. No framework, no build step, no bundler. 14 source files organized under the `BB` global namespace. |
| 4 |
|
| 5 |
## Namespace |
| 6 |
|
| 7 |
All code lives under `window.BB`. No other globals. Cross-module calls use `BB.moduleName.functionName()`. |
| 8 |
|
| 9 |
``` |
| 10 |
BB |
| 11 |
.api Tauri IPC abstraction (api.js) |
| 12 |
.state Proxy-based reactive state with pub/sub (state.js) |
| 13 |
.ui Toast, modal, form builder, progress bar, confirm dialog (components.js) |
| 14 |
.utils HTML escaping, sanitization, debounce (utils.js) |
| 15 |
.sources Sources sidebar, tag filtering, feed edit/delete (sources.js) |
| 16 |
.items Items list, selection, read/star toggle, pagination (items.js) |
| 17 |
.detail Detail panel, reader view, save-to-file (detail.js) |
| 18 |
.feeds Add feed (plugin picker + schema form), refresh, OPML import/export (feeds.js) |
| 19 |
.queryFeeds Query feed CRUD, condition builder modal (query-feeds.js) |
| 20 |
.themes Theme loading, CSS variable derivation, selector UI (themes.js) |
| 21 |
.sync SyncKit cloud sync settings UI (settings-sync.js) |
| 22 |
.updater OTA update banner (updater.js) |
| 23 |
.app Bootstrap, keyboard shortcuts, menu listeners, settings (app.js) |
| 24 |
``` |
| 25 |
|
| 26 |
## Module Inventory |
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
| Namespace | `bb.js` | Creates `window.BB` with empty sub-namespace slots | |
| 31 |
| State | `state.js` | Proxy-wrapped reactive store with `subscribe()`, `set()`, `get()`, `resetPagination()` | |
| 32 |
| API | `api.js` | Thin `invoke()` wrappers grouped by domain (sources, items, plugins, feeds, opml, queryFeeds, reader, actions, sync) | |
| 33 |
| Utils | `utils.js` | `escapeHtml`, `escapeAttr`, `sanitizeHtml`, `debounce`, `getErrorMessage` | |
| 34 |
| Components | `components.js` | `showToast`, `showProgress`, `openModal`, `closeModal`, `openFormModal`, `showErrorWithRetry`, `confirmAction` | |
| 35 |
| Query Feeds | `query-feeds.js` | CRUD for saved filter rules, condition builder modal with field/operator/value rows | |
| 36 |
| Sources | `sources.js` | Sidebar rendering, source selection, tag bar, feed popover (health/edit/delete), feed edit/tags forms | |
| 37 |
| Items | `items.js` | Item list rendering, selection, star/read toggle with in-flight guards, pagination, saved-articles view | |
| 38 |
| Detail | `detail.js` | Detail panel rendering, reader view extraction, save-to-file as HTML, plugin action buttons, saved-items localStorage | |
| 39 |
| Feeds | `feeds.js` | Two-step add-feed flow (plugin picker then schema form), refresh with progress bar, OPML import/export, plugin warning | |
| 40 |
| Settings/Sync | `settings-sync.js` | 4-state sync UI (connect, authenticating, encryption, ready), API key flow, sync event listener | |
| 41 |
| Themes | `themes.js` | Load/apply themes via Tauri IPC, derive CSS variables from theme colors, theme selector `<select>` builder | |
| 42 |
| Updater | `updater.js` | OTA update banner with consent (Install & Restart / Not Now), listens for `update-available` and `menu:check_updates` events | |
| 43 |
| App | `app.js` | `init()` bootstrap, keyboard shortcuts (vim-style j/k), search debounce, sort select, native menu listeners, settings/help/welcome modals | |
| 44 |
|
| 45 |
## Module Pattern |
| 46 |
|
| 47 |
Every module is an IIFE that registers its public API on the namespace: |
| 48 |
|
| 49 |
```javascript |
| 50 |
(function() { |
| 51 |
'use strict'; |
| 52 |
|
| 53 |
// Private constants and helpers |
| 54 |
const ITEMS_PER_PAGE = 50; |
| 55 |
function privateHelper() { ... } |
| 56 |
|
| 57 |
// Public functions |
| 58 |
async function load() { ... } |
| 59 |
function render(items) { ... } |
| 60 |
|
| 61 |
// Register on namespace |
| 62 |
BB.myModule = { load, render }; |
| 63 |
})(); |
| 64 |
``` |
| 65 |
|
| 66 |
Rules: |
| 67 |
- All modules use `'use strict'` |
| 68 |
- Private state stays inside the IIFE closure (e.g., `currentItem` in detail.js, `inFlight` set in items.js) |
| 69 |
- Public API is the object assigned to `BB.moduleName` |
| 70 |
- No `window.X` exports besides `window.BB` |
| 71 |
- Prefer `async/await` over `.then()` chains |
| 72 |
|
| 73 |
## State Management |
| 74 |
|
| 75 |
`BB.state` is a `Proxy` over a plain data object. Property reads go through the proxy `get` trap; property writes trigger `set()` which notifies subscribers. |
| 76 |
|
| 77 |
### Reading state |
| 78 |
|
| 79 |
```javascript |
| 80 |
const items = BB.state.items; |
| 81 |
const source = BB.state.currentSource; |
| 82 |
// or explicit: |
| 83 |
const page = BB.state.get('currentPage'); |
| 84 |
``` |
| 85 |
|
| 86 |
### Writing state |
| 87 |
|
| 88 |
```javascript |
| 89 |
BB.state.set('items', updatedItems); // Triggers subscribers |
| 90 |
BB.state.currentSource = 'my-feed'; // Also triggers (via Proxy set trap) |
| 91 |
``` |
| 92 |
|
| 93 |
### Subscribing to changes |
| 94 |
|
| 95 |
```javascript |
| 96 |
const unsub = BB.state.subscribe('items', (newItems, oldItems) => { |
| 97 |
render(newItems); |
| 98 |
}); |
| 99 |
``` |
| 100 |
|
| 101 |
### State properties |
| 102 |
|
| 103 |
|
| 104 |
|
| 105 |
| `sources` | Array | Data -- feed sources with counts | |
| 106 |
| `items` | Array | Data -- feed items for current view | |
| 107 |
| `queryFeeds` | Array | Data -- saved filter rules | |
| 108 |
| `allTags` | Array | Data -- distinct tags across all feeds | |
| 109 |
| `currentSource` | string | Filter -- busser_id or `''` for all, `'__saved__'` for saved | |
| 110 |
| `currentOrder` | string | Filter -- `'chronological'`, `'score'`, `'unread'`, `'starred'` | |
| 111 |
| `currentSearch` | string | Filter -- search query text | |
| 112 |
| `currentTag` | string | Filter -- tag filter or `''` for all | |
| 113 |
| `currentQueryFeed` | string/null | Filter -- active query feed ID | |
| 114 |
| `currentPage` | number | Pagination -- zero-indexed page | |
| 115 |
| `hasMore` | boolean | Pagination -- backend has more pages | |
| 116 |
| `selectedItemId` | string/null | Selection -- active item in list | |
| 117 |
| `selectedPluginId` | string/null | UI -- plugin chosen in add-feed flow | |
| 118 |
|
| 119 |
`resetPagination(clearSelection?)` is a convenience method that sets `currentPage` to 0 and optionally clears `selectedItemId`. |
| 120 |
|
| 121 |
## Three-Panel Layout Data Flow |
| 122 |
|
| 123 |
The UI has three columns: sources sidebar (left), items list (center), detail panel (right). |
| 124 |
|
| 125 |
``` |
| 126 |
User clicks source in sidebar |
| 127 |
-> BB.sources.select(sourceId) |
| 128 |
-> BB.state.set('currentSource', sourceId) |
| 129 |
-> BB.state.resetPagination(true) |
| 130 |
-> BB.items.load() |
| 131 |
-> BB.api.items.list(filter) // IPC to Rust |
| 132 |
-> Rust filters + paginates + returns |
| 133 |
-> BB.state.set('items', data.items) // triggers subscriber |
| 134 |
-> BB.items.render(items) // subscriber re-renders list |
| 135 |
``` |
| 136 |
|
| 137 |
``` |
| 138 |
User clicks item in list |
| 139 |
-> BB.items.selectItem(id) |
| 140 |
-> BB.state.set('selectedItemId', id) |
| 141 |
-> BB.detail.load(id) |
| 142 |
-> BB.api.items.get(id) // IPC to Rust |
| 143 |
-> BB.detail.renderDetail(item) // renders detail panel |
| 144 |
-> BB.api.items.markRead(id) // background, then reload |
| 145 |
``` |
| 146 |
|
| 147 |
### Reactive subscriptions |
| 148 |
|
| 149 |
Two state subscriptions drive automatic re-rendering: |
| 150 |
- `BB.state.subscribe('items', render)` in items.js -- re-renders item list on any items change |
| 151 |
- `BB.state.subscribe('sources', render)` in sources.js -- re-renders sidebar on source list change |
| 152 |
- `BB.state.subscribe('items', onItemsChanged)` in detail.js -- merges updated summary fields into the detail view |
| 153 |
|
| 154 |
## API Layer |
| 155 |
|
| 156 |
`BB.api` wraps every Tauri IPC command. The UI never calls `__TAURI__.core.invoke` directly (except themes.js and app.js which use `invoke` for config get/set). |
| 157 |
|
| 158 |
```javascript |
| 159 |
const sources = await BB.api.sources.list(); |
| 160 |
const data = await BB.api.items.list({ source: 'my-feed', page: 0 }); |
| 161 |
await BB.api.items.star(itemId); |
| 162 |
await BB.api.feeds.create({ busserId: 'rss', name: 'My Feed', config: { url: '...' } }); |
| 163 |
``` |
| 164 |
|
| 165 |
API groups: |
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
| `sources` | `list` | |
| 170 |
| `items` | `list`, `get`, `markRead`, `markUnread`, `star`, `unstar`, `unreadCount` | |
| 171 |
| `plugins` | `list`, `schema` | |
| 172 |
| `feeds` | `getByBusser`, `get`, `create`, `update`, `delete`, `deleteByBusser`, `fetchAll`, `setTags`, `listAllTags`, `resetCircuitBreaker` | |
| 173 |
| `opml` | `export`, `import` | |
| 174 |
| `queryFeeds` | `list`, `create`, `update`, `delete` | |
| 175 |
| `reader` | `extract` | |
| 176 |
| `actions` | `downloadAndOpen` | |
| 177 |
| `sync` | `testApiKey`, `saveApiKey`, `status`, `startAuth`, `completeAuth`, `disconnect`, `now`, `setupEncryptionNew`, `setupEncryptionExisting`, `updateSettings` | |
| 178 |
|
| 179 |
## Form Modal System |
| 180 |
|
| 181 |
`BB.ui.openFormModal()` builds a form from a field specification array and handles submit/cancel: |
| 182 |
|
| 183 |
```javascript |
| 184 |
BB.ui.openFormModal({ |
| 185 |
title: 'Add RSS Feed', |
| 186 |
fields: [ |
| 187 |
{ name: 'name', type: 'text', label: 'Feed Name', required: true }, |
| 188 |
{ name: 'url', type: 'text', label: 'URL', required: true, placeholder: 'https://...' }, |
| 189 |
], |
| 190 |
submitLabel: 'Add Feed', |
| 191 |
onSubmit: async (data) => { |
| 192 |
await BB.api.feeds.create({ busserId: 'rss', name: data.name, config: { url: data.url } }); |
| 193 |
BB.ui.showToast('Feed created!'); |
| 194 |
}, |
| 195 |
}); |
| 196 |
``` |
| 197 |
|
| 198 |
Supported field types: `text`, `textarea`, `select`, `secret` (renders as password), plus any HTML input type. |
| 199 |
|
| 200 |
The two-step add-feed flow in feeds.js uses this system: step 1 shows a plugin picker list, step 2 calls `BB.api.plugins.schema(pluginId)` to get the plugin's config fields and passes them to `openFormModal()`. |
| 201 |
|
| 202 |
## Load Order |
| 203 |
|
| 204 |
Scripts load in order via `<script>` tags in `index.html`: |
| 205 |
|
| 206 |
1. `bb.js` -- creates `window.BB` namespace with empty sub-objects |
| 207 |
2. `themes.js` -- populates `BB.themes` (init, load, buildSelector) |
| 208 |
3. `utils.js` -- populates `BB.utils` (escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage) |
| 209 |
4. `state.js` -- replaces `BB.state` with Proxy-wrapped reactive store |
| 210 |
5. `api.js` -- replaces `BB.api` with invoke wrappers |
| 211 |
6. `components.js` -- replaces `BB.ui` with toast/modal/form/confirm functions |
| 212 |
7. `query-feeds.js` -- populates `BB.queryFeeds` (depends on utils, state, api, ui) |
| 213 |
8. `sources.js` -- populates `BB.sources` (depends on utils, state, api, ui, queryFeeds) |
| 214 |
9. `items.js` -- populates `BB.items` (depends on utils, state, api, ui, detail) |
| 215 |
10. `detail.js` -- populates `BB.detail` (depends on utils, state, api, ui) |
| 216 |
11. `feeds.js` -- populates `BB.feeds` (depends on utils, state, api, ui, sources, items) |
| 217 |
12. `settings-sync.js` -- populates `BB.sync` (depends on api, ui) |
| 218 |
13. `updater.js` -- populates `BB.updater` (depends on ui, utils) |
| 219 |
14. `app.js` -- populates `BB.app`, auto-calls `init()` on DOMContentLoaded |
| 220 |
|
| 221 |
Note: items.js (9) references `BB.detail` at call time (not load time), so the forward reference to detail.js (10) resolves by the time `init()` runs. |
| 222 |
|
| 223 |
## Key Utility Functions |
| 224 |
|
| 225 |
```javascript |
| 226 |
BB.utils.escapeHtml(str) // XSS prevention for innerHTML |
| 227 |
BB.utils.escapeAttr(str) // Attribute-safe escaping |
| 228 |
BB.utils.sanitizeHtml(html) // DOM-based sanitizer for rich content (strips script, iframe, on* attrs, javascript: URLs) |
| 229 |
BB.utils.debounce(fn, delay) // Delay execution until after last call |
| 230 |
BB.utils.getErrorMessage(err) // Extract string from Tauri ApiError or any error shape |
| 231 |
``` |
| 232 |
|
| 233 |
`sanitizeHtml` is specific to BB (not present in GO). It uses a detached DOM element to parse HTML, then walks the tree removing dangerous elements (`script`, `iframe`, `object`, `embed`, `form`, `style`, `base`) and dangerous attributes (`on*` handlers, `javascript:`/`data:`/`vbscript:` URLs). Used by detail.js to render feed item bodies. |
| 234 |
|
| 235 |
## Tauri Event Listeners |
| 236 |
|
| 237 |
Several modules subscribe to Tauri events beyond IPC commands: |
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
| `fetch-progress` | feeds.js | Per-plugin progress during refresh | |
| 242 |
| `auto-fetch-complete` | app.js | Background auto-fetch finished, reload sources+items | |
| 243 |
| `auto-fetch-error` | app.js | Background fetch error, show toast | |
| 244 |
| `feed-circuit-broken` | app.js | Feed disabled after repeated failures, show retry toast | |
| 245 |
| `menu:refresh` | app.js | Native menu bar Refresh action | |
| 246 |
| `menu:add_feed` | app.js | Native menu bar Add Feed | |
| 247 |
| `menu:import_opml` | app.js | Native menu bar Import OPML | |
| 248 |
| `menu:export_opml` | app.js | Native menu bar Export OPML | |
| 249 |
| `menu:view_all` | app.js | Native menu bar View All | |
| 250 |
| `menu:view_unread` | app.js | Native menu bar View Unread | |
| 251 |
| `menu:view_starred` | app.js | Native menu bar View Starred | |
| 252 |
| `menu:check_updates` | updater.js | Native menu bar Check for Updates | |
| 253 |
| `update-available` | updater.js | OTA update detected by Rust backend | |
| 254 |
| `sync:changes-applied` | settings-sync.js | SyncKit pulled remote changes, reload sources+items | |
| 255 |
|
| 256 |
## CSS |
| 257 |
|
| 258 |
Single stylesheet at `frontend/css/styles.css`. Uses CSS variables set by themes.js at runtime. Theme colors come from shared TOML files in `MNW/shared/themes/` loaded via `get_theme` Tauri command. |
| 259 |
|
| 260 |
Theme-derived CSS variables: |
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
| `--bg-primary` | `background.primary` | |
| 265 |
| `--bg-secondary` | `background.secondary` | |
| 266 |
| `--bg-tertiary` | `background.tertiary` | |
| 267 |
| `--text-primary` | `foreground.primary` | |
| 268 |
| `--text-secondary` | `foreground.secondary` | |
| 269 |
| `--text-muted` | `foreground.muted` | |
| 270 |
| `--accent` | `accent.red` | |
| 271 |
| `--accent-hover` | `accent.red` lightened 10% | |
| 272 |
| `--yolk` | `accent.yellow` | |
| 273 |
| `--yolk-light` | `accent.yellow` lightened 15% | |
| 274 |
| `--border` | `border.default` | |
| 275 |
| `--border-dark` | `border.default` darkened 10% | |
| 276 |
| `--shadow` | `foreground.primary` at 8% opacity | |
| 277 |
| `--shadow-hover` | `foreground.primary` at 12% opacity | |
| 278 |
| `--success` | `accent.green` | |
| 279 |
|
| 280 |
## Key Paths |
| 281 |
|
| 282 |
|
| 283 |
|
| 284 |
| Namespace root | `src-tauri/frontend/js/bb.js` | |
| 285 |
| State manager | `src-tauri/frontend/js/state.js` | |
| 286 |
| API layer | `src-tauri/frontend/js/api.js` | |
| 287 |
| UI components | `src-tauri/frontend/js/components.js` | |
| 288 |
| Items list | `src-tauri/frontend/js/items.js` | |
| 289 |
| Detail panel | `src-tauri/frontend/js/detail.js` | |
| 290 |
| Sources sidebar | `src-tauri/frontend/js/sources.js` | |
| 291 |
| Feed management | `src-tauri/frontend/js/feeds.js` | |
| 292 |
| Query feeds | `src-tauri/frontend/js/query-feeds.js` | |
| 293 |
| Theme system | `src-tauri/frontend/js/themes.js` | |
| 294 |
| Sync settings | `src-tauri/frontend/js/settings-sync.js` | |
| 295 |
| OTA updater | `src-tauri/frontend/js/updater.js` | |
| 296 |
| App bootstrap | `src-tauri/frontend/js/app.js` | |
| 297 |
| Styles | `src-tauri/frontend/css/styles.css` | |
| 298 |
| Entry point | `src-tauri/frontend/index.html` | |
| 299 |
| JS tests | `src-tauri/frontend/js/tests/` | |
| 300 |
|