Skip to main content

max / balanced_breakfast

The search spinner goes, and a refresh says how many feeds Phase 4 and 5 of wiki `loading-and-progress-standard`, and the last consumer. `.search-spinner` was the only rotation left in this frontend. Search here is a local query over stored items behind a 300ms debounce, so under rule 4 there was nothing for a mark to say: the wait is shorter than the delay in front of it, and something that appears and goes inside a third of a second is a flicker rather than a report. The span, the two rules and `@keyframes spin` are gone with it. The refresh is the interesting one. Refreshing N feeds is a countable set with a known total before it starts, and the code already had both numbers: `fetch-progress` carries `completed` and `total`, and the frontend divided them, rounded, and threw the pair away. `showProgress` takes the counts now and says `3/12 feeds` beside the bar, which is a fact where a rounded percentage is a reading of one. Two dishonest drawings went with the arithmetic. The bar opened at five percent to "indicate started", which is five percent of nothing -- a bar claiming progress before a single feed had been fetched. And the failure path filled it to a hundred on the way out, saying every feed was fetched in the one branch that knows otherwise. It is left where the last real event put it.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 20:02 UTC
Signed with PGP, not checked
Commit: 7965b8f5a81bfa2f75f9dfee8a4e6a9aa3085200
Parent: b72a1c6
8 files changed, +131 insertions, -79 deletions
M Cargo.lock +51 -41
@@ -3186,12 +3186,13 @@
3186 3186
3187 3187 [[package]]
3188 3188 name = "makeover-build"
3189 - version = "0.51.0"
3189 + version = "0.53.0"
3190 3190 source = "registry+https://github.com/rust-lang/crates.io-index"
3191 - checksum = "1fdd63f552182f0c39a0416a4420ecfb66c89e4a11531878b4a832d6ebc6aad7"
3191 + checksum = "82a170c530883774fc44d41e8e785260ee1b44cc26d9bfaa4d77154176af794c"
3192 3192 dependencies = [
3193 3193 "makeover",
3194 3194 "makeover-geometry",
3195 + "makeover-timing",
3195 3196 "makeover-webview",
3196 3197 ]
3197 3198
@@ -3207,6 +3208,15 @@
3207 3208 source = "registry+https://github.com/rust-lang/crates.io-index"
3208 3209 checksum = "774bfd81b003f0d0059ff50daa8e625e6969175bc0132ddf7c2108bba40c47d5"
3209 3210
3211 + [[package]]
3212 + name = "makeover-timing"
3213 + version = "0.1.1"
3214 + source = "registry+https://github.com/rust-lang/crates.io-index"
3215 + checksum = "c83b1e0502c0179754752e59dd98fa4b15df8f9d0b454e0291b80c984aa2bd49"
3216 + dependencies = [
3217 + "makeover-geometry",
3218 + ]
3219 +
3210 3220 [[package]]
3211 3221 name = "makeover-touch"
3212 3222 version = "0.24.0"
@@ -3219,9 +3229,9 @@
3219 3229
3220 3230 [[package]]
3221 3231 name = "makeover-webview"
3222 - version = "0.59.1"
3232 + version = "0.60.0"
3223 3233 source = "registry+https://github.com/rust-lang/crates.io-index"
3224 - checksum = "59714ea075f5c266616199eb8345808f85092be04278471ffde546bdb0f43cb6"
3234 + checksum = "203b3a259e249ed32ffd9d48d75a6c12e4aa173095607cc2c852f2c1f3fe9c0e"
3225 3235 dependencies = [
3226 3236 "makeover-geometry",
3227 3237 "makeover-layout",
@@ -5633,7 +5643,7 @@
5633 5643
5634 5644 [[package]]
5635 5645 name = "synckit-client"
5636 - version = "0.8.1"
5646 + version = "0.9.1"
5637 5647 dependencies = [
5638 5648 "apple-native-keyring-store",
5639 5649 "argon2",
@@ -8076,42 +8086,6 @@
8076 8086 "winnow 1.0.4",
8077 8087 ]
8078 8088
8079 - [[patch.unused]]
8080 - name = "quasi-axum"
8081 - version = "0.56.0"
8082 -
8083 - [[patch.unused]]
8084 - name = "quasi-basics"
8085 - version = "0.56.0"
8086 -
8087 - [[patch.unused]]
8088 - name = "quasi-http"
8089 - version = "0.56.0"
8090 -
8091 - [[patch.unused]]
8092 - name = "quasi-immediate"
8093 - version = "0.56.0"
8094 -
8095 - [[patch.unused]]
8096 - name = "quasi-notifs"
8097 - version = "0.56.0"
8098 -
8099 - [[patch.unused]]
8100 - name = "quasi-router"
8101 - version = "0.56.0"
8102 -
8103 - [[patch.unused]]
8104 - name = "quasi-store"
8105 - version = "0.1.0"
8106 -
8107 - [[patch.unused]]
8108 - name = "quasi-tauri"
8109 - version = "0.56.0"
8110 -
8111 - [[patch.unused]]
8112 - name = "quasi-webview"
8113 - version = "0.56.0"
8114 -
8115 8089 [[patch.unused]]
8116 8090 name = "kberg"
8117 8091 version = "0.1.0"
@@ -8123,3 +8097,39 @@
8123 8097 [[patch.unused]]
8124 8098 name = "painhours"
8125 8099 version = "0.1.0"
8100 +
8101 + [[patch.unused]]
8102 + name = "quasi-axum"
8103 + version = "0.64.0"
8104 +
8105 + [[patch.unused]]
8106 + name = "quasi-basics"
8107 + version = "0.64.0"
8108 +
8109 + [[patch.unused]]
8110 + name = "quasi-http"
8111 + version = "0.64.0"
8112 +
8113 + [[patch.unused]]
8114 + name = "quasi-immediate"
8115 + version = "0.64.0"
8116 +
8117 + [[patch.unused]]
8118 + name = "quasi-notifs"
8119 + version = "0.64.0"
8120 +
8121 + [[patch.unused]]
8122 + name = "quasi-router"
8123 + version = "0.64.0"
8124 +
8125 + [[patch.unused]]
8126 + name = "quasi-store"
8127 + version = "0.1.0"
8128 +
8129 + [[patch.unused]]
8130 + name = "quasi-tauri"
8131 + version = "0.64.0"
8132 +
8133 + [[patch.unused]]
8134 + name = "quasi-webview"
8135 + version = "0.64.0"
M Cargo.toml +1 -1
@@ -73,7 +73,7 @@
73 73 bb-pdf = { path = "crates/bb-pdf" }
74 74
75 75 # External sibling crates (shared under ~/Code/Libraries)
76 - synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.8" }
76 + synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.9" }
77 77 synckit-config = { git = "https://makenot.work/git/max/synckit.git", version = "0.2" }
78 78 tauri = "2.10.2"
79 79 tauri-plugin-updater = "2"
@@ -18,7 +18,7 @@
18 18 # Materialises the generated files: themes/, geometry.css, layout.css,
19 19 # typography.css. makeover and makeover-geometry reach the build script through
20 20 # here rather than as pins of their own.
21 - makeover-build = "0.52.0"
21 + makeover-build = "0.53.0"
22 22 # Cuts the two house faces into frontend/fonts/. A git dependency because
23 23 # quasi-type is `publish = false`, which is also why makeover-build cannot do
24 24 # this and only emits the CSS that fetches the result. The rev is the one
@@ -37,7 +37,6 @@
37 37 <div class="header-actions">
38 38 <label for="search-input" class="sr-only">Search items</label>
39 39 <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="field search-input">
40 - <span id="search-spinner" class="search-spinner" aria-hidden="true"></span>
41 40 <label for="sort-select" class="sr-only">Sort order</label>
42 41 <button id="unread-toggle" class="button button--sm" title="Show unread only (U)" aria-pressed="false">Unread</button>
43 42 <select id="sort-select" class="field" title="Sort order (S)">
@@ -68,7 +68,7 @@
68 68 13. Tag: .tag
69 69 14. Context menu: .context-menu
70 70 15. Health indicator: .health-dot (state-by-color, F3 fix pending)
71 - 16. Skeleton & loading: .skeleton-item, .skeleton-line, spinner
71 + 16. Skeleton & loading: .skeleton-item, .skeleton-line
72 72 17. Progress bar: .progress-bar-container
73 73 18. Update banner: .update-banner
74 74 19. Health popover: .health-popover
@@ -323,21 +323,11 @@
323 323 width: 180px;
324 324 }
325 325
326 - /* Search spinner */
327 - .search-spinner {
328 - display: none;
329 - width: 14px;
330 - height: 14px;
331 - border: 2px solid var(--border); /* spinner ring thickness, not a frame */
332 - border-top-color: var(--category-four);
333 - border-radius: var(--radius-full);
334 - animation: spin 0.6s linear infinite;
335 - }
336 - .search-spinner.active { display: inline-block; }
337 -
338 - @keyframes spin {
339 - to { transform: rotate(360deg); }
340 - }
326 + /* `.search-spinner` and `@keyframes spin` are gone (2026-08-26). They were the
327 + only rotation left in this file, and a spinner turns at a rate it invented:
328 + it reads as progress on a wait that has none to report. Search here is a
329 + local query behind a 300ms debounce, so under rule 4 of wiki
330 + `loading-and-progress-standard` there was nothing for it to say. */
341 331
342 332 .search-input::placeholder { color: var(--content-muted); }
343 333
@@ -1191,9 +1181,9 @@
1191 1181 }
1192 1182
1193 1183 /* === 16. Skeleton & loading ============================================
1194 - Canonical: .skeleton-item, .skeleton-line.{short,medium,long},
1195 - .search-spinner (above). Charter target (F5): add BB.ui.renderSkeleton
1196 - helper; widen first-paint usage beyond items panel.
1184 + Canonical: .skeleton-item, .skeleton-line.{short,medium,long}. Charter
1185 + target (F5): add BB.ui.renderSkeleton helper; widen first-paint usage
1186 + beyond items panel.
1197 1187 ========================================================================== */
1198 1188 .skeleton-item {
1199 1189 display: flex;
@@ -1236,6 +1226,17 @@
1236 1226 background-color: var(--category-four);
1237 1227 transition: width var(--transition-slow) ease;
1238 1228 }
1229 + /* The two numbers the bar is drawn from, said out loud. A 3px bar at the foot
1230 + of the header reports roughly-a-quarter and nothing else; this says which
1231 + quarter of what. Empty until something has counted, so a refresh that has not
1232 + heard from a feed yet shows a trough and no claim. */
1233 + .progress-reading {
1234 + position: absolute;
1235 + right: var(--gap-section);
1236 + bottom: var(--gap-bound);
1237 + color: var(--content-muted);
1238 + font-size: var(--font-size-xs);
1239 + }
1239 1240
1240 1241 /* Toast with action */
1241 1242 .toast-action {
@@ -55,16 +55,17 @@
55 55
56 56 // Search input: debounced at 300ms, with request ID so last request wins
57 57 const searchInput = document.getElementById('search-input');
58 - const searchSpinner = document.getElementById('search-spinner');
58 + // No indicator. Search is a local query over stored items, so under
59 + // rule 4 of wiki `loading-and-progress-standard` there is nothing an
60 + // indicator would add: the wait is shorter than the 300ms debounce in
61 + // front of it, and a mark that appears and goes inside a third of a
62 + // second is a flicker rather than a report. The request id still
63 + // stands, because last-request-wins is correctness and not decoration.
59 64 searchInput.addEventListener('input', BB.utils.debounce(async () => {
60 65 BB.state.set('currentSearch', searchInput.value);
61 66 BB.state.resetPagination();
62 - const myId = ++loadRequestId;
63 - searchSpinner.classList.add('active');
67 + ++loadRequestId;
64 68 await BB.items.load();
65 - if (loadRequestId === myId) {
66 - searchSpinner.classList.remove('active');
67 - }
68 69 }, 300));
69 70
70 71 // Sort select: guarded so rapid changes don't interleave results
@@ -35,17 +35,46 @@
35 35
36 36 /**
37 37 * Create and append a progress bar to a container element.
38 + *
39 + * Takes the two counts rather than a percentage (2026-08-26, wiki
40 + * `loading-and-progress-standard`). Three things follow from that and each
41 + * of them was wrong before:
42 + *
43 + * 1. A bar can only be drawn once something is actually counted. The old
44 + * caller opened with `set(5)` to "indicate started", which is five
45 + * percent of nothing: a bar claiming progress before a single feed had
46 + * been fetched. `set` with no total draws an empty trough instead.
47 + * 2. Done over total is what the bar reports, and `3/12` is a fact where
48 + * `25%` is a rounding of one. Rule 1: never a remaining time, an arrival
49 + * time, or a rate extrapolated forward, and a percentage is one step from
50 + * all three.
51 + * 3. The numbers are readable. A 3px bar at the foot of the header says
52 + * roughly-a-quarter and nothing else; the reading says which quarter of
53 + * what.
54 + *
38 55 * @param {HTMLElement} container - Parent element.
39 - * @returns {{set: function(number), remove: function}} Controller.
56 + * @param {string} [noun] - What is being counted, for the reading.
57 + * @returns {{set: function(number, number), remove: function}} Controller.
40 58 */
41 - function showProgress(container) {
59 + function showProgress(container, noun) {
42 60 const bar = document.createElement('div');
43 61 bar.className = 'progress-bar-container';
44 62 bar.innerHTML = '<div class="progress-bar" style="width: 0%"></div>';
63 + const reading = document.createElement('span');
64 + reading.className = 'progress-reading';
45 65 container.appendChild(bar);
66 + container.appendChild(reading);
46 67 return {
47 - set(pct) { bar.querySelector('.progress-bar').style.width = pct + '%'; },
48 - remove() { bar.remove(); },
68 + set(done, total) {
69 + // A total of zero is nothing to do rather than everything done,
70 + // which is `makeover_layout::Meter`'s reading of the same case.
71 + const share = total > 0 ? Math.min(done / total, 1) : 0;
72 + bar.querySelector('.progress-bar').style.width = (share * 100) + '%';
73 + reading.textContent = total > 0
74 + ? done + '/' + total + (noun ? ' ' + noun : '')
75 + : '';
76 + },
77 + remove() { bar.remove(); reading.remove(); },
49 78 };
50 79 }
51 80
@@ -196,7 +196,13 @@
196 196 button.textContent = 'Refreshing...';
197 197
198 198 const header = document.querySelector('.header');
199 - const progress = BB.ui.showProgress(header);
199 + const progress = BB.ui.showProgress(header, 'feeds');
200 +
201 + // How many feeds there are, once something has said. Kept here rather
202 + // than read back off the bar so the finish below can fill it honestly:
203 + // `fetchAll` resolves with items and errors and says nothing about how
204 + // many feeds it walked.
205 + let counted = 0;
200 206
201 207 // Listen for per-plugin progress events
202 208 let unlisten = null;
@@ -204,7 +210,8 @@
204 210 unlisten = await window.__TAURI__.event.listen('fetch-progress', (event) => {
205 211 const { completed, total } = event.payload;
206 212 if (total > 0) {
207 - progress.set(Math.round((completed / total) * 100));
213 + counted = total;
214 + progress.set(completed, total);
208 215 }
209 216 });
210 217 } catch (_) {
@@ -212,10 +219,13 @@
212 219 }
213 220
214 221 try {
215 - progress.set(5); // indicate started
216 -
222 + // Nothing is set here. The bar used to open at five percent to
223 + // "indicate started", which is a bar claiming progress before a
224 + // single feed had been fetched -- rule 1 of wiki
225 + // `loading-and-progress-standard`, and the exact shape it forbids.
226 + // The trough is drawn empty and the first real event fills it.
217 227 const result = await BB.api.feeds.fetchAll();
218 - progress.set(100);
228 + progress.set(counted, counted);
219 229
220 230 let msg = 'Fetched ' + result.itemsFetched + ' item' + (result.itemsFetched !== 1 ? 's' : '');
221 231 if (result.errors && result.errors.length > 0) {
@@ -228,7 +238,9 @@
228 238 BB.sources.load();
229 239 BB.items.load();
230 240 } catch (err) {
231 - progress.set(100);
241 + // The bar is left where the last event put it. Filling it on the way
242 + // out would say every feed was fetched, which is the one thing this
243 + // branch knows to be false.
232 244 BB.ui.showErrorWithRetry('Failed to refresh: ' + BB.utils.getErrorMessage(err), refresh);
233 245 } finally {
234 246 if (unlisten) unlisten();