OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / eef038f

render: two-column post row — avatar beside the content, not above it
The largest single thing the timeline was missing. Full-width text under a small avatar reads as a log; every mainstream client puts the avatar in its own column with the body indented beside it, and that is most of why ours did not look like one. avatar moves out of byline() into avatarLink(), its own column .prow grid, 42px + 10px gutter — bsky's PostFeedItem size={42}, gap: 10 .pbody min-width:0 Two decisions worth keeping: The grid is on an inner .prow, NOT on .post. .post doubles as the <details> element in the collapsed-moderation case, and a grid there would lay the <summary> out as a grid column. Wrapping only the body leaves the summary untouched — verified by rendering both a muted and a labelled post. .pbody's min-width:0 is load-bearing, not decoration. A grid item's min-width defaults to auto, so one long unbroken word — a URL, a DID, both common here — would refuse to shrink and push the card past the viewport instead of wrapping. avatarLink() is aria-hidden with tabindex="-1", for the same reason .pstretch is: the byline already links to that profile, so this adds a mouse target rather than a destination. A keyboard or screen-reader user should not walk to the same profile twice per post. Verified: full suite green (the render tests assert class="pfp", "pfp none" and "byline" — all still emitted, now from two elements instead of one); no overflow at 360/390/768/1280 in light or dark; collapsed moderation still collapses; and a nested thread does not compound the .tnode indent with the new column into a staircase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eef038fd8fb97aa46c3b731090b1fd300b53788f
by plankatron <fleeky@gmail.com> · 2026-08-24T19:11:02.000Z

Changed files (2) +42 -7

