OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 9297417

0.0.42: an empty page says what is empty, why, and what fixes it
Ours said "Nothing yet." — the one thing the reader already knew. Bluesky's empty feed names what is empty, explains why, and puts the two buttons that fix it on the page. Six of ours do all three now, through one `empty()` helper. ⛔ AND THE TIMELINE DOES NOT CONFLATE ITS TWO CAUSES. Following nobody and following people whose feeds have not arrived are different problems with different fixes — the UI version of drawing a converged edge and a resolved one the same way. MEASURED 2026-09-07: stortron follows nobody, its timeline has always been blank, and no page has ever said why. It says so now, and a reader who already follows three feeds is told they are waiting rather than told to go find somebody. ⚠ The follow set is asked for ONLY when the timeline is empty. It is a scan of our own feed, and a timeline with posts in it must not pay for a message it will not render — asserted both ways. An action is {href} or {post}: "make a room" is not a page you can navigate to, and the alternative was letting callers pass markup, which this file has been bitten by before. Two existing tests caught me writing something false, and both were right: - "Nobody has named you" is a claim about the world. This node can only speak for the feeds it holds, and the limit belongs in the sentence a reader actually reads, not in a line underneath it. - de-duplicating the notifications paragraph deleted the two facts it carried — that notifications are derived and nothing is sent to you, and that ringing your doorbell is the one thing a stranger can always do. Removing a duplicate must not remove the claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9297417de807ec810c055bef49be93947c188393
by plankatron <fleeky@gmail.com> · 2026-09-07T21:28:07.000Z

Changed files (6) +189 -18

