OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / cbe36dd

0.0.41: every row that names a person has a face
Mastodon draws an avatar on every notification row; Bluesky does the same on follow lists. Ours drew one only when the person had uploaded a picture and NOTHING when they had not — while post rows always draw their grey circle from the article grid. So the timeline had a left edge and every list did not, and a list of people read as loose text rather than as rows. ⛔ A placeholder, never nothing. It is what gives every row the same left edge; without it the people who happen to have pictures are indented and the rest are flush, which is a different shape per row. Two real defects fell out of looking at the result: · search's People section rendered a bullet, a circle and NO NAME. The daemon's `searchProfiles` returns {author, ...profile}, and the harness fixture passed bare keys — so `who(x.author)` got undefined and drew an anonymous dot. A fixture that does not match the daemon makes the harness check ITSELF, which is the thing preview.mjs's own header warns about. The shape is pinned by a test now. · knocks began its row with a key where every other row begins with a face. We hold nothing about a stranger — no picture, no name — but they are still a person, and a row shaped differently says they are a different kind of thing. ⚠ My audit said "avatars appear on 3 of 12 pages". That was wrong: following and notifications already asked for them and got nothing back, because the fixtures had no pictures. The defect was the missing fallback, not missing wiring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cbe36dd5e4825edbc51b95801ea3595302c3d5d1
by plankatron <fleeky@gmail.com> · 2026-09-07T21:23:32.000Z

Changed files (4) +64 -7

