| … | … | @@ -260,21 +260,35 @@ export function byline (p) { |
| 260 | 260 | // you tell two accounts with the same display name apart. |
| 261 | 261 | const sub = (a.handleVerified && a.handle && a.displayName) ? ` <span class="handle">@${esc(a.handle)}</span>` : '' |
| 262 | 262 | 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. |
| 276 | export function avatarLink (p) { |
| 277 | const a = p.author || {} |
| 278 | const did = p.did |
| 263 | 279 | // The appview hands back the RAW blob ref (it deliberately does not duplicate blobCid's knowledge of |
| 264 | 280 | // the four shapes a ref arrives in); accept either that or an already-extracted cid. |
| 265 | 281 | const cid = a.avatarCid || blobCid(a.avatar && a.avatar.ref) |
| 266 | 282 | // `avatarUrl` is a LENS-supplied absolute URL, used only when we hold no blob of our own — a bsky |
| 267 | 283 | // 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 |
| 269 | 285 | // timeline. https only, and no-referrer so browsing here does not leak the page you are on. |
| 270 | 286 | const av = (cid && isSafeCid(cid)) |
| 271 | 287 | ? `<img class="pfp" src="${esc(blobUrl(did, cid))}" alt="" loading="lazy" width="42" height="42">` |
| 272 | 288 | : (a.avatarUrl && /^https:\/\//i.test(a.avatarUrl) |
| 273 | 289 | ? `<img class="pfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="42" height="42" referrerpolicy="no-referrer">` |
| 274 | 290 | : '<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>` |
| 278 | 292 | } |
| 279 | 293 | |
| 280 | 294 | // One post. did → a real /profile/<did> link; moderation (hide/warn/muted) collapses into a native |
| … | … | @@ -427,7 +441,18 @@ export function renderPost (p) { |
| 427 | 441 | // Suppressed on the focused post of a thread page (`p.focused`): a page linking to itself is a dead |
| 428 | 442 | // click, and the overlay would sit on top of the one post the reader opened the page to read. |
| 429 | 443 | 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}` |
| 431 | 456 | const collapse = p.treatment === 'hide' || p.treatment === 'warn' || p.muted |
| 432 | 457 | if (collapse) { |
| 433 | 458 | 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)} |
| 512 | 537 | to a profile, a tag/mention/url in the text goes where it points, like/repost stay buttons, and a |
| 513 | 538 | collapsed post's <summary> still toggles rather than navigating. Anything NOT listed here (the text |
| 514 | 539 | 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} |
| 516 | 541 | /* A click target with no hover state is invisible until you happen to click it. Kept faint: it must |
| 517 | 542 | read as "this row is live", not as a selected state. */ |
| 518 | 543 | .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 |
| 839 | 864 | /* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between |
| 840 | 865 | them is padding INSIDE a target, not dead space between two small ones. */ |
| 841 | 866 | .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. */ |
| 842 | 877 | /* The timestamp now rides the byline row. Muted and small: it answers "when", it is not a control. */ |
| 843 | 878 | .phead .permalink{color:var(--fg-faint);font-size:var(--f0);text-decoration:none;white-space:nowrap} |
| 844 | 879 | .phead .permalink:hover{text-decoration:underline} |