lib/pages.js +91 -16
……@@ -101,6 +101,30 @@ export function keyline (label, key) {
101101 '<code>' + esc(key) + '</code></details>'
102102}
103103
104/**
105 * What a page says when it has nothing to show.
106 *
107 * ⛔ "Nothing yet." is the one thing the reader already knows. An empty state has three jobs: name
108 * what is empty, say WHY it is empty, and offer the thing that fixes it. Bluesky's empty feed does
109 * all three and puts two buttons on the page; ours did none of them.
110 *
111 * ⚠ `why` is escaped, and actions are {href, label} rather than markup — an empty state is the
112 * place a caller is most tempted to pass a sentence with a link in it, and this file has already
113 * been bitten by markup arriving through a value.
114 */
115export function empty (title, why, actions = []) {
116 // ⚠ A next step is sometimes a POST — "make a room" is not a page you can navigate to. So an
117 // action is {href} OR {post}, and this renders the right element for each. Still no markup
118 // arriving through a value: the caller passes a URL and a label, never a fragment.
119 const links = actions.length
120 ? '<div class="choices">' + actions.map((a) => (a.post
121 ? '<form method="post" action="' + esc(a.post) + '"><button>' + esc(a.label) + '</button></form>'
122 : '<a class="file" href="' + esc(a.href) + '">' + esc(a.label) + '</a>')).join(' ') + '</div>'
123 : ''
124 return '<div class="empty"><p class="empty-title">' + esc(title) + '</p>' +
125 (why ? '<p class="who">' + esc(why) + '</p>' : '') + links + '</div>'
126}
127
104128const CSS = `
105129/* ⛔ ONE colour and ONE spacing scale, as variables. Values scattered through rules is how a page
106130 ends up with four greys that were each "close enough" at the time. */
……@@ -323,6 +347,12 @@ ul.people li{border-top:1px solid var(--line);padding:var(--s2) 0}
323347 padding:.05rem .5rem;white-space:nowrap}
324348/* ⚠ The full key stays — it is the only unforgeable name on the page. It does not need to be the
325349 loudest thing on it: dim and small, so it is available rather than shouting. */
350/* An empty state is a block with room around it, not a stray sentence where content would be. */
351.empty{border:1px solid var(--line);border-radius:12px;padding:var(--s4) var(--s3);
352 margin:var(--s3) 0;text-align:center;background:var(--bg-soft)}
353.empty-title{font-weight:600;margin:0 0 var(--s1)}
354.empty .who{margin:0 auto;max-width:26rem}
355.empty .choices{justify-content:center;margin-top:var(--s3)}
326356.keyline{margin:var(--s1) 0;font-size:12px}
327357.keyline summary{color:var(--dim);cursor:pointer;list-style:none;width:fit-content}
328358.keyline summary::-webkit-details-marker{display:none}
……@@ -609,13 +639,36 @@ function avatarOf (author, profiles) {
609639// post, and the daemon bounds the value for the same reason.
610640const QUICK = ['🔥', '😂', '👀']
611641
612export function timeline (rows, { unreadable = 0, profiles = null, files = null, message = null, quote = null, mention = null } = {}) {
642export function timeline (rows, { unreadable = 0, profiles = null, files = null, message = null, quote = null, mention = null, empty: state = null } = {}) {
613643 const warn = unreadable
614644 // ⛔ Never silent. "nothing here" and "nothing here, and N records I hold pointers for but
615645 // cannot read" are different answers, and the daemon distinguishes them — so the page must too.
616646 ? '<p class="warn">' + unreadable + ' record(s) could not be read — the log arrived, the bytes did not.</p>'
617647 : ''
618 const body = rows.length ? rows.map((r) => post(r, profiles, files, '/')).join('') : '<p>Nothing yet.</p>'
648 /**
649 * ⛔ TWO KINDS OF EMPTY, and they are not the same problem.
650 *
651 * follows 0 there is nothing to read because nothing was asked for. The fix is to find
652 * somebody, and the page says where.
653 * follows N N feeds were asked for and none of them has arrived yet. The fix is time, or a
654 * look at which ones are missing — and telling this reader to "follow somebody"
655 * would be advice they have already taken.
656 *
657 * Conflating them is the UI version of drawing a converged edge and a resolved one the same way.
658 */
659 const nothing = !state
660 ? empty('Nothing yet.', null, [])
661 : (state.follows === 0
662 ? empty('Your timeline is empty.',
663 'A timeline is made of the feeds you hold, and you hold none yet. Find somebody, or ' +
664 'paste a feed key if you already have one.',
665 [{ href: '/search', label: 'search for people' }, { href: '/following', label: 'paste a key' }])
666 : empty('Nothing has arrived yet.',
667 'You follow ' + state.follows + ' feed(s). None of them has anything this node can read ' +
668 'so far — replication takes as long as it takes, and a feed that never arrives shows as ' +
669 'not replicated in your list.',
670 [{ href: '/following', label: 'see who you follow' }]))
671 const body = rows.length ? rows.map((r) => post(r, profiles, files, '/')).join('') : nothing
619672 return layout('favela', composer(message, quote, mention) + warn + body)
620673}
621674
……@@ -722,11 +775,16 @@ export function search (query, results, unreadable, people = [], profiles = null
722775export function mentions (rows, profiles = null, files = null) {
723776 const body = rows.length
724777 ? rows.map((r) => post(r, profiles, files, '/mentions')).join('')
725 : '<p>Nobody you hold has named you.</p>'
726 return layout('mentions', body +
727 '<p class="who">Only posts from feeds you hold are searched, and only a ' +
728 '<code>[@name](favela://…)</code> link counts — a bare <code>@name</code> is plain text, never ' +
729 'a guess about who was meant.</p>')
778 : empty('Nobody you hold has named you.',
779 'And only a [@name](favela://…) link counts — a bare @name is plain text, never a guess ' +
780 'about who was meant.',
781 [{ href: '/following', label: 'see who you follow' }])
782 const note = rows.length
783 ? '<p class="who">Only posts from feeds you hold are searched, and only a ' +
784 '<code>[@name](favela://…)</code> link counts — a bare <code>@name</code> is plain text, ' +
785 'never a guess about who was meant.</p>'
786 : ''
787 return layout('mentions', body + note)
730788}
731789
732790/**
……@@ -779,11 +837,17 @@ export function notifications (rows, profiles = null) {
779837 const snippet = n.body ? '<div class="who bio">' + esc(n.body.replace(/\s+/g, ' ').trim().slice(0, 120)) + '</div>' : ''
780838 return '<li>' + who(n.author, profiles) + ' ' + verb + link + snippet + '</li>'
781839 }).join('') + '</ul>'
782 : '<p>Nothing yet.</p>'
783 return layout('notifications', items +
784 '<p class="who">Derived from feeds you already hold — nothing is sent to you. Someone whose ' +
785 'feed you do not hold can interact with you and this will not show it; ringing your doorbell ' +
786 'is the one thing they can always do.</p>')
840 : empty('No notifications.',
841 'These are derived from feeds you already hold — nothing is sent to you. Somebody whose feed ' +
842 'you do not hold can interact with you and this will not show it; ringing your doorbell is ' +
843 'the one thing they can always do.',
844 [{ href: '/knocks', label: 'see who knocked' }, { href: '/following', label: 'see who you follow' }])
845 const note = rows.length
846 ? '<p class="who">Derived from feeds you already hold — nothing is sent to you. Someone whose ' +
847 'feed you do not hold can interact with you and this will not show it; ringing your doorbell ' +
848 'is the one thing they can always do.</p>'
849 : ''
850 return layout('notifications', items + note)
787851}
788852
789853/**
……@@ -927,7 +991,10 @@ export function rooms (list) {
927991 '<li><a href="/r/' + esc(r.key) + '"><b>' + esc(r.config.title || 'untitled room') + '</b></a>' +
928992 '<div class="who">' + esc(String(r.members)) + ' member(s) · <code>' + esc(r.key.slice(0, 16)) + '…</code></div>' +
929993 '</li>').join('') + '</ul>'
930 : '<p>No rooms yet.</p>'
994 : empty('No rooms yet.',
995 'A room is a place with its own members, and what you write in one is read by them. Make one, ' +
996 'or paste a key somebody handed you into the box above.',
997 [{ post: '/rooms/create', label: 'make a room' }])
931998 return layout('rooms',
932999 '<form class="inline-form" method="post" action="/rooms/join">' +
9331000 '<input type="text" name="key" placeholder="room key (64 hex)"><button>join</button></form>' +
……@@ -1010,7 +1077,11 @@ export function following (keys, missing, profiles = null) {
10101077 : ''
10111078 return '<li>' + who(k, profiles) + warn + bio + '</li>'
10121079 }).join('') + '</ul>'
1013 : '<p>Following nobody.</p>'
1080 // ⚠ The follow form is already below; the empty state points AT it rather than repeating it.
1081 : empty('Following nobody.',
1082 'Everything you read comes from a feed you hold. Search for a person, or paste a feed key ' +
1083 'into the box below.',
1084 [{ href: '/search', label: 'search for people' }, { href: '/knocks', label: 'see who knocked' }])
10141085 const form = '<form class="inline-form" method="post" action="/follow">' +
10151086 '<input type="text" name="feed" placeholder="feed key (64 hex)"><button>follow</button></form>'
10161087 return layout('following', form + items)
……@@ -1308,10 +1379,14 @@ export function knocks (rows) {
13081379 keyline('full key', k.feed) +
13091380 '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' +
13101381 '<button>follow them</button></form></li>').join('')
1311 : '<li>Nobody has knocked.</li>'
1382 : ''
13121383 // Accepting a knock FOLLOWS them. It does not give them access to your feed, and the button says
13131384 // so — a UI that said "accept" would imply the opposite of what happens.
1314 return layout('knocks', '<ul>' + items + '</ul>')
1385 const none = rows.length ? '' : empty('Nobody has knocked.',
1386 'Ringing your doorbell is the one thing somebody can do without you holding their feed. When ' +
1387 'they do, they appear here and you can choose to follow them.',
1388 [{ href: '/search', label: 'search for people' }])
1389 return layout('knocks', rows.length ? '<ul>' + items + '</ul>' : none)
13151390}
13161391
13171392export function error (message) {
lib/router.js +17 -1
……@@ -290,9 +290,25 @@ export async function route (req, res, ctx) {
290290 const mr = url.searchParams.get('mention')
291291 const mh = (url.searchParams.get('handle') || 'them').replace(/[^\w.@-]/g, '')
292292 const mention = mr && /^[0-9a-f]{64}$/i.test(mr) ? '[@' + mh + '](favela://' + mr + ')' : null
293 /**
294 * ⛔ An empty timeline has TWO causes and the page cannot tell them apart on its own.
295 *
296 * Following nobody and following people whose feeds have not arrived are different problems
297 * with different fixes, and one sentence for both is the UI version of drawing a converged edge
298 * and a resolved one the same way. MEASURED 2026-09-07: stortron follows nobody, its timeline
299 * has always been blank, and nothing on the page has ever said why.
300 *
301 * ⚠ Asked for ONLY when the timeline is empty. It is a scan of our own follow set, and the
302 * common path — a timeline with posts in it — must not pay for a message it will not render.
303 */
304 const emptyState = rows.length ? null : {
305 follows: await client.call('following')
306 .then((f) => (Array.isArray(f) ? f.length : ((f && f.following) || []).length))
307 .catch(() => 0)
308 }
293309 return send(res, pages.timeline(rows, {
294310 quote, mention, unreadable: un.length, profiles, files: await fileTypes(rows, client),
295 message: url.searchParams.get('m')
311 message: url.searchParams.get('m'), empty: emptyState
296312 }))
297313 }
298314
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.41",
3 "version": "0.0.42",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
preview.mjs +6 -0
……@@ -27,6 +27,12 @@ const rows = [
2727
2828const pages = {
2929 timeline: () => timeline(rows, { profiles, unreadable: 0 }),
30 'timeline-empty': () => timeline([], { profiles, empty: { follows: 0 } }),
31 'timeline-waiting': () => timeline([], { profiles, empty: { follows: 3 } }),
32 'following-empty': () => following([], [], profiles),
33 'rooms-empty': () => rooms([]),
34 'knocks-empty': () => knocks([]),
35 'notifications-empty': () => notifications([], profiles),
3036 person: () => person(A, profiles[A], rows.filter((r) => r.author === A), null, { held: true, following: true }),
3137 following: () => following([A, B], [B], profiles),
3238 notifications: () => notifications([
test/empty.test.js +56 -0
……@@ -0,0 +1,56 @@
1// ⛔ AN EMPTY PAGE STILL HAS TO SAY SOMETHING TRUE.
2//
3// Ours said "Nothing yet." — which is the one thing the reader already knew. Bluesky's empty feed
4// says what is empty, why it is empty, and puts the two buttons that fix it on the page. That is
5// live for us right now: stortron's timeline is empty because it follows nobody, and the page has
6// never once said so.
7//
8// ⛔ AND IT MUST NOT CONFLATE THE CAUSES. "You follow nobody" and "you follow people and nothing has
9// replicated" are different problems with different fixes — the same distinction EDGE-GRAPH §4
10// makes about edges, applied to a page. One sentence for both would be the UI version of drawing a
11// converged edge and a resolved one the same way.
12
13import test from 'brittle'
14import { timeline, following, notifications, rooms, knocks, mentions, empty } from '../lib/pages.js'
15
16const strip = (h) => h.replace(/<style[\s\S]*?<\/style>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
17
18test('★★ an empty timeline says WHICH kind of empty it is', async (t) => {
19 const nobody = strip(timeline([], { empty: { follows: 0 } }))
20 t.ok(/follow/i.test(nobody), 'names the cause: nobody is followed')
21 t.absent(/replicat/i.test(nobody), 'and does not blame replication for it')
22
23 const waiting = strip(timeline([], { empty: { follows: 3 } }))
24 t.ok(/3/.test(waiting), 'says how many feeds are being waited on')
25 // ⛔ The opposite mistake: telling someone who follows three people to go find some.
26 t.absent(/follow (some|nobody)/i.test(waiting), 'does not tell them to follow somebody')
27})
28
29test('★ an empty page OFFERS the thing that fixes it', async (t) => {
30 // A sentence with no way out is a dead end. Every empty state here ends in a link that is the
31 // next step, which is the whole difference between Bluesky's empty feed and ours.
32 for (const [name, html] of [
33 ['timeline', timeline([], { empty: { follows: 0 } })],
34 ['following', following([], [], null)],
35 ['rooms', rooms([])],
36 ['knocks', knocks([])],
37 ['mentions', mentions([], null)],
38 ['notifications', notifications([], null)]
39 ]) {
40 const box = html.slice(html.indexOf('class="empty"'))
41 t.ok(html.includes('class="empty"'), name + ' uses the shared empty state')
42 t.ok(/<a [^>]*href="\/|<button/.test(box), name + ' offers a next step')
43 }
44})
45
46test('⚠ a non-empty page has no empty state', async (t) => {
47 // The obvious regression: rendering the explanation above real content.
48 const rows = [{ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-01T00:00:00Z' }]
49 t.absent(timeline(rows, { empty: { follows: 0 } }).includes('class="empty"'))
50})
51
52test('⚠ empty() escapes what it is given', async (t) => {
53 const out = empty('t', '<script>x</script>', [{ href: '/x', label: '<b>y</b>' }])
54 t.absent(out.includes('<script>'))
55 t.absent(out.includes('<b>y</b>'))
56})
test/routes.test.js +18 -0
……@@ -1128,6 +1128,24 @@ test('⚠ an ACTOR node asks for none of that', async (t) => {
11281128 t.is(called('replies').length, 0)
11291129})
11301130
1131test('★★ an empty timeline asks WHO you follow, and a full one does not', async (t) => {
1132 reset()
1133 answers = { timeline: [], unresolved: [], profiles: {}, following: ['ab'.repeat(32)] }
1134 const r = await request('GET', '/', { headers: { cookie } })
1135 t.is(r.status, 200)
1136 t.is(called('following').length, 1, 'asked, because there was nothing to show')
1137 t.ok(r.body.includes('Nothing has arrived yet'), 'and said which kind of empty it is')
1138
1139 // ⚠ The common path must not pay for a message it will not render.
1140 reset()
1141 answers = {
1142 timeline: [{ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-01T00:00:00Z' }],
1143 unresolved: [], profiles: {}, following: []
1144 }
1145 await request('GET', '/', { headers: { cookie } })
1146 t.is(called('following').length, 0, 'not asked when there are posts')
1147})
1148
11311149test('shut down', async (t) => {
11321150 await new Promise((r) => server.close(r))
11331151 await new Promise((r) => daemon.close(r))