Skip to main content

max / balanced_breakfast

ui: replace empty-state emoji with monochrome SVG icons The empty states used emoji icons (egg, refresh, magnifier, bookmark, and a checkmark), violating the no-emoji brand rule. Ported GO's inline-SVG pattern: BB.ui.emptyStateIcon(key) returns a 24x24 stroke="currentColor" icon (feed / refresh / inbox / search / bookmark), theme-aware and monochrome. The "all caught up" state now uses the inbox icon (was a checkmark). Wrappers (.empty-icon / .source-item-empty-icon) size and center the SVG. Covers items.js (4), bookmarks.js (1), sources.js (1). 55 JS tests pass; lint clean; CSS parses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 20:01 UTC
Commit: a33b9d3664cb95f73d0b0fe958fe545c28ea9647
Parent: deb53b4
5 files changed, +43 insertions, -10 deletions
@@ -380,10 +380,12 @@ body {
380 380 }
381 381 .row--article.is-pinned { border-left-color: var(--action); }
382 382 .empty-icon {
383 - font-size: 2.5rem;
383 + display: flex;
384 + justify-content: center;
384 385 margin-bottom: 0.75rem;
385 - opacity: 0.6;
386 + color: var(--content-muted);
386 387 }
388 + .empty-icon svg { width: 2.5rem; height: 2.5rem; }
387 389
388 390 /* Standalone empty-state block. The items/bookmarks empty rows use
389 391 `<li class="empty-state">`; new surfaces use BB.ui.renderEmptyState().
@@ -997,10 +999,12 @@ body {
997 999 }
998 1000 .source-item--empty:hover { background-color: transparent; }
999 1001 .source-item-empty-icon {
1000 - font-size: 1.5rem;
1002 + display: flex;
1003 + justify-content: center;
1001 1004 margin-bottom: 0.5rem;
1002 - opacity: 0.5;
1005 + color: var(--content-muted);
1003 1006 }
1007 + .source-item-empty-icon svg { width: 1.5rem; height: 1.5rem; }
1004 1008 .source-item-empty-strong { color: var(--category-four); font-weight: 700; }
1005 1009 .source-item-empty-link { color: var(--category-four); }
1006 1010
@@ -42,7 +42,7 @@
42 42 const msg = currentTag
43 43 ? 'No bookmarks with tag "' + escapeHtml(currentTag) + '".'
44 44 : 'Your reading list is empty.<br>Bookmark articles from the detail panel, or add a URL.';
45 - list.innerHTML = '<li class="empty-state"><div class="empty-icon">&#x1F516;</div>' + msg + '</li>';
45 + list.innerHTML = '<li class="empty-state"><div class="empty-icon">' + BB.ui.emptyStateIcon('bookmark') + '</div>' + msg + '</li>';
46 46 renderTagBar([]);
47 47 return;
48 48 }
@@ -253,6 +253,33 @@
253 253 * @param {function} [opts.onClick] - Click handler for the CTA button.
254 254 * @param {boolean} [opts.compact=false] - Use the compact size variant.
255 255 */
256 + /**
257 + * Inline monochrome SVG icons for empty states (theme-aware via
258 + * currentColor; no emoji, per the brand rule). 24x24 stroke paths.
259 + */
260 + const EMPTY_STATE_ICONS = {
261 + feed: '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.5" fill="currentColor" stroke="none"/>',
262 + refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 4v5h-5"/>',
263 + inbox: '<path d="M3 13l3-8h12l3 8"/><path d="M3 13h5l1.5 3h5L16 13h5v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
264 + search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
265 + bookmark: '<path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1z"/>',
266 + };
267 +
268 + /**
269 + * Return an inline SVG string for an empty-state icon, or '' for an unknown
270 + * key. Wrap the result in a sizing element (`.empty-icon` /
271 + * `.source-item-empty-icon`) at the call site.
272 + * @param {string} key - One of the EMPTY_STATE_ICONS keys.
273 + * @returns {string}
274 + */
275 + function emptyStateIcon(key) {
276 + const paths = EMPTY_STATE_ICONS[key];
277 + if (!paths) return '';
278 + return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
279 + + 'stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" '
280 + + 'aria-hidden="true">' + paths + '</svg>';
281 + }
282 +
256 283 function renderEmptyState(container, message, opts) {
257 284 opts = opts || {};
258 285 container.innerHTML = '';
@@ -645,6 +672,7 @@
645 672 confirmAction, // legacy — prefer showConfirmDialog for new code
646 673 showConfirmDialog,
647 674 renderEmptyState,
675 + emptyStateIcon,
648 676 showContextMenu,
649 677 showUndoToast,
650 678 bulkActionWithUndo,
@@ -100,14 +100,15 @@
100 100 const hasFeeds = BB.state.sources && BB.state.sources.length > 0;
101 101 const totalItems = hasFeeds ? BB.state.sources.reduce((n, s) => n + s.totalCount, 0) : 0;
102 102 let message;
103 + const icon = (key) => '<div class="empty-icon">' + BB.ui.emptyStateIcon(key) + '</div>';
103 104 if (!hasFeeds) {
104 - message = '<div class="empty-icon">&#x1F373;</div>No feeds yet.<br>Click <strong>+ Add Feed</strong> to get started, or import an OPML file.<br><kbd>?</kbd> for keyboard shortcuts.';
105 + message = icon('feed') + 'No feeds yet.<br>Click <strong>+ Add Feed</strong> to get started, or import an OPML file.<br><kbd>?</kbd> for keyboard shortcuts.';
105 106 } else if (totalItems === 0) {
106 - message = '<div class="empty-icon">&#x1F504;</div>Feeds added but no articles yet.<br>Click <strong>Refresh</strong> to fetch posts.';
107 + message = icon('refresh') + 'Feeds added but no articles yet.<br>Click <strong>Refresh</strong> to fetch posts.';
107 108 } else if (BB.state.unreadOnly) {
108 - message = '<div class="empty-icon">&#x2713;</div>All caught up! No unread items.';
109 + message = icon('inbox') + 'All caught up! No unread items.';
109 110 } else {
110 - message = '<div class="empty-icon">&#x1F50D;</div>No items match the current filter.<br>Try switching to "All" or a different source.';
111 + message = icon('search') + 'No items match the current filter.<br>Try switching to "All" or a different source.';
111 112 }
112 113 list.innerHTML = '<li class="empty-state">' + message + '</li>';
113 114 document.getElementById('load-more').style.display = 'none';
@@ -165,7 +165,7 @@
165 165 if (sources.length === 0) {
166 166 const emptyLi = document.createElement('li');
167 167 emptyLi.className = 'source-item source-item--empty';
168 - emptyLi.innerHTML = '<div class="source-item-empty-icon">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong class="source-item-empty-strong">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" class="source-item-empty-link">import an OPML file</a>.';
168 + emptyLi.innerHTML = '<div class="source-item-empty-icon">' + BB.ui.emptyStateIcon('feed') + '</div>Add your first feed to get started.<br>Click <strong class="source-item-empty-strong">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" class="source-item-empty-link">import an OPML file</a>.';
169 169 frag.appendChild(emptyLi);
170 170 }
171 171