lib/pages.js +19 -5
……@@ -48,9 +48,20 @@ const short = (k) => esc(String(k || '').slice(0, 8))
4848 */
4949export function who (author, profiles, { avatar = true } = {}) {
5050 const p = (profiles && profiles[author]) || null
51 const img = avatar && p && p.avatar
52 ? '<img class="av" src="/avatar/' + esc(p.avatar) + '" alt="">'
53 : ''
51 /**
52 * ⛔ A PLACEHOLDER WHEN THERE IS NO PICTURE, never nothing.
53 *
54 * The avatar used to be omitted entirely for a feed that had published none, so a list of people
55 * indented the ones with pictures and left the rest flush — every row a different shape, and the
56 * whole list reading as loose text instead of rows. Post rows never had this problem because the
57 * article grid always draws its circle. Mastodon and Bluesky both draw a default face; neither
58 * has a row that starts with a name.
59 */
60 const img = !avatar
61 ? ''
62 : (p && p.avatar
63 ? '<img class="av" src="/avatar/' + esc(p.avatar) + '" alt="">'
64 : '<span class="av av-dot"></span>')
5465 const handle = p && p.handle ? '<span class="handle">@' + esc(p.handle) + '</span> ' : ''
5566 // The self-label is not bold when a handle is present: two names in one weight would read as one
5667 // name, and they are different KINDS of claim.
……@@ -321,7 +332,9 @@ ul.people li{border-top:1px solid var(--line);padding:var(--s2) 0}
321332.keyline[open] summary::before{transform:rotate(90deg)}
322333.keyline code{display:block;color:var(--dim);word-break:break-all;margin-top:.25rem;
323334 -webkit-user-select:all;user-select:all}
324img.av{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;object-fit:cover}
335img.av,.av-dot{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;
336 object-fit:cover;flex:none}
337.av-dot{display:inline-block;background:var(--line)}
325338img.av-lg{width:96px;height:96px;border-radius:50%;object-fit:cover}
326339label{display:block;margin:.4rem 0}
327340pre{overflow-x:auto;background:var(--bg-soft);padding:var(--s2);border-radius:8px}
……@@ -1291,7 +1304,8 @@ export function knocks (rows) {
12911304 const items = rows.length
12921305 // ⛔ A stranger who knocked has NO profile — we hold nothing of theirs. The key prefix is the
12931306 // only thing on the row that says who this is, so it stays on screen and only the rest folds.
1294 ? rows.map((k) => '<li><code>' + short(k.feed) + '…</code>' + keyline('full key', k.feed) +
1307 ? rows.map((k) => '<li><span class="av av-dot"></span><code>' + short(k.feed) + '…</code>' +
1308 keyline('full key', k.feed) +
12951309 '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' +
12961310 '<button>follow them</button></form></li>').join('')
12971311 : '<li>Nobody has knocked.</li>'
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.40",
3 "version": "0.0.41",
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 +1 -1
……@@ -40,7 +40,7 @@ const pages = {
4040 // ⛔ The harness covered six of fifteen pages, so an audit of "the design" was an audit of the
4141 // timeline. Every page a READER lands on belongs here; the ones behind a POST (theme, handle,
4242 // namespace) are forms inside settings and are covered by it.
43 search: () => search('ups', rows.slice(0, 2), 1, [A], profiles, null,
43 search: () => search('ups', rows.slice(0, 2), 1, [{ author: A, ...profiles[A] }], profiles, null,
4444 [{ handle: 'tabertron0', name: 'tabertron', feed: A, verdict: 'confirmed' }]),
4545 mentions: () => mentions([rows[0]], profiles),
4646 rooms: () => rooms([
test/keys.test.js +43 -0
……@@ -79,3 +79,46 @@ test('⚠ keyline is the ONE way this is done', async (t) => {
7979 t.ok(out.includes(A), 'carries the whole key')
8080 t.ok(out.includes('<summary>'), 'and says what it is before you open it')
8181})
82
83// ── every row that names a person has a face ──────────────────────────────────────────────────
84//
85// Mastodon draws a 40px avatar on every notification row; Bluesky does the same on follow lists.
86// Ours drew one only when the person had uploaded a picture, and NOTHING when they had not — while
87// post rows always draw a grey circle. So the timeline had a left edge and every list did not, and
88// a list of people read as loose text rather than as rows.
89
90test('★★ a person with NO picture still occupies the picture', async (t) => {
91 const { who } = await import('../lib/pages.js')
92 const withPic = who(A, { [A]: { handle: 'a0', avatar: 'ff'.repeat(32) } })
93 const noPic = who(A, { [A]: { handle: 'a0' } })
94 t.ok(withPic.includes('<img'), 'a picture is shown when there is one')
95 // ⛔ Not an empty string. The placeholder is what gives every row the same left edge; without it
96 // the rows that happen to have pictures are indented and the rest are not.
97 t.ok(/class="av[- ]/.test(noPic) || noPic.includes('av-dot'),
98 'and a placeholder stands in its place when there is not')
99})
100
101test('★ the lists that name people all use the SAME renderer', async (t) => {
102 const { following, notifications } = await import('../lib/pages.js')
103 const bare = { [A]: { handle: 'a0' } }
104 for (const [name, html] of [
105 ['following', following([A], [], bare)],
106 ['notifications', notifications([{ kind: 'follow', author: A, target: null }], bare)]
107 ]) {
108 // One renderer, so a change to how a person looks lands everywhere at once rather than in the
109 // one place somebody remembered.
110 t.ok(html.includes('class="byline"'), name + ' renders people through who()')
111 t.ok(/av-dot|class="av"/.test(html), name + ' shows a face or its placeholder')
112 }
113})
114
115test('⛔ a People result NAMES somebody', async (t) => {
116 const { search } = await import('../lib/pages.js')
117 // ⛔ The daemon's shape, pinned. `searchProfiles` returns {author, ...profile}; the harness fixture
118 // passed bare keys, so every People row rendered a placeholder circle, a bullet and no name — and
119 // it looked exactly like a styling bug rather than a fixture that did not match the daemon.
120 const html = search('a', [], 0, [{ author: A, handle: 'tabertron0', name: 'tabertron' }], null, null, [])
121 const section = html.slice(html.indexOf('<h2>People</h2>'))
122 t.ok(section.includes('tabertron0'), 'the row says who it found')
123 t.ok(section.includes('/u/' + A), 'and links to them')
124})