OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 7978e3d

render: rebuild the post row — 42px avatar, one-line byline, 44px targets
avatar 36px (32 on mobile) -> 42px everywhere, matching PostFeedItem size={42} byline name / handle / time on ONE line; the timestamp moves up out of .meta .meta becomes purely a controls row: reply, repost, like, and an overflow targets every control is a real 44x44 box, measured The timestamp moving is what makes the row read: a reader asks who and when together, and splitting them cost a whole line per post while leaving .meta a mix of "when" and "act on this". It stays the permalink, and is raised above the stretched-link overlay so it keeps its own hover and title. ★ The overflow menu is forced by arithmetic, not taste. Seven controls each need a 44px target; seven targets plus their counts exceed the 366px a 390px phone gives, and the row wrapped to two lines. Reply/repost/like stay visible — the three bsky keeps, and the three that act on the post itself. Quote, bookmark, report, edit and delete move behind a native <details>, the same no-JS pattern the delete confirm already uses, absolutely positioned so opening it never reflows the feed. Its controls keep 44x44 too; an overflow is not an excuse for a smaller target. Two wrong turns, both caught by measuring rather than looking: - A transparent ::after (the CSS equivalent of the hitSlop bsky sets on every control) fixed height but could not fix width — a fixed inset on a 6px glyph still measured 22x39 for .qbtn — and insets large enough would have overlapped neighbouring targets. Real 44px boxes make target == box, so overlap is impossible by construction. - I wrote a rationale after a comment's closing */ TWICE, leaving prose loose in the stylesheet so the parser dropped the rules under it. Both times the edit silently did nothing and the render showed it. Measured: all 7 controls 44x44 (and 44x44 inside the open menu); no overflow at 360/390/768/1280; dark body rgb(17,17,17). Full suite green. One test updated, not weakened: it asserted the literal `.controls select{min-width:0` and that selector gained `.controls button` when buttons were themed for dark mode. Same property, wider selector. NOT done, deliberately: bsky indents post text into a column beside the avatar. That needs renderPost restructured into two columns and would touch the stretched link, media, quotes and thread nesting — a separate change, not a style tweak. Phase 3 of the visual-parity order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7978e3d827aab5e24cd76eef0a956727d76f7133
by plankatron <fleeky@gmail.com> · 2026-08-24T15:53:18.000Z

Changed files (3) +70 -19

