| … | … | @@ -48,9 +48,20 @@ const short = (k) => esc(String(k || '').slice(0, 8)) |
| 48 | 48 | */ |
| 49 | 49 | export function who (author, profiles, { avatar = true } = {}) { |
| 50 | 50 | 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>') | |
| 54 | 65 | const handle = p && p.handle ? '<span class="handle">@' + esc(p.handle) + '</span> ' : '' |
| 55 | 66 | // The self-label is not bold when a handle is present: two names in one weight would read as one |
| 56 | 67 | // 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} |
| 321 | 332 | .keyline[open] summary::before{transform:rotate(90deg)} |
| 322 | 333 | .keyline code{display:block;color:var(--dim);word-break:break-all;margin-top:.25rem; |
| 323 | 334 | -webkit-user-select:all;user-select:all} |
| 324 | img.av{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;object-fit:cover} | |
| 335 | img.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)} | |
| 325 | 338 | img.av-lg{width:96px;height:96px;border-radius:50%;object-fit:cover} |
| 326 | 339 | label{display:block;margin:.4rem 0} |
| 327 | 340 | pre{overflow-x:auto;background:var(--bg-soft);padding:var(--s2);border-radius:8px} |
| … | … | @@ -1291,7 +1304,8 @@ export function knocks (rows) { |
| 1291 | 1304 | const items = rows.length |
| 1292 | 1305 | // ⛔ A stranger who knocked has NO profile — we hold nothing of theirs. The key prefix is the |
| 1293 | 1306 | // 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) + | |
| 1295 | 1309 | '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' + |
| 1296 | 1310 | '<button>follow them</button></form></li>').join('') |
| 1297 | 1311 | : '<li>Nobody has knocked.</li>' |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.40", | |
| 3 | "version": "0.0.41", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -40,7 +40,7 @@ const pages = { |
| 40 | 40 | // ⛔ The harness covered six of fifteen pages, so an audit of "the design" was an audit of the |
| 41 | 41 | // timeline. Every page a READER lands on belongs here; the ones behind a POST (theme, handle, |
| 42 | 42 | // 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, | |
| 44 | 44 | [{ handle: 'tabertron0', name: 'tabertron', feed: A, verdict: 'confirmed' }]), |
| 45 | 45 | mentions: () => mentions([rows[0]], profiles), |
| 46 | 46 | rooms: () => rooms([ |
| … | … | @@ -79,3 +79,46 @@ test('⚠ keyline is the ONE way this is done', async (t) => { |
| 79 | 79 | t.ok(out.includes(A), 'carries the whole key') |
| 80 | 80 | t.ok(out.includes('<summary>'), 'and says what it is before you open it') |
| 81 | 81 | }) |
| 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 | ||
| 90 | test('★★ 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 | ||
| 101 | test('★ 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 | ||
| 115 | test('⛔ 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 | }) |