lib/render.js +41 -6
……@@ -260,21 +260,35 @@ export function byline (p) {
260260 // you tell two accounts with the same display name apart.
261261 const sub = (a.handleVerified && a.handle && a.displayName) ? ` <span class="handle">@${esc(a.handle)}</span>` : ''
262262 const href = `/profile/${encodeURIComponent(did)}`
263 // The DID is kept as the link title, not printed: it is the identity that matters for verification,
264 // but it should cost a hover rather than a line.
265 //
266 // The avatar is NOT in here. It renders as its own column beside the post body (see .prow), which is
267 // the layout every mainstream client uses and the one thing our timeline most obviously lacked.
268 // avatarLink() below emits it; both point at the same profile.
269 return `<a class="byline" href="${href}" title="${esc(did)}"><span class="name">${esc(name)}</span>${sub}</a>`
270}
271
272// The avatar column. A separate anchor to the same profile as the byline, so it is a real link and a
273// comfortable target — but aria-hidden and out of the tab order, for the same reason .pstretch is: it
274// adds a MOUSE target to a destination the byline already provides, it does not add a destination.
275// A keyboard or screen-reader user should not walk to the same profile twice per post.
276export function avatarLink (p) {
277 const a = p.author || {}
278 const did = p.did
263279 // The appview hands back the RAW blob ref (it deliberately does not duplicate blobCid's knowledge of
264280 // the four shapes a ref arrives in); accept either that or an already-extracted cid.
265281 const cid = a.avatarCid || blobCid(a.avatar && a.avatar.ref)
266282 // `avatarUrl` is a LENS-supplied absolute URL, used only when we hold no blob of our own — a bsky
267283 // author whose profile record this node has never indexed. The profile PAGE got this in 0.3.12; the
268 // per-post byline did not, so the same account had a picture on its profile and a grey circle in the
284 // per-post avatar did not, so the same account had a picture on its profile and a grey circle in the
269285 // timeline. https only, and no-referrer so browsing here does not leak the page you are on.
270286 const av = (cid && isSafeCid(cid))
271287 ? `<img class="pfp" src="${esc(blobUrl(did, cid))}" alt="" loading="lazy" width="42" height="42">`
272288 : (a.avatarUrl && /^https:\/\//i.test(a.avatarUrl)
273289 ? `<img class="pfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="42" height="42" referrerpolicy="no-referrer">`
274290 : '<span class="pfp none" aria-hidden="true"></span>')
275 // The DID is kept as the link title, not printed: it is the identity that matters for verification,
276 // but it should cost a hover rather than a line.
277 return `<a class="byline" href="${href}" title="${esc(did)}">${av}<span class="name">${esc(name)}</span>${sub}</a>`
291 return `<a class="pavatar" href="/profile/${encodeURIComponent(did)}" aria-hidden="true" tabindex="-1">${av}</a>`
278292}
279293
280294// One post. did → a real /profile/<did> link; moderation (hide/warn/muted) collapses into a native
……@@ -427,7 +441,18 @@ export function renderPost (p) {
427441 // Suppressed on the focused post of a thread page (`p.focused`): a page linking to itself is a dead
428442 // click, and the overlay would sit on top of the one post the reader opened the page to read.
429443 const stretch = p.focused ? '' : `<a class="pstretch" href="${postHref(p)}" aria-hidden="true" tabindex="-1"></a>`
430 const body = `${stretch}${head}${inReply}<div class="text">${text}</div>${renderEmbed(p)}${renderVideo(p)}${renderExternal(p)}${renderQuote(p)}${meta}`
444 // ★ TWO COLUMNS: avatar beside the content, not above it. This is the layout every mainstream client
445 // uses and the largest single thing our timeline was missing — full-width text under a small avatar
446 // reads as a log, not a feed.
447 //
448 // Done as an inner .prow rather than by making .post itself a grid, because .post is ALSO the
449 // <details> element in the collapsed-moderation case; a grid there would lay the <summary> out as a
450 // column. The row wraps only the body, so the summary keeps sitting above it untouched.
451 //
452 // .pbody carries min-width:0 in CSS — without it a long unbroken word in a post makes the text
453 // column refuse to shrink and pushes the card wider than the screen.
454 const row = `<div class="prow">${avatarLink(p)}<div class="pbody">${head}${inReply}<div class="text">${text}</div>${renderEmbed(p)}${renderVideo(p)}${renderExternal(p)}${renderQuote(p)}${meta}</div></div>`
455 const body = `${stretch}${row}`
431456 const collapse = p.treatment === 'hide' || p.treatment === 'warn' || p.muted
432457 if (collapse) {
433458 const reason = p.muted ? '🔇 muted account' : (p.treatment === 'hide' ? `◼ hidden by a label (${esc(labels.join(', '))})` : `⚠ ${esc(labels.join(', '))}`)
……@@ -512,7 +537,7 @@ nav{margin:.4rem 0 1rem;display:flex;gap:1rem;font-size:var(--f1)}
512537 to a profile, a tag/mention/url in the text goes where it points, like/repost stay buttons, and a
513538 collapsed post's <summary> still toggles rather than navigating. Anything NOT listed here (the text
514539 itself, images, empty space, padding) falls through to the thread — which is the point. */
515.post .byline,.post .phead .permalink,.post .inreply a,.post .text a,.post .meta a,.post .meta form,.post .media a,.post .quote,.post .card,.post .delw,details.post>summary{position:relative;z-index:1}
540.post .byline,.post .pavatar,.post .phead .permalink,.post .inreply a,.post .text a,.post .meta a,.post .meta form,.post .media a,.post .quote,.post .card,.post .delw,details.post>summary{position:relative;z-index:1}
516541/* A click target with no hover state is invisible until you happen to click it. Kept faint: it must
517542 read as "this row is live", not as a selected state. */
518543.post:hover{background:var(--bg-soft)}
……@@ -839,6 +864,16 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
839864/* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between
840865 them is padding INSIDE a target, not dead space between two small ones. */
841866.meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)}
867/* ── two-column post row ───────────────────────────────────────────────────────────────────────────
868 Avatar column, then everything else. 42px and a 10px gutter, matching bsky's PostFeedItem
869 (size={42}, gap: 10). Applied to an inner .prow rather than to .post, because .post doubles as the
870 <details> element for collapsed moderation and a grid there would column-lay the <summary>. */
871.prow{display:grid;grid-template-columns:42px 1fr;column-gap:10px;align-items:start}
872/* Load-bearing. A grid item's min-width defaults to auto, so one long unbroken word (a URL, a DID)
873 would refuse to shrink and push the whole card past the viewport instead of wrapping. */
874.pbody{min-width:0}
875.pavatar{display:block;line-height:0;text-decoration:none}
876/* The avatar stays 42px on a phone too — bsky does not shrink it, and it is what anchors the row. */
842877/* The timestamp now rides the byline row. Muted and small: it answers "when", it is not a control. */
843878.phead .permalink{color:var(--fg-faint);font-size:var(--f0);text-decoration:none;white-space:nowrap}
844879.phead .permalink:hover{text-decoration:underline}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.72",
3 "version": "0.3.73",
44 "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",