lib/render.js +66 -17
……@@ -268,9 +268,9 @@ export function byline (p) {
268268 // per-post byline did not, so the same account had a picture on its profile and a grey circle in the
269269 // timeline. https only, and no-referrer so browsing here does not leak the page you are on.
270270 const av = (cid && isSafeCid(cid))
271 ? `<img class="pfp" src="${esc(blobUrl(did, cid))}" alt="" loading="lazy" width="36" height="36">`
271 ? `<img class="pfp" src="${esc(blobUrl(did, cid))}" alt="" loading="lazy" width="42" height="42">`
272272 : (a.avatarUrl && /^https:\/\//i.test(a.avatarUrl)
273 ? `<img class="pfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="36" height="36" referrerpolicy="no-referrer">`
273 ? `<img class="pfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="42" height="42" referrerpolicy="no-referrer">`
274274 : '<span class="pfp none" aria-hidden="true"></span>')
275275 // The DID is kept as the link title, not printed: it is the identity that matters for verification,
276276 // but it should cost a hover rather than a line.
……@@ -330,7 +330,11 @@ export function renderPost (p) {
330330 const badges = (local ? '<span class="badge">local-only</span>' : '') + renderReach(p) + lbls
331331 // Author ABOVE the text (a reader needs to know who is speaking before they read it) and the
332332 // timestamp below as a permalink — the one affordance that lets a post be shared at all.
333 const head = `<div class="phead">${byline(p)}${badges}</div>`
333 // Timestamp sits ON the byline line, not below it. A reader asks who and when together, and splitting
334 // them cost a whole row per post while leaving .meta a mixed bag of "when" and "act on this". It is
335 // still the permalink — the one affordance that lets a post be shared at all.
336 const stamp = `<a class="permalink" href="${postHref(p)}"><time datetime="${esc(p.createdAt)}" title="${esc(p.createdAt)}">${esc(relTime(p.createdAt))}</time></a>`
337 const head = `<div class="phead">${byline(p)}${stamp}${badges}</div>`
334338 // Like count is shown only when non-zero: a row of "0" on every post is noise, and an absent count
335339 // reads correctly as "none" without spending pixels saying so.
336340 // Like is a plain form POST so it works with scripting off, and carries `return` so the redirect lands
……@@ -346,12 +350,12 @@ export function renderPost (p) {
346350 // way to START a thread from a timeline — you can only join one that already exists. The icon is the
347351 // affordance; the number is incidental.
348352 const replyLabel = p.replyCount > 0 ? `${p.replyCount} repl${p.replyCount === 1 ? 'y' : 'ies'} — open thread` : 'reply to this post'
349 const replies = ` · <a class="replyc" href="${postHref(p)}" aria-label="${esc(replyLabel)}" title="${esc(replyLabel)}"><span aria-hidden="true">↩${p.replyCount > 0 ? ' ' + p.replyCount : ''}</span></a>`
353 const replies = `<a class="replyc" href="${postHref(p)}" aria-label="${esc(replyLabel)}" title="${esc(replyLabel)}"><span aria-hidden="true">↩${p.replyCount > 0 ? ' ' + p.replyCount : ''}</span></a>`
350354 // Quote — a plain link that opens the composer pre-loaded with this post, so quoting works with
351355 // scripting off. Only for a viewer who can write; an anonymous reader gets no dead control.
352356 // `quotable === false` is stamped by the post page from the postgate. Offering a control whose write
353357 // we would refuse is worse than not offering it.
354 const quoteBtn = (p.canLike && p.quotable !== false) ? ` · <a class="qbtn" href="/?quote=${encodeURIComponent(uri)}" aria-label="quote this post"><span aria-hidden="true">❝</span></a>` : ''
358 const quoteBtn = (p.canLike && p.quotable !== false) ? `<a class="qbtn" href="/?quote=${encodeURIComponent(uri)}" aria-label="quote this post"><span aria-hidden="true">❝</span></a>` : ''
355359 // Delete — YOUR OWN posts only, and `isOwn` is stamped by withViewer from the viewer's DID. The server
356360 // re-checks ownership regardless: a hidden control is not a permission check.
357361 //
……@@ -361,20 +365,20 @@ export function renderPost (p) {
361365 // Bookmark — private, so it is deliberately quiet: no count, no shared state, just on or off. A plain
362366 // form, like every other control here.
363367 const bmBtn = p.canLike
364 ? ` · <form class="bmf" method="POST" action="/api/bookmark"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="bmbtn${p.viewerBookmarked ? ' on' : ''}" aria-pressed="${p.viewerBookmarked ? 'true' : 'false'}" aria-label="${p.viewerBookmarked ? 'remove bookmark' : 'save this post'}" title="${p.viewerBookmarked ? 'remove bookmark' : 'bookmark'}">${p.viewerBookmarked ? '★' : '☆'}</button></form>`
368 ? `<form class="bmf" method="POST" action="/api/bookmark"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="bmbtn${p.viewerBookmarked ? ' on' : ''}" aria-pressed="${p.viewerBookmarked ? 'true' : 'false'}" aria-label="${p.viewerBookmarked ? 'remove bookmark' : 'save this post'}" title="${p.viewerBookmarked ? 'remove bookmark' : 'bookmark'}">${p.viewerBookmarked ? '★' : '☆'}</button></form>`
365369 : ''
366370 // Report — only on someone ELSE's post (reporting your own is meaningless) and only for a viewer who
367371 // could act. Needs JS and a session, and says so rather than failing silently (web/static/report.js).
368372 const repBtn = (p.canLike && !p.isOwn)
369 ? ` · <button class="reportbtn" type="button" data-uri="${esc(uri)}"${p.cid ? ` data-cid="${esc(p.cid)}"` : ''} aria-label="report this post" title="report">⚑</button>`
373 ? `<button class="reportbtn" type="button" data-uri="${esc(uri)}"${p.cid ? ` data-cid="${esc(p.cid)}"` : ''} aria-label="report this post" title="report">⚑</button>`
370374 : ''
371375 // Edit sits BEFORE delete and is a plain link, not a <details> confirm: editing is reversible
372376 // (edit again), deleting is not. Presenting them with equal weight would invite the wrong one.
373377 const editBtn = p.isOwn && !p.focusedEdit
374 ? ` · <a class="editlink" href="/edit/${encodeURIComponent(p.did)}/${encodeURIComponent(p.rkey)}?return=${encodeURIComponent(p.returnTo || '/')}" aria-label="edit this post">✎</a>`
378 ? `<a class="editlink" href="/edit/${encodeURIComponent(p.did)}/${encodeURIComponent(p.rkey)}?return=${encodeURIComponent(p.returnTo || '/')}" aria-label="edit this post">✎</a>`
375379 : ''
376380 const delBtn = p.isOwn
377 ? ` · <details class="delw"><summary aria-label="delete this post"><span aria-hidden="true">🗑</span></summary><form class="delf" method="POST" action="/api/delete"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="delbtn">really delete</button></form></details>`
381 ? `<details class="delw"><summary aria-label="delete this post"><span aria-hidden="true">🗑</span></summary><form class="delf" method="POST" action="/api/delete"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="delbtn">really delete</button></form></details>`
378382 : ''
379383 // Repost mirrors like: a plain form toggle, count hidden at zero, anonymous sees the count only.
380384 const rpCount = p.repostCount > 0 ? `<span class="rps">${p.repostCount}</span>` : ''
……@@ -392,7 +396,19 @@ export function renderPost (p) {
392396 const editedMark = p.editedAt
393397 ? ` <span class="edited" title="edited ${esc(p.editedAt)} — the original time is kept">(edited)</span>`
394398 : ''
395 const meta = `<div class="meta"><a class="permalink" href="${postHref(p)}"><time datetime="${esc(p.createdAt)}" title="${esc(p.createdAt)}">${esc(relTime(p.createdAt))}</time></a>${editedMark}${likeBtn}${repostBtn ? ' · ' + repostBtn : ''}${replies}${quoteBtn}${bmBtn}${repBtn}${editBtn}${delBtn}</div>`
399 // .meta is now purely the controls row: one job, so it can be spaced and sized as one.
400 //
401 // ★ PRIMARY THREE, then an overflow. This is forced by arithmetic, not taste: every control needs a
402 // 44px target, and seven of them plus their counts do not fit the 366px a 390px phone leaves — the
403 // row wrapped to two lines. Reply/repost/like are the ones bsky keeps visible, and they are the ones
404 // that act on the post itself; quote, bookmark, report, edit and delete are about YOUR relationship
405 // to it and can cost a tap. Native <details>, the same no-JS pattern the delete confirm already
406 // uses, so the menu opens with scripting off.
407 const secondary = quoteBtn + bmBtn + repBtn + editBtn + delBtn
408 const more = secondary
409 ? `<details class="morew"><summary aria-label="more actions" title="more actions"><span aria-hidden="true">\u22ef</span></summary><div class="moremenu">${secondary}</div></details>`
410 : ''
411 const meta = `<div class="meta">${replies}${repostBtn}${likeBtn}${more}${editedMark}</div>`
396412 // The WHOLE ENTRY opens the thread. Until now the only ways in were the timestamp and the ↩ icon —
397413 // two ~10px targets on a card as wide as the page — so the reply view, which is where a conversation
398414 // actually lives, was the hardest thing here to reach.
……@@ -496,7 +512,7 @@ nav{margin:.4rem 0 1rem;display:flex;gap:1rem;font-size:var(--f1)}
496512 to a profile, a tag/mention/url in the text goes where it points, like/repost stay buttons, and a
497513 collapsed post's <summary> still toggles rather than navigating. Anything NOT listed here (the text
498514 itself, images, empty space, padding) falls through to the thread — which is the point. */
499.post .byline,.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}
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}
500516/* A click target with no hover state is invisible until you happen to click it. Kept faint: it must
501517 read as "this row is live", not as a selected state. */
502518.post:hover{background:var(--bg-soft)}
……@@ -516,7 +532,7 @@ details.post>summary{cursor:pointer;color:var(--fg-faint);font-style:italic;list
516532.byline{display:flex;align-items:center;gap:.5rem;text-decoration:none;color:inherit;min-width:0}
517533.byline:hover .name{text-decoration:underline}
518534.byline .name{font-weight:600;font-size:var(--f2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
519.pfp{width:36px;height:36px;border-radius:50%;object-fit:cover;background:var(--line);flex:none}
535.pfp{width:42px;height:42px;border-radius:50%;object-fit:cover;background:var(--line);flex:none}
520536.pfp.none{display:inline-block;background:var(--line)}
521537.pfp.big{width:72px;height:72px}
522538.meta .permalink{color:var(--fg-faint);text-decoration:none}.meta .permalink:hover{text-decoration:underline}
……@@ -692,10 +708,39 @@ a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,
692708.likebtn,.rpbtn,.followbtn{transition:transform .08s ease,color .12s ease,opacity .12s ease;touch-action:manipulation}
693709/* The hit area is stated, not inherited. It used to fall out of glyph size plus padding, which meant
694710 dropping the font from 14.4px to 14px silently took it to 23.59px — under WCAG 2.2 AA 2.5.8, which
695 asks 24x24. A target size that moves when someone edits a font scale is not a target size. This is
696 the FLOOR, not the goal: Material asks 48dp and Apple 44pt, and the post-row rebuild raises it. */
697.likebtn,.rpbtn{display:inline-flex;align-items:center;justify-content:center;
698 min-width:24px;min-height:24px;padding:.3rem .45rem;margin:-.3rem -.1rem}
711 asks 24x24. A target size that moves when someone edits a font scale is not a target size.
712
713 24px is the floor; Material asks 48dp and Apple 44pt. Getting there by PADDING would make the
714 controls row 44px tall and push every post apart, so the target is expanded without expanding the
715 box: a transparent ::after stretched past the button's bounds. That is what bsky does natively —
716 PostControls sets an explicit hitSlop on every control — and this is the CSS equivalent. The inset
717 is half the row gap horizontally, so neighbouring targets meet exactly and never overlap.
718
719 First attempt grew the target with a transparent ::after, the way bsky's hitSlop does. Measured, it
720 only half worked: it fixed height everywhere but could not fix width, because a fixed inset applied
721 to a 6px-wide glyph still yields 22px (.qbtn measured 22x39, .replyc 36x39). And insets that DID
722 reach 44 would have overlapped their neighbours, so the wrong control gets tapped.
723
724 So the controls are real 44x44 boxes. Target equals box, which means neighbours cannot overlap by
725 construction, and the row can use a small gap: 7 controls x 44 + 6 x 4 = 332px, inside the 366px a
726 390px phone gives. */
727.likebtn,.rpbtn,.bmbtn,.reportbtn,.replyc,.qbtn,.editlink,.delw>summary,.morew>summary{
728 display:inline-flex;align-items:center;justify-content:center;
729 min-width:44px;min-height:44px;box-sizing:border-box}
730.likebtn,.rpbtn{padding:0;margin:0}
731/* Overflow menu. A native <details>, like the delete confirm, so it opens with scripting off. The
732 panel is absolutely positioned so opening it never reflows the timeline underneath — a menu that
733 pushes every post below it down is how you lose your place in a feed. */
734.morew{position:relative;display:inline-flex}
735.morew>summary{list-style:none;cursor:pointer;color:var(--fg-faint);border-radius:999px}
736.morew>summary::-webkit-details-marker{display:none}
737.morew>summary:hover{color:var(--fg);background:var(--bg-soft)}
738.morew[open]>summary{color:var(--fg);background:var(--bg-soft)}
739.moremenu{position:absolute;right:0;top:100%;z-index:20;display:flex;align-items:center;gap:var(--s1);
740 padding:var(--s1);background:var(--bg);border:1px solid var(--line);border-radius:var(--r2);
741 box-shadow:0 4px 16px rgba(0,0,0,.12)}
742/* The menu's own controls keep their 44px targets — an overflow is not an excuse for a smaller one. */
743.moremenu>*{flex:none}
699744.likebtn:active,.rpbtn:active,.followbtn:active{transform:scale(.88)}
700745.likebtn[disabled],.rpbtn[disabled],.followbtn[disabled]{opacity:.55;cursor:default}
701746.likebtn.failed,.rpbtn.failed,.followbtn.failed{color:var(--danger);animation:shake .25s}
……@@ -791,7 +836,12 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
791836.phead{gap:var(--s2);margin-bottom:var(--s2)}
792837.byline .name{font-size:var(--f2)}
793838.handle{color:var(--fg-faint);font-weight:400;font-size:var(--f1)}
839/* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between
840 them is padding INSIDE a target, not dead space between two small ones. */
794841.meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)}
842/* The timestamp now rides the byline row. Muted and small: it answers "when", it is not a control. */
843.phead .permalink{color:var(--fg-faint);font-size:var(--f0);text-decoration:none;white-space:nowrap}
844.phead .permalink:hover{text-decoration:underline}
795845.inreply{color:var(--fg-faint);font-size:var(--f0)}
796846.composer{background:var(--bg-soft);border:1px solid var(--line);border-radius:var(--r3);padding:var(--s3);margin-bottom:var(--s5)}
797847.composer textarea{border:1px solid var(--line);border-radius:var(--r1);background:var(--bg);resize:vertical}
……@@ -827,7 +877,6 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
827877 .post{padding:var(--s4) 0;border-radius:0}
828878 .media.multi{gap:var(--s1)}
829879 .meta{gap:var(--s2)}
830 .pfp{width:32px;height:32px}
831880}
832881@media(prefers-color-scheme:dark){
833882 :root{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.70",
3 "version": "0.3.71",
44 "description": "favela-web — 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",
test/render.test.mjs +3 -1
……@@ -767,7 +767,9 @@ const ctl = timelinePage({ result: { posts: [] }, viewer, canWrite: true })
767767has(ctl, '.controls{flex-wrap:wrap', 'the control row wraps instead of overflowing')
768768// min-width:0 is the load-bearing half — a flex item defaults to min-width:auto, so a select refuses to
769769// shrink below its longest option and pushes its siblings out rather than wrapping.
770has(ctl, '.controls select{min-width:0', 'and selects are allowed to shrink')
770// The selector gained `.controls button` when buttons were themed for dark mode — before that only
771// selects were, so a button kept the UA's light chrome. The property under test is unchanged.
772has(ctl, '.controls select,.controls button{min-width:0', 'and selects are allowed to shrink')
771773has(ctl, '#post-btn{margin-left:auto', 'the post button ends the row wherever it lands')
772774// A <select> is as wide as its LONGEST option, so no CSS can save a 28-character label.
773775not(ctl, 'syndicates to bsky</option>', 'the long option labels are gone')