Skip to main content

max / balanced_breakfast

css: F5 phase B — route sources sidebar rows through BB.ui.renderRow First real proof of the renderRow primitive beyond feeds.js. The three slot-shaped source rows (All, per-source, query-feed) now build via BB.ui.renderRow with element slots instead of innerHTML templates: - health dot -> icon slot; name -> primary; tag chips -> secondary; the count + gear/delete cluster -> meta slot (kept as .source-actions). - Passing the always-visible cluster as `meta` (not the hover-reveal `actions` slot) preserves current behavior with no renderRow change; delete's hover-reveal still works via the retained .source-item class. - Rows are now XSS-safe by construction (textContent, no escapeHtml templating). Empty-state / divider / "+ Saved Filter" stay plain <li> (not data rows). No DnD exists for sources (plan assumption was wrong). Test harness: the mock DOM stored innerHTML as an opaque string that appendChild never updated, so element-built rows were uninspectable. Gave it an innerHTML getter that serializes children + attributes, and loaded the real components.js so tests exercise the actual renderRow (showToast/ openFormModal remain no-ops). 55 JS tests pass; lint unchanged (28 pre-existing theme-token flags). Needs a visual pass on row spacing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 17:42 UTC
Commit: 613d74ac0f98c544e55cc241b9273d826208bb8a
Parent: 2f0339b
2 files changed, +174 insertions, -102 deletions
@@ -9,6 +9,91 @@
9 9
10 10 const { escapeHtml, getErrorMessage } = BB.utils;
11 11
12 + /** Human-readable health status labels (aria-label / tooltip). */
13 + const healthLabels = {
14 + yellow: 'Feed has warnings',
15 + red: 'Feed has errors',
16 + circuit_broken: 'Feed disabled by circuit breaker',
17 + auth_error: 'Feed authentication failed',
18 + config_error: 'Feed configuration error',
19 + rate_limited: 'Feed rate-limited; will retry',
20 + };
21 +
22 + // ── Row-part builders (fed into BB.ui.renderRow slots) ──
23 + // Building elements (not innerHTML templates) keeps rows XSS-safe by
24 + // construction and routes every sidebar row through the shared primitive.
25 +
26 + /** Source/query name as a `.source-name` span (primary slot). */
27 + function nameEl(name) {
28 + const el = document.createElement('span');
29 + el.className = 'source-name';
30 + el.textContent = name;
31 + return el;
32 + }
33 +
34 + /** Unread/total count pill as a `.source-count` span. */
35 + function countEl(text, allRead) {
36 + const el = document.createElement('span');
37 + el.className = 'source-count' + (allRead ? ' all-read' : '');
38 + el.textContent = text;
39 + return el;
40 + }
41 +
42 + /** A `.source-edit`/`.source-delete` icon button with a static glyph. */
43 + function iconBtn(cls, glyph, title, onClick) {
44 + const b = document.createElement('button');
45 + b.className = cls;
46 + b.title = title;
47 + b.setAttribute('aria-label', title);
48 + b.innerHTML = glyph; // static HTML entity (gear / times) — safe
49 + b.onclick = onClick;
50 + return b;
51 + }
52 +
53 + /** Wrap trailing controls in a `.source-actions` cluster (meta slot). */
54 + function actionsEl(children) {
55 + const wrap = document.createElement('span');
56 + wrap.className = 'source-actions';
57 + children.forEach(c => c && wrap.appendChild(c));
58 + return wrap;
59 + }
60 +
61 + /** Leading `.health-dot` for a non-green feed, or null. */
62 + function healthDotEl(source) {
63 + if (!source.health || source.health === 'green') return null;
64 + const dot = document.createElement('span');
65 + dot.className = 'health-dot';
66 + dot.setAttribute('data-health', source.health);
67 + // F3: state-by-color paired with shape via [data-health] CSS + aria-label.
68 + dot.setAttribute('aria-label', source.lastError || healthLabels[source.health] || 'Feed status');
69 + dot.title = source.lastError || healthLabels[source.health] || 'Feed has errors';
70 + return dot;
71 + }
72 +
73 + /** Tag chips as a `.source-tags` block (secondary slot), or null. */
74 + function tagsEl(tags) {
75 + if (!tags || tags.length === 0) return null;
76 + const wrap = document.createElement('div');
77 + wrap.className = 'source-tags';
78 + tags.forEach(t => {
79 + const chip = document.createElement('span');
80 + chip.className = 'source-tag-chip';
81 + chip.textContent = t;
82 + wrap.appendChild(chip);
83 + });
84 + return wrap;
85 + }
86 +
87 + /** Attach Enter/Space activation to a rendered row. */
88 + function wireRowKeys(li, activate) {
89 + li.onkeydown = (e) => {
90 + if (e.key === 'Enter' || e.key === ' ') {
91 + e.preventDefault();
92 + activate();
93 + }
94 + };
95 + }
96 +
12 97 /**
13 98 * Fetch the source list, all tags, and query feeds from the backend.
14 99 * @returns {Promise<void>}
@@ -56,20 +141,20 @@
56 141 // to avoid the flash of empty content.
57 142 const frag = document.createDocumentFragment();
58 143
59 - const allLi = document.createElement('li');
60 - allLi.className = 'source-item' + (current === '' ? ' active' : '');
61 - allLi.dataset.source = '';
62 - allLi.setAttribute('role', 'option');
63 - allLi.setAttribute('aria-selected', current === '' ? 'true' : 'false');
64 - allLi.setAttribute('tabindex', '0');
65 - allLi.innerHTML = '<span class="source-name">All</span><span class="source-count' + allReadClass + '">' + allCountText + '</span>';
66 - allLi.addEventListener('click', () => BB.sources.select(''));
67 - allLi.addEventListener('keydown', (e) => {
68 - if (e.key === 'Enter' || e.key === ' ') {
69 - e.preventDefault();
70 - BB.sources.select('');
71 - }
144 + const allLi = BB.ui.renderRow({
145 + tag: 'li',
146 + className: 'source-item' + (current === '' ? ' active' : ''),
147 + primary: nameEl('All'),
148 + meta: countEl(allCountText, allReadClass !== ''),
149 + attrs: {
150 + role: 'option',
151 + 'aria-selected': current === '' ? 'true' : 'false',
152 + tabindex: '0',
153 + 'data-source': '',
154 + },
155 + onClick: () => BB.sources.select(''),
72 156 });
157 + wireRowKeys(allLi, () => BB.sources.select(''));
73 158 frag.appendChild(allLi);
74 159
75 160 // Empty-state onboarding when no feeds exist.
@@ -84,61 +169,34 @@
84 169 }
85 170
86 171 sources.forEach(source => {
87 - const li = document.createElement('li');
88 - li.className = 'source-item' + (current === source.id ? ' active' : '');
89 - li.dataset.source = source.id;
90 - li.setAttribute('role', 'option');
91 - li.setAttribute('aria-selected', current === source.id ? 'true' : 'false');
92 - li.setAttribute('tabindex', '0');
93 - li.onclick = () => BB.sources.select(source.id);
94 - li.onkeydown = (e) => {
95 - if (e.key === 'Enter' || e.key === ' ') {
96 - e.preventDefault();
97 - BB.sources.select(source.id);
98 - }
99 - };
100 -
101 - const srcReadClass = source.unreadCount === 0 && source.totalCount > 0 ? ' all-read' : '';
172 + const activate = () => BB.sources.select(source.id);
173 + const srcReadClass = source.unreadCount === 0 && source.totalCount > 0;
102 174 const countText = source.unreadCount > 0
103 175 ? `${source.unreadCount}/${source.totalCount}`
104 176 : source.totalCount > 0 ? `\u2713 ${source.totalCount}` : `${source.totalCount}`;
105 177
106 - const healthLabels = {
107 - yellow: 'Feed has warnings',
108 - red: 'Feed has errors',
109 - circuit_broken: 'Feed disabled by circuit breaker',
110 - auth_error: 'Feed authentication failed',
111 - config_error: 'Feed configuration error',
112 - rate_limited: 'Feed rate-limited; will retry',
113 - };
114 - const healthDot = source.health && source.health !== 'green'
115 - // F3: state-by-color paired with shape via [data-health] CSS
116 - // rules + aria-label for screen readers. F5: dropped the
117 - // legacy `health-${health}` class (now handled by [data-health]).
118 - ? `<span class="health-dot" data-health="${escapeHtml(source.health)}" aria-label="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed status')}" title="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed has errors')}"></span>`
119 - : '';
120 -
121 - const tagChips = (source.tags || [])
122 - .map(t => `<span class="source-tag-chip">${escapeHtml(t)}</span>`)
123 - .join('');
124 -
125 - li.innerHTML = `
126 - ${healthDot}
127 - <div class="source-info">
128 - <span class="source-name">${escapeHtml(source.name)}</span>
129 - ${tagChips ? `<div class="source-tags">${tagChips}</div>` : ''}
130 - </div>
131 - <span class="source-actions">
132 - <span class="source-count${srcReadClass}">${countText}</span>
133 - <button class="source-edit" title="Feed settings" aria-label="Feed settings">&#9881;</button>
134 - </span>
135 - `;
136 -
137 - li.querySelector('.source-edit').onclick = (e) => {
138 - e.stopPropagation();
139 - showFeedPopover(source, e.currentTarget);
140 - };
141 -
178 + const li = BB.ui.renderRow({
179 + tag: 'li',
180 + className: 'source-item' + (current === source.id ? ' active' : ''),
181 + icon: healthDotEl(source),
182 + primary: nameEl(source.name),
183 + secondary: tagsEl(source.tags),
184 + meta: actionsEl([
185 + countEl(countText, srcReadClass),
186 + iconBtn('source-edit', '&#9881;', 'Feed settings', (e) => {
187 + e.stopPropagation();
188 + showFeedPopover(source, e.currentTarget);
189 + }),
190 + ]),
191 + attrs: {
192 + role: 'option',
193 + 'aria-selected': current === source.id ? 'true' : 'false',
194 + tabindex: '0',
195 + 'data-source': source.id,
196 + },
197 + onClick: activate,
198 + });
199 + wireRowKeys(li, activate);
142 200 frag.appendChild(li);
143 201 });
144 202
@@ -152,40 +210,30 @@
152 210
153 211 const currentQF = BB.state.currentQueryFeed;
154 212 queryFeeds.forEach(qf => {
155 - const li = document.createElement('li');
156 - li.className = 'source-item query-feed' + (currentQF === qf.id ? ' active' : '');
157 - li.setAttribute('role', 'option');
158 - li.setAttribute('aria-selected', currentQF === qf.id ? 'true' : 'false');
159 - li.setAttribute('tabindex', '0');
160 - li.onclick = () => BB.queryFeeds.select(qf.id);
161 - li.onkeydown = (e) => {
162 - if (e.key === 'Enter' || e.key === ' ') {
163 - e.preventDefault();
164 - BB.queryFeeds.select(qf.id);
165 - }
166 - };
167 -
168 - li.innerHTML = `
169 - <div class="source-info">
170 - <span class="source-name">${escapeHtml(qf.name)}</span>
171 - </div>
172 - <span class="source-actions">
173 - <span class="source-count">${escapeHtml(String(qf.matchCount))}</span>
174 - <button class="source-edit" title="Edit query feed" aria-label="Edit query feed">&#9881;</button>
175 - <button class="source-delete" title="Delete query feed" aria-label="Delete query feed">&times;</button>
176 - </span>
177 - `;
178 -
179 - li.querySelector('.source-edit').onclick = (e) => {
180 - e.stopPropagation();
181 - BB.queryFeeds.openBuilder(qf);
182 - };
183 -
184 - li.querySelector('.source-delete').onclick = (e) => {
185 - e.stopPropagation();
186 - BB.queryFeeds.deleteFeed(qf);
187 - };
188 -
213 + const activate = () => BB.queryFeeds.select(qf.id);
214 + const li = BB.ui.renderRow({
215 + tag: 'li',
216 + className: 'source-item query-feed' + (currentQF === qf.id ? ' active' : ''),
217 + primary: nameEl(qf.name),
218 + meta: actionsEl([
219 + countEl(String(qf.matchCount), false),
220 + iconBtn('source-edit', '&#9881;', 'Edit query feed', (e) => {
221 + e.stopPropagation();
222 + BB.queryFeeds.openBuilder(qf);
223 + }),
224 + iconBtn('source-delete', '&times;', 'Delete query feed', (e) => {
225 + e.stopPropagation();
226 + BB.queryFeeds.deleteFeed(qf);
227 + }),
228 + ]),
229 + attrs: {
230 + role: 'option',
231 + 'aria-selected': currentQF === qf.id ? 'true' : 'false',
232 + tabindex: '0',
233 + },
234 + onClick: activate,
235 + });
236 + wireRowKeys(li, activate);
189 237 frag.appendChild(li);
190 238 });
191 239 }
@@ -18,6 +18,17 @@ console.log('Running BB frontend tests...\n');
18 18
19 19 const mockElements = {};
20 20
21 + // Serialize a mock element to an HTML-ish string so tests can assert against
22 + // rows built from child elements (BB.ui.renderRow) — not just innerHTML
23 + // template strings. Reflects className + attributes set via setAttribute.
24 + function outerHTML(el) {
25 + const tag = (el.tagName || 'div').toLowerCase();
26 + let attrs = '';
27 + if (el.className) attrs += ` class="${el.className}"`;
28 + for (const [k, v] of Object.entries(el._attrs || {})) attrs += ` ${k}="${v}"`;
29 + return `<${tag}${attrs}>${el.innerHTML}</${tag}>`;
30 + }
31 +
21 32 function createMockElement(tag) {
22 33 const el = {
23 34 tagName: (tag || 'div').toUpperCase(),
@@ -25,7 +36,8 @@ function createMockElement(tag) {
25 36 id: '',
26 37 style: { cssText: '' },
27 38 dataset: {},
28 - innerHTML: '',
39 + _html: '',
40 + _attrs: {},
29 41 _text: '',
30 42 set textContent(v) {
31 43 this._text = v;
@@ -37,11 +49,18 @@ function createMockElement(tag) {
37 49 .replace(/'/g, '&#039;');
38 50 },
39 51 get textContent() { return this._text; },
52 + // innerHTML: explicit assignment wins; otherwise serialize children so
53 + // element-built rows are inspectable.
54 + get innerHTML() {
55 + if (this._html) return this._html;
56 + return this.children.map(outerHTML).join('');
57 + },
58 + set innerHTML(v) { this._html = v; },
40 59 children: [],
41 60 attributes: [],
42 61 _listeners: {},
43 - setAttribute(k, v) { this[k] = v; },
44 - getAttribute(k) { return this[k] || null; },
62 + setAttribute(k, v) { this._attrs[k] = v; this[k] = v; },
63 + getAttribute(k) { return this._attrs[k] != null ? this._attrs[k] : (this[k] || null); },
45 64 addEventListener(ev, fn) { this._listeners[ev] = fn; },
46 65 querySelector() { return createMockElement('span'); },
47 66 querySelectorAll() { return []; },
@@ -127,7 +146,12 @@ BB.api = {
127 146 },
128 147 plugins: { schema: async () => ({ fields: [] }) },
129 148 };
130 - BB.ui = { showToast() {}, openFormModal() {} };
149 + // Load the real BB.ui (renderRow, renderEmptyState, etc.) — components.js has
150 + // no load-time DOM side effects. Then stub the methods that touch document.body
151 + // / overlays, matching the harness's prior no-op mock so render tests stay pure.
152 + require('../components');
153 + BB.ui.showToast = () => {};
154 + BB.ui.openFormModal = () => {};
131 155 BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} };
132 156 BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
133 157