Skip to main content

max / balanced_breakfast

css: F5 phase B — restyle article rows (items + bookmarks) onto renderRow The .item twins now use the renderRow .row layout with a .row--article modifier for appearance, replacing the legacy .item-* layout classes. This merges the .item collapse the CSS earmarked for Phase C, since a parity-only wrap would have neutralized .row's layout and added no value. - renderRow: rows use the icon / header / content / actions scaffold; the control stack (star or pin + read dot) is the icon slot, the author/time meta line is the new header slot, the title is primary, tags are secondary, and bookmark Open/more are the actions slot (hover-reveal for free). - CSS: .item{,.unread,.read,.selected}, .item-indicators/-content/-header/ -author/-time/-text/-secondary, .item-source-badge, .bookmark-item.pinned, and .bookmark-actions are gone, restyled as .row--article + is-unread/ is-read/is-selected/is-pinned, .article-controls/-meta/-author/-time/ -source-badge, and .row-primary/.row-secondary overrides. Focus-visible, mobile padding, and touch-hover overrides re-pointed at .row--article. - Rows are XSS-safe by construction (element building, no innerHTML templates). Empty rows use the standalone .empty-state block. - mobile.js swipe + long-press row selector .item -> .row--article. 55 JS tests pass; CSS parses clean; lint unchanged (28 pre-existing token flags). Needs a visual pass on the feed + reading-list rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 18:12 UTC
Commit: d521de691daf3623318a4a4daf555c3d9cfef86b
Parent: 8e80d0a
4 files changed, +168 insertions, -128 deletions
@@ -686,46 +686,39 @@ body {
686 686
687 687 .items-list { list-style: none; }
688 688
689 - .item {
690 - display: flex;
689 + /* Article rows: feed items + reading-list bookmarks. Uses the renderRow .row
690 + layout (icon / content / actions, flex + gap) plus article appearance +
691 + read/unread/selected/pinned state. Replaces the legacy .item-* layout. */
692 + .row--article {
691 693 padding: 0.75rem 1.25rem;
692 694 border-bottom: 1px solid var(--border);
693 695 border-left: 3px solid transparent;
694 696 cursor: pointer;
695 697 transition: background-color 0.15s;
696 698 }
697 - .item:hover { background-color: var(--surface-overlay); }
698 - .item.unread {
699 + .row--article:hover { background-color: var(--surface-overlay); }
700 + .row--article.is-unread {
699 701 border-left-color: var(--danger);
700 702 background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page));
701 703 }
702 - .item.read { color: var(--content-secondary); }
703 - .item.selected {
704 + .row--article.is-read { color: var(--content-secondary); }
705 + .row--article.is-selected {
704 706 background-color: var(--surface-sunken);
705 707 border-left-color: var(--category-four);
706 708 }
707 - .item.unread.selected {
709 + .row--article.is-unread.is-selected {
708 710 border-left-color: var(--danger);
709 711 background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page));
710 712 }
711 - .item.empty-state {
712 - text-align: center;
713 - color: var(--content-muted);
714 - padding: 3rem 2rem;
715 - cursor: default;
716 - line-height: 1.6;
717 - flex-direction: column;
718 - align-items: center;
719 - }
713 + .row--article.is-pinned { border-left-color: var(--action); }
720 714 .empty-icon {
721 715 font-size: 2.5rem;
722 716 margin-bottom: 0.75rem;
723 717 opacity: 0.6;
724 718 }
725 719
726 - /* F2 addition (2026-06-02) — .empty-state as its own block. Today's
727 - .item.empty-state (modifier-on-row) stays as legacy until F5 retires
728 - call sites. Use this block + BB.ui.renderEmptyState() for new surfaces.
720 + /* Standalone empty-state block. The items/bookmarks empty rows use
721 + `<li class="empty-state">`; new surfaces use BB.ui.renderEmptyState().
729 722 <div class="empty-state">
730 723 <div class="empty-state-icon">...</div>
731 724 <p class="empty-state-text">No items</p>
@@ -758,11 +751,12 @@ body {
758 751 }
759 752 .empty-state--compact .empty-state-icon { font-size: 1.5rem; }
760 753
761 - .item-indicators {
754 + /* Control stack (star / pin + read dot) passed into renderRow's icon slot.
755 + The .row layout provides the gap to the content; no margin needed here. */
756 + .article-controls {
762 757 display: flex;
763 758 flex-direction: column;
764 759 align-items: center;
765 - margin-right: 0.75rem;
766 760 min-width: 24px;
767 761 }
768 762
@@ -788,38 +782,39 @@ body {
788 782 }
789 783 .read-indicator.unread { background-color: var(--danger); }
790 784
791 - .item-content { flex: 1; min-width: 0; }
785 + /* Content column is renderRow's .row-content (flex:1; min-width:0). */
792 786
793 - .item-header {
787 + /* Header slot holds the author/time meta line (+ mobile source badge). */
788 + .article-meta {
794 789 display: flex;
795 790 justify-content: space-between;
796 791 align-items: flex-start;
797 792 margin-bottom: 0.15rem;
798 793 }
799 -
800 - .item-author {
794 + .article-author {
801 795 font-weight: 700;
802 796 color: var(--danger);
803 - font-size: 0.8rem;
797 + font-size: var(--font-size-md);
804 798 }
799 + .article-time { color: var(--content-muted); font-size: var(--font-size-sm); }
805 800
806 - .item-time { color: var(--content-muted); font-size: 0.7rem; }
807 -
808 - .item-text {
809 - color: var(--content);
801 + /* Title: two-line clamp (overrides .row-primary's single flex line). */
802 + .row--article .row-primary {
810 803 display: -webkit-box;
811 804 -webkit-line-clamp: 2;
812 805 -webkit-box-orient: vertical;
813 806 overflow: hidden;
807 + font-weight: 400;
808 + color: var(--content);
814 809 line-height: 1.4;
815 - font-size: 0.875rem;
810 + font-size: var(--font-size-base);
816 811 }
817 -
818 - .item-secondary {
812 + .row--article .row-secondary {
819 813 color: var(--content-secondary);
820 - font-size: 0.8rem;
814 + font-size: var(--font-size-md);
821 815 margin-top: 0.15rem;
822 816 }
817 + /* .article-source-badge (mobile-only) lives in section 38. */
823 818
824 819 .load-more { padding: 1rem; text-align: center; }
825 820
@@ -1430,7 +1425,7 @@ body {
1430 1425 .source-item:focus-visible,
1431 1426 .star-btn:focus-visible,
1432 1427 .source-delete:focus-visible,
1433 - .item:focus-visible,
1428 + .row--article:focus-visible,
1434 1429 .tab:focus-visible,
1435 1430 .plugin-item:focus-visible,
1436 1431 .sort-select:focus-visible,
@@ -1779,7 +1774,7 @@ body {
1779 1774 /* === 38. Source-badge on items (mobile-only display) =================
1780 1775 Hidden on desktop; shown inside the @media (max-width: 768px) block below.
1781 1776 ========================================================================== */
1782 - .item-source-badge { display: none; }
1777 + .article-source-badge { display: none; }
1783 1778
1784 1779 /* === 39. @media (max-width: 768px) — mobile layout ==================== */
1785 1780 @media (max-width: 768px) {
@@ -1834,7 +1829,7 @@ body {
1834 1829 .mobile-search-bar { display: flex; }
1835 1830
1836 1831 /* Larger tap targets */
1837 - .item { padding: 0.875rem 1rem; }
1832 + .row--article { padding: 0.875rem 1rem; }
1838 1833 .star-btn { font-size: 1.3rem; padding: 0.4rem 0.5rem; margin: -0.4rem -0.5rem; }
1839 1834 .source-item { min-height: 3rem; }
1840 1835
@@ -1870,7 +1865,7 @@ body {
1870 1865 }
1871 1866
1872 1867 /* Source badge on items */
1873 - .item-source-badge {
1868 + .article-source-badge {
1874 1869 display: inline-block;
1875 1870 background-color: var(--surface-sunken);
1876 1871 color: var(--content-secondary);
@@ -1882,7 +1877,7 @@ body {
1882 1877 }
1883 1878
1884 1879 /* Bold unread item titles */
1885 - .item.unread .item-text { font-weight: 700; }
1880 + .row--article.is-unread .row-primary { font-weight: 700; }
1886 1881
1887 1882 /* Sticky detail header */
1888 1883 .detail-header {
@@ -1909,10 +1904,10 @@ body {
1909 1904
1910 1905 /* === 40. @media (hover: none) — touch overrides ====================== */
1911 1906 @media (hover: none) {
1912 - .item:hover { background-color: transparent; }
1913 - .item.unread:hover { background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page)); }
1914 - .item.selected:hover { background-color: var(--surface-sunken); }
1915 - .item.unread.selected:hover { background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page)); }
1907 + .row--article:hover { background-color: transparent; }
1908 + .row--article.is-unread:hover { background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page)); }
1909 + .row--article.is-selected:hover { background-color: var(--surface-sunken); }
1910 + .row--article.is-unread.is-selected:hover { background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page)); }
1916 1911 .source-item:hover { background-color: transparent; }
1917 1912 .source-item.active:hover { background-color: var(--surface-sunken); }
1918 1913 .sidebar-saved:hover { background-color: transparent; }
@@ -2133,22 +2128,8 @@ body {
2133 2128 color: white;
2134 2129 border-color: var(--action);
2135 2130 }
2136 - .bookmark-item .bookmark-actions {
2137 - display: flex;
2138 - gap: 0.25rem;
2139 - align-items: center;
2140 - margin-left: auto;
2141 - flex-shrink: 0;
2142 - opacity: 0;
2143 - transition: opacity 0.15s;
2144 - }
2145 - .bookmark-item:hover .bookmark-actions,
2146 - .bookmark-item:focus-within .bookmark-actions {
2147 - opacity: 1;
2148 - }
2149 - .bookmark-item.pinned {
2150 - border-left: 3px solid var(--action);
2151 - }
2131 + /* Bookmark Open/more actions use renderRow's .row-actions (hover-revealed);
2132 + pinned state uses .row--article.is-pinned. */
2152 2133 .bookmark-tags {
2153 2134 display: flex;
2154 2135 gap: 0.25rem;
@@ -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="item empty-state"><div class="empty-icon">&#x1F516;</div>' + msg + '</li>';
45 + list.innerHTML = '<li class="empty-state"><div class="empty-icon">&#x1F516;</div>' + msg + '</li>';
46 46 renderTagBar([]);
47 47 return;
48 48 }
@@ -53,43 +53,75 @@
53 53 const frag = document.createDocumentFragment();
54 54
55 55 for (const bm of bookmarks) {
56 - const li = document.createElement('li');
57 - li.className = 'item bookmark-item' + (bm.isPinned ? ' pinned' : '');
58 - li.dataset.id = bm.id;
59 - li.setAttribute('role', 'option');
60 - li.setAttribute('tabindex', '0');
61 -
62 - const tags = bm.tags.length > 0
63 - ? '<div class="bookmark-tags">' + bm.tags.map(t =>
64 - '<span class="tag bookmark-tag" onclick="event.stopPropagation(); BB.bookmarks.load(\'' + escapeAttr(t) + '\')">'
65 - + escapeHtml(t) + '</span>'
66 - ).join('') + '</div>'
67 - : '';
68 -
69 - li.innerHTML = `
70 - <div class="item-indicators">
71 - <button class="star-btn${bm.isPinned ? ' starred' : ''}"
72 - aria-label="Toggle pin"
73 - onclick="event.stopPropagation(); BB.bookmarks.togglePin('${escapeAttr(bm.id)}', ${bm.isPinned})">
74 - ${bm.isPinned ? '\u{1F4CC}' : '\u{1F4CC}'}
75 - </button>
76 - </div>
77 - <div class="item-content">
78 - <div class="item-header">
79 - ${bm.author ? '<span class="item-author">' + escapeHtml(bm.author) + '</span>' : ''}
80 - <span class="item-time">${escapeHtml(bm.timeAgo)}</span>
81 - </div>
82 - ${bm.sourceName ? '<span class="item-source-badge">' + escapeHtml(bm.sourceName) + '</span>' : ''}
83 - <div class="item-text">${escapeHtml(bm.title || bm.url)}</div>
84 - ${tags}
85 - </div>
86 - <div class="bookmark-actions">
87 - <button class="btn btn-small" onclick="event.stopPropagation(); BB.bookmarks.openUrl('${escapeAttr(bm.url)}')" title="Open URL">Open</button>
88 - <button class="btn btn-small" onclick="event.stopPropagation(); BB.bookmarks.showContextMenu(event, '${escapeAttr(bm.id)}')" title="More actions">\u22EF</button>
89 - </div>
90 - `;
56 + // Control stack: pin toggle (renderRow icon slot).
57 + const controls = document.createElement('div');
58 + controls.className = 'article-controls';
59 + const pin = document.createElement('button');
60 + pin.className = 'star-btn' + (bm.isPinned ? ' starred' : '');
61 + pin.setAttribute('aria-label', 'Toggle pin');
62 + pin.textContent = '\u{1F4CC}';
63 + pin.onclick = (e) => { e.stopPropagation(); togglePin(bm.id, bm.isPinned); };
64 + controls.appendChild(pin);
65 +
66 + // Header slot: optional author + time meta line + mobile source badge.
67 + const header = document.createDocumentFragment();
68 + const meta = document.createElement('div');
69 + meta.className = 'article-meta';
70 + if (bm.author) {
71 + const author = document.createElement('span');
72 + author.className = 'article-author';
73 + author.textContent = bm.author;
74 + meta.appendChild(author);
75 + }
76 + const time = document.createElement('span');
77 + time.className = 'article-time';
78 + time.textContent = bm.timeAgo;
79 + meta.appendChild(time);
80 + header.appendChild(meta);
81 + if (bm.sourceName) {
82 + const badge = document.createElement('span');
83 + badge.className = 'article-source-badge';
84 + badge.textContent = bm.sourceName;
85 + header.appendChild(badge);
86 + }
91 87
92 - li.onclick = () => openUrl(bm.url);
88 + // Secondary slot: tag chips.
89 + let secondary = null;
90 + if (bm.tags.length > 0) {
91 + secondary = document.createElement('div');
92 + secondary.className = 'bookmark-tags';
93 + for (const t of bm.tags) {
94 + const chip = document.createElement('span');
95 + chip.className = 'tag bookmark-tag';
96 + chip.textContent = t;
97 + chip.onclick = (e) => { e.stopPropagation(); load(t); };
98 + secondary.appendChild(chip);
99 + }
100 + }
101 +
102 + // Actions slot: Open + more (hover-revealed via .row-actions).
103 + const openBtn = document.createElement('button');
104 + openBtn.className = 'btn btn-small';
105 + openBtn.title = 'Open URL';
106 + openBtn.textContent = 'Open';
107 + openBtn.onclick = (e) => { e.stopPropagation(); openUrl(bm.url); };
108 + const moreBtn = document.createElement('button');
109 + moreBtn.className = 'btn btn-small';
110 + moreBtn.title = 'More actions';
111 + moreBtn.textContent = '\u22EF';
112 + moreBtn.onclick = (e) => { e.stopPropagation(); showContextMenu(e, bm.id); };
113 +
114 + const li = BB.ui.renderRow({
115 + tag: 'li',
116 + className: 'row--article' + (bm.isPinned ? ' is-pinned' : ''),
117 + icon: controls,
118 + header: header,
119 + primary: bm.title || bm.url,
120 + secondary: secondary,
121 + actions: [openBtn, moreBtn],
122 + attrs: { role: 'option', tabindex: '0', 'data-id': bm.id },
123 + onClick: () => openUrl(bm.url),
124 + });
93 125 li.onkeydown = (e) => {
94 126 if (e.key === 'Enter' || e.key === ' ') {
95 127 e.preventDefault();
@@ -7,7 +7,7 @@
7 7 (function() {
8 8 'use strict';
9 9
10 - const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils;
10 + const { getErrorMessage } = BB.utils;
11 11
12 12 /** Set of item IDs with in-flight star/read toggles to prevent double-clicks. */
13 13 const inFlight = new Set();
@@ -109,7 +109,7 @@
109 109 } else {
110 110 message = '<div class="empty-icon">&#x1F50D;</div>No items match the current filter.<br>Try switching to "All" or a different source.';
111 111 }
112 - list.innerHTML = '<li class="item empty-state">' + message + '</li>';
112 + list.innerHTML = '<li class="empty-state">' + message + '</li>';
113 113 document.getElementById('load-more').style.display = 'none';
114 114 return;
115 115 }
@@ -118,36 +118,63 @@
118 118 const frag = document.createDocumentFragment();
119 119
120 120 items.forEach(item => {
121 - const li = document.createElement('li');
122 - li.className = 'item'
123 - + (item.isRead ? ' read' : ' unread')
124 - + (selected === item.id ? ' selected' : '');
125 - li.dataset.id = item.id;
126 - li.setAttribute('role', 'option');
127 - li.setAttribute('aria-selected', selected === item.id ? 'true' : 'false');
121 + const isSelected = selected === item.id;
128 122
129 - li.innerHTML = `
130 - <div class="item-indicators">
131 - <button class="star-btn${item.isStarred ? ' starred' : ''}"
132 - aria-label="Toggle star"
133 - onclick="event.stopPropagation(); BB.items.toggleStar('${escapeAttr(item.id)}', ${item.isStarred})">
134 - ${item.isStarred ? '\u2605' : '\u2606'}
135 - </button>
136 - <div class="read-indicator${item.isRead ? '' : ' unread'}"></div>
137 - </div>
138 - <div class="item-content">
139 - <div class="item-header">
140 - <span class="item-author">${escapeHtml(item.author)}</span>
141 - <span class="item-time">${escapeHtml(item.timeAgo)}</span>
142 - </div>
143 - <span class="item-source-badge">${escapeHtml(item.sourceName)}</span>
144 - <div class="item-text">${escapeHtml(item.title || item.text)}</div>
145 - ${item.secondary ? `<div class="item-secondary">${escapeHtml(item.secondary)}</div>` : ''}
146 - </div>
147 - `;
123 + // Control stack: star + read-indicator (renderRow icon slot).
124 + const controls = document.createElement('div');
125 + controls.className = 'article-controls';
126 + const star = document.createElement('button');
127 + star.className = 'star-btn' + (item.isStarred ? ' starred' : '');
128 + star.setAttribute('aria-label', 'Toggle star');
129 + star.textContent = item.isStarred ? '\u2605' : '\u2606';
130 + star.onclick = (e) => { e.stopPropagation(); toggleStar(item.id, item.isStarred); };
131 + const readDot = document.createElement('div');
132 + readDot.className = 'read-indicator' + (item.isRead ? '' : ' unread');
133 + controls.appendChild(star);
134 + controls.appendChild(readDot);
148 135
149 - li.setAttribute('tabindex', '0');
150 - li.onclick = () => selectItem(item.id);
136 + // Header slot: author/time meta line + mobile source badge.
137 + const header = document.createDocumentFragment();
138 + const meta = document.createElement('div');
139 + meta.className = 'article-meta';
140 + const author = document.createElement('span');
141 + author.className = 'article-author';
142 + author.textContent = item.author;
143 + const time = document.createElement('span');
144 + time.className = 'article-time';
145 + time.textContent = item.timeAgo;
146 + meta.appendChild(author);
147 + meta.appendChild(time);
148 + header.appendChild(meta);
149 + if (item.sourceName) {
150 + const badge = document.createElement('span');
151 + badge.className = 'article-source-badge';
152 + badge.textContent = item.sourceName;
153 + header.appendChild(badge);
154 + }
155 +
156 + let secondary = null;
157 + if (item.secondary) {
158 + secondary = document.createElement('div');
159 + secondary.textContent = item.secondary;
160 + }
161 +
162 + const li = BB.ui.renderRow({
163 + tag: 'li',
164 + className: 'row--article ' + (item.isRead ? 'is-read' : 'is-unread')
165 + + (isSelected ? ' is-selected' : ''),
166 + icon: controls,
167 + header: header,
168 + primary: item.title || item.text,
169 + secondary: secondary,
170 + attrs: {
171 + role: 'option',
172 + 'aria-selected': isSelected ? 'true' : 'false',
173 + tabindex: '0',
174 + 'data-id': item.id,
175 + },
176 + onClick: () => selectItem(item.id),
177 + });
151 178 li.ondblclick = (e) => {
152 179 e.preventDefault();
153 180 expandReaderView(item.id);
@@ -187,7 +187,7 @@
187 187 if (!container) return;
188 188
189 189 addSwipeDelegate(container, {
190 - rowSelector: '.item',
190 + rowSelector: '.row--article',
191 191 getActions: (row) => {
192 192 const id = row.dataset.id;
193 193 if (!id) return null;
@@ -216,7 +216,7 @@
216 216 const container = document.getElementById('items-list');
217 217 if (!container) return;
218 218
219 - addLongPressDelegate(container, '.item', (row) => {
219 + addLongPressDelegate(container, '.row--article', (row) => {
220 220 const id = row.dataset.id;
221 221 const item = BB.state.items.find(i => i.id === id);
222 222 if (!item) return;