| … | … | @@ -268,9 +268,9 @@ export function byline (p) { |
| 268 | 268 | // per-post byline did not, so the same account had a picture on its profile and a grey circle in the |
| 269 | 269 | // timeline. https only, and no-referrer so browsing here does not leak the page you are on. |
| 270 | 270 | 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">` |
| 272 | 272 | : (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">` |
| 274 | 274 | : '<span class="pfp none" aria-hidden="true"></span>') |
| 275 | 275 | // The DID is kept as the link title, not printed: it is the identity that matters for verification, |
| 276 | 276 | // but it should cost a hover rather than a line. |
| … | … | @@ -330,7 +330,11 @@ export function renderPost (p) { |
| 330 | 330 | const badges = (local ? '<span class="badge">local-only</span>' : '') + renderReach(p) + lbls |
| 331 | 331 | // Author ABOVE the text (a reader needs to know who is speaking before they read it) and the |
| 332 | 332 | // 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>` |
| 334 | 338 | // Like count is shown only when non-zero: a row of "0" on every post is noise, and an absent count |
| 335 | 339 | // reads correctly as "none" without spending pixels saying so. |
| 336 | 340 | // 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) { |
| 346 | 350 | // way to START a thread from a timeline — you can only join one that already exists. The icon is the |
| 347 | 351 | // affordance; the number is incidental. |
| 348 | 352 | 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>` |
| 350 | 354 | // Quote — a plain link that opens the composer pre-loaded with this post, so quoting works with |
| 351 | 355 | // scripting off. Only for a viewer who can write; an anonymous reader gets no dead control. |
| 352 | 356 | // `quotable === false` is stamped by the post page from the postgate. Offering a control whose write |
| 353 | 357 | // 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>` : '' |
| 355 | 359 | // Delete — YOUR OWN posts only, and `isOwn` is stamped by withViewer from the viewer's DID. The server |
| 356 | 360 | // re-checks ownership regardless: a hidden control is not a permission check. |
| 357 | 361 | // |
| … | … | @@ -361,20 +365,20 @@ export function renderPost (p) { |
| 361 | 365 | // Bookmark — private, so it is deliberately quiet: no count, no shared state, just on or off. A plain |
| 362 | 366 | // form, like every other control here. |
| 363 | 367 | 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>` |
| 365 | 369 | : '' |
| 366 | 370 | // Report — only on someone ELSE's post (reporting your own is meaningless) and only for a viewer who |
| 367 | 371 | // could act. Needs JS and a session, and says so rather than failing silently (web/static/report.js). |
| 368 | 372 | 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>` |
| 370 | 374 | : '' |
| 371 | 375 | // Edit sits BEFORE delete and is a plain link, not a <details> confirm: editing is reversible |
| 372 | 376 | // (edit again), deleting is not. Presenting them with equal weight would invite the wrong one. |
| 373 | 377 | 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>` |
| 375 | 379 | : '' |
| 376 | 380 | 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>` |
| 378 | 382 | : '' |
| 379 | 383 | // Repost mirrors like: a plain form toggle, count hidden at zero, anonymous sees the count only. |
| 380 | 384 | const rpCount = p.repostCount > 0 ? `<span class="rps">${p.repostCount}</span>` : '' |
| … | … | @@ -392,7 +396,19 @@ export function renderPost (p) { |
| 392 | 396 | const editedMark = p.editedAt |
| 393 | 397 | ? ` <span class="edited" title="edited ${esc(p.editedAt)} — the original time is kept">(edited)</span>` |
| 394 | 398 | : '' |
| 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>` |
| 396 | 412 | // The WHOLE ENTRY opens the thread. Until now the only ways in were the timestamp and the ↩ icon — |
| 397 | 413 | // two ~10px targets on a card as wide as the page — so the reply view, which is where a conversation |
| 398 | 414 | // 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)} |
| 496 | 512 | to a profile, a tag/mention/url in the text goes where it points, like/repost stay buttons, and a |
| 497 | 513 | collapsed post's <summary> still toggles rather than navigating. Anything NOT listed here (the text |
| 498 | 514 | 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} |
| 500 | 516 | /* A click target with no hover state is invisible until you happen to click it. Kept faint: it must |
| 501 | 517 | read as "this row is live", not as a selected state. */ |
| 502 | 518 | .post:hover{background:var(--bg-soft)} |
| … | … | @@ -516,7 +532,7 @@ details.post>summary{cursor:pointer;color:var(--fg-faint);font-style:italic;list |
| 516 | 532 | .byline{display:flex;align-items:center;gap:.5rem;text-decoration:none;color:inherit;min-width:0} |
| 517 | 533 | .byline:hover .name{text-decoration:underline} |
| 518 | 534 | .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} |
| 520 | 536 | .pfp.none{display:inline-block;background:var(--line)} |
| 521 | 537 | .pfp.big{width:72px;height:72px} |
| 522 | 538 | .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, |
| 692 | 708 | .likebtn,.rpbtn,.followbtn{transition:transform .08s ease,color .12s ease,opacity .12s ease;touch-action:manipulation} |
| 693 | 709 | /* The hit area is stated, not inherited. It used to fall out of glyph size plus padding, which meant |
| 694 | 710 | 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} |
| 699 | 744 | .likebtn:active,.rpbtn:active,.followbtn:active{transform:scale(.88)} |
| 700 | 745 | .likebtn[disabled],.rpbtn[disabled],.followbtn[disabled]{opacity:.55;cursor:default} |
| 701 | 746 | .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 |
| 791 | 836 | .phead{gap:var(--s2);margin-bottom:var(--s2)} |
| 792 | 837 | .byline .name{font-size:var(--f2)} |
| 793 | 838 | .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. */ |
| 794 | 841 | .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} |
| 795 | 845 | .inreply{color:var(--fg-faint);font-size:var(--f0)} |
| 796 | 846 | .composer{background:var(--bg-soft);border:1px solid var(--line);border-radius:var(--r3);padding:var(--s3);margin-bottom:var(--s5)} |
| 797 | 847 | .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 |
| 827 | 877 | .post{padding:var(--s4) 0;border-radius:0} |
| 828 | 878 | .media.multi{gap:var(--s1)} |
| 829 | 879 | .meta{gap:var(--s2)} |
| 830 | | .pfp{width:32px;height:32px} |
| 831 | 880 | } |
| 832 | 881 | @media(prefers-color-scheme:dark){ |
| 833 | 882 | :root{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f; |