OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 6a199bb

say when the view may be out of date
This node is designed to be INTERMITTENTLY ONLINE (spec/mobile-node.md §5) and every page rendered identically whether the index advanced a second ago or froze two days ago. The freeze signature is invisible by construction: peers connected, daemon up, backfill "successful", profile renders — just missing everything appended since. Today the handset's node was dead for 68 minutes behind a timeline that read as merely quiet. frontsDiag has computed the answer since the ashatron freeze — onHighestRev, behindBy, live-per-front. It was only ever rendered on /status, which a reader never opens. This puts one sentence on the page they are actually on and links to /status for the why. ⛔ It never claims currency. The freshest front we can SEE may not be the freshest that exists, so "up to date" is not a statement this node is entitled to make. Two things are sayable — no live peer, or behind by N on the line we are on — and everything else renders NOTHING. Silence means "no reason to doubt", not "verified fresh"; a green tick would be the lie. Stated as a COUNT, because "syncing…" is what the old empty state said while nothing was happening. Wired on / and /following. render 385 assertions, full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6a199bbd1b2e211d81ae3deb649b6e87530ee76b
by plankatron <fleeky@gmail.com> · 2026-08-18T14:13:16.000Z

Changed files (3) +91 -5

bin/favela-web.js +4 -2
……@@ -1437,7 +1437,9 @@ const server = http.createServer(async (req, res) => {
14371437 }
14381438 } catch { composerLists = [] }
14391439 }
1440 return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, quoting, composerLists, apRkeys: await apProjected() }))
1440 // Freshness qualifies everything on this page, so it is computed for the page the reader is ON —
1441 // not only for /status, which a reader never opens. In-memory router maps; no I/O.
1442 return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, quoting, composerLists, apRkeys: await apProjected(), freshness: frontsDiag(router) }))
14411443 }
14421444 // Following feed — the DIDs the viewer follows, handed to getTimeline's existing `authors` filter.
14431445 // No new index: that filter already exists for community scoping.
……@@ -1447,7 +1449,7 @@ const server = http.createServer(async (req, res) => {
14471449 const result = authors.length
14481450 ? await withBookmarks(await withLensBylines(await withMod(await av.getTimeline({ limit: 50, cursor: url.searchParams.get('cursor') || null, authors, viewerDid: viewer.did }), viewer.did)), viewer.did)
14491451 : { posts: [] }
1450 return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, active: 'following', apRkeys: await apProjected(), emptyMsg: authors.length ? 'nobody you follow has posted here yet' : 'you are not following anyone yet — open a profile to follow them' }))
1452 return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, active: 'following', apRkeys: await apProjected(), freshness: frontsDiag(router), emptyMsg: authors.length ? 'nobody you follow has posted here yet' : 'you are not following anyone yet — open a profile to follow them' }))
14511453 }
14521454 // /profile/<did>/followers · /profile/<did>/following — the dedicated list.
14531455 //
lib/render.js +45 -2
……@@ -600,6 +600,9 @@ details.post>summary{cursor:pointer;color:#888;font-style:italic;list-style:reve
600600.composerhint{font-size:.78rem;color:var(--fg-faint,#6b7480);margin:.4rem 0 0}
601601/* Which identity this form writes as. Above the textarea because it has to be read BEFORE typing, not
602602 discovered after posting; strong enough to be seen, quiet enough not to shout on every page. */
603/* Staleness. Above everything, because it qualifies everything below it — but a notice, not an alarm:
604 it is frequently true and correctly ignorable on a P2P node. */
605.stale{font-size:.82rem;background:var(--bg-soft);border:1px solid var(--line);border-left:3px solid #b8860b;border-radius:var(--r1);padding:.45rem .6rem;margin:0 0 var(--s3)}
603606.composerwho{font-size:.8rem;color:var(--fg-faint,#6b7480);margin:0 0 .4rem}
604607.composerwho strong{color:var(--fg);font-weight:600}
605608@media(max-width:640px){
……@@ -1041,6 +1044,44 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic
10411044}
10421045
10431046// The signed-in/anonymous indicator + sign-in/out link (the DID session layer wires these routes).
1047// Is what you are reading current? The one state this UI never had.
1048//
1049// ⛔ THIS NODE IS DESIGNED TO BE INTERMITTENTLY ONLINE (favela-web/spec/mobile-node.md §5) and every page
1050// rendered identically whether the index advanced a second ago or froze two days ago. The freeze
1051// signature is invisible by construction: peers connected, daemon up, backfill "successful", profile
1052// renders — just missing everything appended since. On 2026-08-18 the handset's node was dead for 68
1053// minutes behind a timeline that looked merely quiet.
1054//
1055// frontsDiag has computed the answer since the ashatron freeze; it was only ever rendered on /status,
1056// which a reader never opens. This is the one sentence a reader needs, on the page they are on.
1057//
1058// ⛔ NEVER CLAIMS CURRENCY. The freshest front we can SEE may not be the freshest that exists, so "up to
1059// date" is not a statement this node is entitled to make. Two things are sayable — "no peer is serving
1060// this right now" and "we are behind by N on the line we are on" — and everything else renders NOTHING.
1061// Silence here means "no reason to doubt", not "verified fresh"; a green tick would be the lie.
1062export function freshnessNote (diag) {
1063 if (!diag || !Array.isArray(diag.dids) || !diag.dids.length) return ''
1064
1065 // No live front = nobody answered for this identity. This is the handset's case, and the reader's
1066 // question ("is this all there is?") has a real answer we were withholding.
1067 const dark = diag.dids.filter((d) => !((d && d.fronts) || []).some((f) => f && f.live))
1068 if (dark.length) {
1069 const n = dark.length
1070 const total = diag.dids.length
1071 return `<p class="stale">⚠ this view may be <strong>out of date</strong> — ${n} of ${total} ${n === 1 ? 'identity has' : 'identities have'} no live peer right now. <a href="/status">why</a></p>`
1072 }
1073
1074 // On a line, but a shorter one than we can see: the freeze signature, in a reader's words. Stated as a
1075 // COUNT, because "syncing…" is what the old empty state said while nothing was happening.
1076 const behind = diag.dids.filter((d) => d && Number.isFinite(d.behindBy) && d.behindBy > 0)
1077 if (behind.length) {
1078 const worst = behind.reduce((a, b) => (b.behindBy > a.behindBy ? b : a))
1079 return `<p class="stale">⚠ <strong>behind</strong> by ${worst.behindBy} record${worst.behindBy === 1 ? '' : 's'} on ${esc(shortDid(worst.did))}. <a href="/status">why</a></p>`
1080 }
1081
1082 return ''
1083}
1084
10441085function whoami (viewer) {
10451086 if (viewer && viewer.did) return `<span class="who">${esc(viewer.handle || viewer.did)} · <a href="/logout">sign out</a></span>`
10461087 return '<span class="who"><a href="/login">sign in</a></span>'
……@@ -1107,7 +1148,7 @@ const POSTCLICK = `<script>
11071148
11081149// Full HTML document. `client` (optional) is the progressive-enhancement <script> src/inline appended
11091150// before </body>; with it absent the page is fully functional server-rendered.
1110export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [] } = {}) {
1151export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null } = {}) {
11111152 return `<!doctype html><html lang="en"><head><meta charset="utf-8">
11121153<meta name="viewport" content="width=device-width,initial-scale=1">
11131154<title>${esc(title)}</title>
……@@ -1120,6 +1161,7 @@ export function page ({ title = 'favela', heading, viewer = null, active = 'time
11201161${nav(active, viewer)}
11211162</header>
11221163<main id="main" tabindex="-1">
1164${freshnessNote(freshness)}
11231165${composer(canWrite, quoting, composerLists, viewer)}
11241166${body}
11251167</main>
……@@ -1132,7 +1174,7 @@ ${POSTCLICK}
11321174}
11331175
11341176// ── whole-page views ──────────────────────────────────────────
1135export function timelinePage ({ result, viewer = null, canWrite = false, clientScript = '', active = 'timeline', emptyMsg = 'no posts yet (indexing…)', quoting = null, composerLists = [], apRkeys = null } = {}) {
1177export function timelinePage ({ result, viewer = null, canWrite = false, clientScript = '', active = 'timeline', emptyMsg = 'no posts yet (indexing…)', quoting = null, composerLists = [], apRkeys = null, freshness = null } = {}) {
11361178 const base = active === 'following' ? '/following' : '/'
11371179 const posts = withReach(withViewer(result && result.posts, viewer, base), apRkeys)
11381180 return page({
……@@ -1142,6 +1184,7 @@ export function timelinePage ({ result, viewer = null, canWrite = false, clientS
11421184 canWrite,
11431185 quoting,
11441186 composerLists,
1187 freshness,
11451188 body: renderPosts(posts, emptyMsg) + moreLink(result && result.cursor, base),
11461189 // Composer image picker is progressive enhancement — only loaded when there is a composer to enhance.
11471190 clientScript: clientScript || (canWrite ? '<script type="module" src="/static/compose.js"></script>' : '')
test/render.test.mjs +42 -1
……@@ -1,5 +1,5 @@
11// favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs
2import { withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState } from '../lib/render.js'
2import { withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState } from '../lib/render.js'
33import { SCOPE_COLLECTION } from '../lib/scopes.js'
44
55let pass = 0
……@@ -847,4 +847,45 @@ function t_is_empty (s, m) { ok(s === '', m) }
847847 'a reader who cannot write is told nothing about posting')
848848}
849849
850// ── a reader must be told when the view may be out of date ────────────────────────────────────────
851//
852// ⛔ THE STATE NOBODY RENDERS. This node is designed to be INTERMITTENTLY ONLINE — a phone that Android
853// suspends, a laptop that closes — and every page renders identically whether the index advanced a
854// second ago or froze 42 hours ago. On 2026-08-18 the handset's node was dead for 68 minutes and the UI
855// said nothing: a timeline that had simply stopped moving, which reads as "quiet day".
856//
857// frontsDiag has computed the answer since the ashatron freeze (onHighestRev / behindBy / live per
858// front). It was only ever rendered on /status, which a reader never opens. This puts the one sentence a
859// reader needs on the page they are actually on, and links to /status for the why.
860//
861// ⛔ IT CAN ONLY EVER SAY "MAY BE BEHIND". Currency is not provable — the freshest front we can SEE may
862// not be the freshest that exists — so there is no "up to date" branch, and no news renders nothing.
863{
864 const front = (over = {}) => ({ firehoseKey: 'aa'.repeat(16), short: 'aaaaaaaaaaaa', live: true, verified: true, rev: '3mt', firehoseLength: 10, chosen: true, ...over })
865 const did = (over = {}) => ({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', frontCount: 1, chosen: 'aaaaaaaaaaaa', onHighestRev: true, behindBy: 0, fronts: [front()], ...over })
866
867 t_is_empty(freshnessNote(null), 'no diagnostic at all → no claim either way')
868 t_is_empty(freshnessNote({ dids: [] }), 'nothing hosted → nothing to say')
869 t_is_empty(freshnessNote({ dids: [did()] }), 'on the best line we can see → SILENCE, never "up to date"')
870
871 // Nobody serving: the exact case the handset hit — the DID is tracked, no peer answered.
872 const dead = freshnessNote({ dids: [did({ fronts: [front({ live: false })] })] })
873 has(dead, 'out of date', 'no live peer → the reader is told the view may be stale')
874 has(dead, '/status', 'and pointed at where the detail already lives')
875
876 // Behind on the line we are on: the freeze signature, in a reader's words.
877 const behind = freshnessNote({ dids: [did({ onHighestRev: false, behindBy: 7 })] })
878 has(behind, '7', 'behind by N is stated as a number, not "syncing"')
879 has(behind, 'behind', 'and named as being behind')
880
881 // Mixed: one healthy, one dead — must not be silenced by the healthy one.
882 const mixed = freshnessNote({ dids: [did(), did({ did: 'did:plc:other', fronts: [front({ live: false })] })] })
883 has(mixed, 'out of date', 'one unreachable identity is enough to warn')
884
885 // And it reaches the actual page, above the fold, only when there is something to say.
886 const p = timelinePage({ result: { posts: [] }, freshness: { dids: [did({ fronts: [front({ live: false })] })] } })
887 has(p, 'class="stale"', 'the banner renders on the timeline page')
888 not(timelinePage({ result: { posts: [] } }), 'class="stale"', 'and never when no freshness was supplied')
889}
890
850891console.log(`\nRENDER OK: ${pass} assertions`)