// favela SSR render layer — pure view-model → HTML string. No DOM, no JS required to READ (the
// server renders posts, hashtags become real <a href="/tag/…"> links, and moderation collapse uses a
// native <details> so warn/hide reveal works with JS disabled). The same JSON APIs + client script
// remain as progressive enhancement (live 5s refresh), but a URL renders fully server-side — the
// "P2P-less window" (DESIGN §2.6/§4.1): no install, no JS, SEO-able, RSS/Atom for free (renderAtom).
//
// Pure + injected: takes plain view-models ({ posts, cursor }, a viewer descriptor) and returns
// strings. It touches no P2P/index state, so it is unit-testable in isolation.
import { SCOPE_COLLECTION } from './scopes.js'
import { VERSION } from './version.js'
import { reachFor } from './reach.js'
import { isSafeCid } from './blob-serve.js'
const LOCAL_NSID = SCOPE_COLLECTION.local
const TAG_RE = /(?:^|\s)#([\p{L}\p{N}_]+)/gu
export function esc (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
}
// Normalize an atproto blob `ref` to its CID string, tolerating every form the ref can arrive in:
// a decoded `{$link}` (how wj62's records store it), a dag-json `{'/':cid}`, a multiformats CID
// instance, or a bare string. Returns null if none match.
export function blobCid (ref) {
if (!ref) return null
if (typeof ref === 'string') return ref
if (ref.asCID === ref && typeof ref.toString === 'function') return ref.toString()
if (typeof ref.$link === 'string') return ref.$link
if (typeof ref['/'] === 'string') return ref['/']
return null
}
// The favela getBlob URL for a DID's blob — served from the SHARED overlay files (slice 3b).
function blobUrl (did, cid) {
return `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`
}
// The image list an embed carries, if any. Handles app.bsky.embed.images and the .media side of
// app.bsky.embed.recordWithMedia (a quote-post with attached images). Other embeds → no images.
function embedImages (embed) {
if (!embed) return []
if (embed.$type === 'app.bsky.embed.images' && Array.isArray(embed.images)) return embed.images
if (embed.$type === 'app.bsky.embed.recordWithMedia' && embed.media && embed.media.$type === 'app.bsky.embed.images' && Array.isArray(embed.media.images)) return embed.media.images
return []
}
// Render a post's image embeds to <img> tags pointing at favela's getBlob (the shared overlay bytes).
// alt text is escaped; a ref whose cid is missing/unsafe is skipped (never emitted as a src). Returns ''
// when there are no images. loading=lazy so a long timeline doesn't fetch every blob eagerly.
// A LINK CARD (app.bsky.embed.external). Self-contained in the record — title/description/uri, and a
// thumb blob — so unlike a quote it needs no resolution. Rendered as one anchor: the whole card is the
// link, which is what every other client does and what a reader expects.
//
// The uri is scheme-checked before it becomes an href (same rule renderRichText applies to link facets):
// a card is attacker-supplied content and javascript: must never survive into an attribute.
// app.bsky.embed.video — a blob, like an image. Rendered as a real <video controls> rather than a
// "this post has a video" placeholder: we already serve the bytes through getBlob, so there is nothing to
// gain by refusing to play them. preload=metadata so a timeline of videos does not pull megabytes.
export function renderVideo (p) {
const e = p && p.record && p.record.embed
const v = e && e.$type === 'app.bsky.embed.video' ? e
: (e && e.$type === 'app.bsky.embed.recordWithMedia' && e.media && e.media.$type === 'app.bsky.embed.video' ? e.media : null)
if (!v) return ''
const cid = blobCid(v.video && v.video.ref)
if (!cid || !isSafeCid(cid) || !p.did) return ''
const alt = esc(v.alt || '')
return `<div class="media"><video class="vid" controls preload="metadata" playsinline src="${esc(blobUrl(p.did, cid))}"${alt ? ` aria-label="${alt}"` : ''}></video></div>`
}
export function renderExternal (p) {
const e = p && p.record && p.record.embed
const ext = e && e.$type === 'app.bsky.embed.external' ? e.external
: (e && e.$type === 'app.bsky.embed.recordWithMedia' && e.media && e.media.$type === 'app.bsky.embed.external' ? e.media.external : null)
if (!ext || typeof ext.uri !== 'string' || !/^https?:\/\//i.test(ext.uri)) return ''
let host = ''
try { host = new URL(ext.uri).host.replace(/^www\./, '') } catch { host = '' }
const cid = blobCid(ext.thumb && ext.thumb.ref)
const thumb = (cid && isSafeCid(cid) && p.did)
? `<img class="cardimg" src="${esc(blobUrl(p.did, cid))}" alt="" loading="lazy">`
: ''
return `<a class="card" href="${esc(ext.uri)}" rel="noopener nofollow ugc" target="_blank">${thumb}<span class="cardtxt">${host ? `<span class="cardhost">${esc(host)}</span>` : ''}<span class="cardtitle">${esc(ext.title || ext.uri)}</span>${ext.description ? `<span class="carddesc">${esc(ext.description)}</span>` : ''}</span></a>`
}
// A QUOTE POST. `p.quoted` is attached by the appview (_resolveQuoted) — the record only embeds a
// strongRef, and before this it rendered as NOTHING: the commentary appeared with the post it was about
// invisible, which reads as a non-sequitur.
//
// Deliberately NOT renderPost: a quoted post gets no like/repost/reply controls of its own (acting on it
// belongs on its own page) and no nested quote, so a quote-of-a-quote cannot recurse a timeline into a
// tree. It IS a link to the quoted post, so the whole card navigates there.
//
// An unresolved quote says so rather than rendering an empty box — quoting a post outside this node's
// window is normal, and a blank card would read as a broken render instead of an honest gap.
export function renderQuote (p) {
const e = p && p.record && p.record.embed
const isQuote = e && (e.$type === 'app.bsky.embed.record' || e.$type === 'app.bsky.embed.recordWithMedia')
if (!isQuote) return ''
const q = p.quoted
if (!q || !q.record) {
const ref = e.$type === 'app.bsky.embed.record' ? e.record : (e.record && (e.record.record || e.record))
const uri = ref && typeof ref.uri === 'string' ? ref.uri : ''
return `<div class="quote missingq">quoted post is outside this node\'s window${uri ? `<br><code>${esc(uri)}</code>` : ''}</div>`
}
const a = q.author || {}
const name = a.displayName || (a.handleVerified && a.handle ? '@' + a.handle : shortDid(q.did))
const cid = a.avatarCid || blobCid(a.avatar && a.avatar.ref)
const av = (cid && isSafeCid(cid))
? `<img class="pfp qpfp" src="${esc(blobUrl(q.did, cid))}" alt="" loading="lazy" width="20" height="20">`
: (a.avatarUrl && /^https:\/\//i.test(a.avatarUrl)
? `<img class="pfp qpfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="20" height="20" referrerpolicy="no-referrer">`
: '<span class="pfp qpfp none" aria-hidden="true"></span>')
const when = q.record.createdAt ? `<time datetime="${esc(q.record.createdAt)}">${esc(relTime(q.record.createdAt))}</time>` : ''
const text = renderRichText(q.record.text || '', q.record.facets)
return `<a class="quote" href="${esc(postHref(q))}"><span class="qhead">${av}<span class="qname">${esc(name)}</span>${when}</span><span class="qtext">${text}</span></a>`
}
export function renderEmbed (p) {
const imgs = embedImages(p && p.record && p.record.embed)
if (!imgs.length) return ''
const many = imgs.length > 1
const tags = imgs.map((im) => {
const cid = blobCid(im && im.image && im.image.ref)
if (!cid || !isSafeCid(cid)) return ''
const url = blobUrl(p.did, cid)
const alt = esc((im && im.alt) || '')
// Each image is a REAL LINK to its own full-size bytes. With no JS that opens the image — which is
// what a reader wants and what the timeline could not do at all before. With JS, actions.js
// intercepts it and shows the lightbox. It also has to be a link for another reason: the whole post
// is a stretched anchor to its thread, so without an element of its own an image click would open
// the thread instead of the picture. `.post .media a` is raised above that overlay in CSS.
return `<a class="lb" href="${esc(url)}"${alt ? ` data-alt="${alt}"` : ''}><img src="${esc(url)}" alt="${alt}" loading="lazy"></a>`
}).filter(Boolean).join('')
return tags ? `<div class="media${many ? ' multi' : ''}">${tags}</div>` : ''
}
// "older posts" — the cursor was computed and thrown away on every page, so a reader could only ever see
// the newest 50 and there was no way to reach anything before that. A plain link with ?cursor=, so paging
// works with scripting off; `cursor` null means the stream ended and nothing renders.
export function moreLink (cursor, baseHref = '/') {
if (!cursor) return ''
const sep = baseHref.includes('?') ? '&' : '?'
return `<a class="more" href="${esc(baseHref + sep + 'cursor=' + encodeURIComponent(cursor))}" rel="next">older posts →</a>`
}
// A bare URL in post text used to render as inert characters, which is a daily papercut — people post
// links constantly. Matched conservatively: stop before trailing punctuation so "see https://x.com." does
// not swallow the full stop, and never inside an existing tag (this runs on ESCAPED text, so there are no
// tags to be inside).
const URL_RE = /\bhttps?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g
function linkifyUrls (escaped) {
return escaped.replace(URL_RE, (u) => `<a class="link" href="${u}" rel="noopener nofollow ugc" target="_blank">${u}</a>`)
}
// Escape text, then linkify #hashtags into real server-navigable /tag/<tag> links (no JS needed) and
// bare URLs into anchors. This is the FALLBACK path — see renderRichText for the authoritative one.
export function renderText (t) {
return linkifyUrls(esc(t)).replace(TAG_RE, (m, tag) => {
const lead = m.slice(0, m.length - tag.length - 1) // preserve the leading space/BOL the regex ate
return `${lead}<a class="tag" href="/tag/${encodeURIComponent(tag.toLowerCase())}">#${esc(tag)}</a>`
})
}
// atproto rich text is NOT the post text re-parsed — it is `record.facets`, byte ranges into the UTF-8
// encoding of `text` carrying links, mentions and tags. Re-deriving them with a regex is guesswork that
// disagrees with what the author actually published: a link whose display text differs from its target,
// a mention, or a tag the writer did not spell with a '#' are all invisible to a regex and all normal.
//
// Offsets are BYTE offsets, so slicing must go through the UTF-8 encoding — slicing the JS string would
// mis-cut anything past the first non-ASCII character, which is most of the world's text.
export function renderRichText (text, facets) {
const t = String(text == null ? '' : text)
if (!Array.isArray(facets) || !facets.length) return renderText(t)
let bytes
try { bytes = new TextEncoder().encode(t) } catch { return renderText(t) }
const dec = new TextDecoder()
const slice = (a, b) => { try { return dec.decode(bytes.slice(a, b)) } catch { return '' } }
// Sort + drop anything overlapping or out of range: a malformed facet set must degrade to plain text,
// never to interleaved tags.
const ranges = facets
.filter((f) => f && f.index && Number.isInteger(f.index.byteStart) && Number.isInteger(f.index.byteEnd))
.filter((f) => f.index.byteStart >= 0 && f.index.byteEnd <= bytes.length && f.index.byteStart < f.index.byteEnd)
.sort((a, b) => a.index.byteStart - b.index.byteStart)
let out = ''
let at = 0
for (const f of ranges) {
if (f.index.byteStart < at) continue // overlaps the previous facet — skip rather than nest
out += linkifyUrls(esc(slice(at, f.index.byteStart)))
const label = esc(slice(f.index.byteStart, f.index.byteEnd))
const feat = (Array.isArray(f.features) ? f.features : [])[0] || {}
const type = String(feat.$type || '')
if (type.endsWith('#link') && typeof feat.uri === 'string' && /^https?:\/\//i.test(feat.uri)) {
out += `<a class="link" href="${esc(feat.uri)}" rel="noopener nofollow ugc" target="_blank">${label}</a>`
} else if (type.endsWith('#tag') && typeof feat.tag === 'string') {
out += `<a class="tag" href="/tag/${encodeURIComponent(feat.tag.toLowerCase())}">${label}</a>`
} else if (type.endsWith('#mention') && typeof feat.did === 'string') {
out += `<a class="mention" href="/profile/${encodeURIComponent(feat.did)}">${label}</a>`
} else {
out += label // unknown feature: show the text, invent no link
}
at = f.index.byteEnd
}
return out + linkifyUrls(esc(slice(at, bytes.length)))
}
// A minimal at:// splitter for rendering links. Deliberately separate from lib/strongref.js's
// parseAtUri: that one exists to gate a WRITE and is strict about nsids; this only needs did + rkey to
// build a href, and render must stay free of store-facing imports.
function parseAtUriLite (uri) {
const parts = String(uri || '').replace(/^at:\/\//, '').split('/')
return parts.length === 3 && parts[0].startsWith('did:') ? { did: parts[0], rkey: parts[2] } : null
}
function uriOf (p) {
return `at://${p.did}/${(p.record && p.record.$type) || 'app.bsky.feed.post'}/${p.rkey}`
}
// A post's own page. `uriOf` was computed and thrown away before this — there was no way to link to a
// single post, which also meant no way to SHARE one.
export function postHref (p) {
return `/post/${encodeURIComponent(p.did)}/${encodeURIComponent(p.rkey)}`
}
// did:plc:wj62ebbusln6gxo5azo6ogzo -> wj62ebbu…ogzo. Enough to tell two authors apart at a glance
// without spending a line of the post on an opaque string.
export function shortDid (did) {
const d = String(did || '')
const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d
return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id
}
// "2h", "3d" — the form every reader scans for. The exact value stays available in the datetime
// attribute and the title, so nothing is lost by not printing an ISO timestamp on every row.
export function relTime (iso, now = Date.now()) {
const t = Date.parse(iso)
if (!Number.isFinite(t)) return String(iso || '')
const secs = Math.round((now - t) / 1000)
if (secs < 0) return 'just now'
if (secs < 60) return secs + 's'
const mins = Math.round(secs / 60); if (mins < 60) return mins + 'm'
const hrs = Math.round(mins / 60); if (hrs < 24) return hrs + 'h'
const days = Math.round(hrs / 24); if (days < 7) return days + 'd'
const wks = Math.round(days / 7); if (wks < 5) return wks + 'w'
const mos = Math.round(days / 30); if (mos < 12) return mos + 'mo'
return Math.round(days / 365) + 'y'
}
// The author line: avatar + display name, degrading to a short DID. `author` is attached server-side
// (lib/bylines.js); when it is absent — cold cache, or a pure render test — this still produces a
// usable line rather than nothing.
export function byline (p) {
const a = p.author || {}
const did = p.did
// displayName -> VERIFIED handle -> short DID. The handle is only ever shown when the resolver
// verified it bidirectionally (it yields 'handle.invalid' otherwise), so a DID claiming someone
// else's handle can never render as that person.
const name = a.displayName || (a.handleVerified && a.handle ? '@' + a.handle : shortDid(did))
// Show the handle alongside a displayName too — a name alone is not an identity, and this is how
// you tell two accounts with the same display name apart.
const sub = (a.handleVerified && a.handle && a.displayName) ? ` <span class="handle">@${esc(a.handle)}</span>` : ''
const href = `/profile/${encodeURIComponent(did)}`
// The DID is kept as the link title, not printed: it is the identity that matters for verification,
// but it should cost a hover rather than a line.
//
// The avatar is NOT in here. It renders as its own column beside the post body (see .prow), which is
// the layout every mainstream client uses and the one thing our timeline most obviously lacked.
// avatarLink() below emits it; both point at the same profile.
return `<a class="byline" href="${href}" title="${esc(did)}"><span class="name">${esc(name)}</span>${sub}</a>`
}
// The avatar column. A separate anchor to the same profile as the byline, so it is a real link and a
// comfortable target — but aria-hidden and out of the tab order, for the same reason .pstretch is: it
// adds a MOUSE target to a destination the byline already provides, it does not add a destination.
// A keyboard or screen-reader user should not walk to the same profile twice per post.
export function avatarLink (p) {
const a = p.author || {}
const did = p.did
// The appview hands back the RAW blob ref (it deliberately does not duplicate blobCid's knowledge of
// the four shapes a ref arrives in); accept either that or an already-extracted cid.
const cid = a.avatarCid || blobCid(a.avatar && a.avatar.ref)
// `avatarUrl` is a LENS-supplied absolute URL, used only when we hold no blob of our own — a bsky
// author whose profile record this node has never indexed. The profile PAGE got this in 0.3.12; the
// per-post avatar did not, so the same account had a picture on its profile and a grey circle in the
// timeline. https only, and no-referrer so browsing here does not leak the page you are on.
const av = (cid && isSafeCid(cid))
? `<img class="pfp" src="${esc(blobUrl(did, cid))}" alt="" loading="lazy" width="42" height="42">`
: (a.avatarUrl && /^https:\/\//i.test(a.avatarUrl)
? `<img class="pfp" src="${esc(a.avatarUrl)}" alt="" loading="lazy" width="42" height="42" referrerpolicy="no-referrer">`
: '<span class="pfp none" aria-hidden="true"></span>')
return `<a class="pavatar" href="/profile/${encodeURIComponent(did)}" aria-hidden="true" tabindex="-1">${av}</a>`
}
// One post. did → a real /profile/<did> link; moderation (hide/warn/muted) collapses into a native
// <details> so it is revealable with NO JavaScript. `treatment`/`muted`/`labels` are attached by the
// server's moderation pass (withMod) — the render just honors them.
// ── reach ─────────────────────────────────────────────────────────────────────────────────────────
//
// Where a post actually went. Every public post already reaches bsky AND the fediverse from the same
// record bytes — verified on both surfaces 2026-08-05 — and nothing in the client ever said so, so
// the one architecturally interesting fact about this project was invisible to its user.
//
// ★ OBSERVED and ELIGIBLE render DIFFERENTLY, and that is the point.
// `fediverse` is observed — ap-bridge's outbox names the rkey, so it was published.
// `bsky` is eligible — the lexicon is one bsky indexes, so the post was OFFERED. Whether bsky
// actually indexed it needs a per-post API call we do not make on a timeline.
// Rendering them alike would claim something we did not check, which is the exact habit that made
// #24 and #26 hard to diagnose ("everything we control reports success").
//
// `favela` is never badged: it is true of every post on the page, so it carries no information.
// Absence is silent too — no badge means not projected OR not asked, and inventing a "not on the
// fediverse" badge from a failed HTTP call would be worse than saying nothing.
const REACH_LABEL = {
bsky: ['bsky', 'offered to bsky — this lexicon is one its AppView indexes'],
fediverse: ['fediverse', 'published to the fediverse — ap-bridge\'s outbox lists this post']
}
export function renderReach (p) {
if (!p || !Array.isArray(p.reach)) return ''
return p.reach.map((r) => {
const spec = REACH_LABEL[r && r.tier]
if (!spec) return ''
const weak = r.state === 'eligible' ? ' weak' : ''
return `<span class="badge reach${weak}" title="${esc(spec[1])}">${esc(spec[0])}</span>`
}).join('')
}
// Stamp reach onto a post list. Kept OUT of renderPost for the same reason as withViewer: whether we
// could reach ap-bridge is a property of the request, not of the post.
export function withReach (posts, apRkeys, fn = reachFor) {
if (!Array.isArray(posts) || typeof fn !== 'function') return posts
for (const p of posts) {
if (!p) continue
const collection = (p.record && p.record.$type) || p.collection || null
p.reach = fn({ collection, rkey: p.rkey, apRkeys }).filter((r) => r.tier !== 'favela')
}
return posts
}
export function renderPost (p) {
const local = (p.record && p.record.$type) === LOCAL_NSID
const text = renderRichText((p.record && p.record.text) || '', p.record && p.record.facets)
const labels = Array.isArray(p.labels) ? p.labels : []
const lbls = labels.map((v) => `<span class="lbl">${esc(v)}</span>`).join('')
const badges = (local ? '<span class="badge">local-only</span>' : '') + renderReach(p) + lbls
// Author ABOVE the text (a reader needs to know who is speaking before they read it) and the
// timestamp below as a permalink — the one affordance that lets a post be shared at all.
// Timestamp sits ON the byline line, not below it. A reader asks who and when together, and splitting
// them cost a whole row per post while leaving .meta a mixed bag of "when" and "act on this". It is
// still the permalink — the one affordance that lets a post be shared at all.
const stamp = `<a class="permalink" href="${postHref(p)}"><time datetime="${esc(p.createdAt)}" title="${esc(p.createdAt)}">${esc(relTime(p.createdAt))}</time></a>`
const head = `<div class="phead">${byline(p)}${stamp}${badges}</div>`
// Like count is shown only when non-zero: a row of "0" on every post is noise, and an absent count
// reads correctly as "none" without spending pixels saying so.
// Like is a plain form POST so it works with scripting off, and carries `return` so the redirect lands
// back where you clicked. `canLike` gates rendering it at all — an anonymous viewer gets the count only.
const uri = p.uri || uriOf(p)
const likes = p.likeCount > 0 ? `<span class="likes" title="${p.likeCount} like${p.likeCount === 1 ? '' : 's'}">${p.likeCount}</span>` : ''
const likeBtn = p.canLike
? `<form class="likef" method="POST" action="/api/like"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="likebtn${p.viewerLiked ? ' on' : ''}" aria-pressed="${p.viewerLiked ? 'true' : 'false'}" aria-label="${p.viewerLiked ? 'unlike this post' : 'like this post'}" title="${p.viewerLiked ? 'unlike' : 'like'}">${p.viewerLiked ? '♥' : '♡'}</button>${likes}</form>`
: (likes ? `<span class="likef">♥ ${likes}</span>` : '')
// Reply count links INTO the thread — it is the affordance for "there is more here", so it must be
// clickable rather than decorative. Hidden at zero, like likes.
// ALWAYS shown, count or not. Rendering it only when replyCount > 0 (as 0.2.43 did) means there is no
// way to START a thread from a timeline — you can only join one that already exists. The icon is the
// affordance; the number is incidental.
const replyLabel = p.replyCount > 0 ? `${p.replyCount} repl${p.replyCount === 1 ? 'y' : 'ies'} — open thread` : 'reply to this post'
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>`
// Quote — a plain link that opens the composer pre-loaded with this post, so quoting works with
// scripting off. Only for a viewer who can write; an anonymous reader gets no dead control.
// `quotable === false` is stamped by the post page from the postgate. Offering a control whose write
// we would refuse is worse than not offering it.
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>` : ''
// Delete — YOUR OWN posts only, and `isOwn` is stamped by withViewer from the viewer's DID. The server
// re-checks ownership regardless: a hidden control is not a permission check.
//
// Deletion is irreversible and federates, so it asks first — but the confirm is a native <details>,
// not a JS confirm(), so it works with scripting off exactly like the moderation collapse does. One
// click reveals the button; the destructive act is never the first click.
// Bookmark — private, so it is deliberately quiet: no count, no shared state, just on or off. A plain
// form, like every other control here.
const bmBtn = p.canLike
? `<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>`
: ''
// Report — only on someone ELSE's post (reporting your own is meaningless) and only for a viewer who
// could act. Needs JS and a session, and says so rather than failing silently (web/static/report.js).
const repBtn = (p.canLike && !p.isOwn)
? `<button class="reportbtn" type="button" data-uri="${esc(uri)}"${p.cid ? ` data-cid="${esc(p.cid)}"` : ''} aria-label="report this post" title="report">⚑</button>`
: ''
// Edit sits BEFORE delete and is a plain link, not a <details> confirm: editing is reversible
// (edit again), deleting is not. Presenting them with equal weight would invite the wrong one.
const editBtn = p.isOwn && !p.focusedEdit
? `<a class="editlink" href="/edit/${encodeURIComponent(p.did)}/${encodeURIComponent(p.rkey)}?return=${encodeURIComponent(p.returnTo || '/')}" aria-label="edit this post">✎</a>`
: ''
const delBtn = p.isOwn
? `<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>`
: ''
// Repost mirrors like: a plain form toggle, count hidden at zero, anonymous sees the count only.
const rpCount = p.repostCount > 0 ? `<span class="rps">${p.repostCount}</span>` : ''
const repostBtn = p.canLike
? `<form class="rpf" method="POST" action="/api/repost"><input type="hidden" name="uri" value="${esc(uri)}"><input type="hidden" name="return" value="${esc(p.returnTo || '/')}"><button class="rpbtn${p.viewerReposted ? ' on' : ''}" aria-pressed="${p.viewerReposted ? 'true' : 'false'}" aria-label="${p.viewerReposted ? 'undo repost' : 'repost this post'}" title="${p.viewerReposted ? 'undo repost' : 'repost'}">⇄</button>${rpCount}</form>`
: (rpCount ? `<span class="rpf">⇄ ${rpCount}</span>` : '')
// A reply says so, and links to what it answers — otherwise it reads as a non-sequitur in a timeline.
const parentUri = p.record && p.record.reply && p.record.reply.parent && p.record.reply.parent.uri
const pref = parentUri ? parseAtUriLite(parentUri) : null
const inReply = pref ? `<div class="inreply">↩ in reply to <a href="/post/${encodeURIComponent(pref.did)}/${encodeURIComponent(pref.rkey)}">${esc(shortDid(pref.did))}</a></div>` : ''
// ★ "edited" is DERIVED, not a record field. The firehose op's action said `update`, and the index
// recorded that at ingest (spec §10.5.2 — atproto's $orig/$hist pattern exists because atproto has
// no history; this substrate has some). It sits next to the timestamp because that is where a
// reader looks to ask "when", and an edit is an answer to that question.
const editedMark = p.editedAt
? ` <span class="edited" title="edited ${esc(p.editedAt)} — the original time is kept">(edited)</span>`
: ''
// .meta is now purely the controls row: one job, so it can be spaced and sized as one.
//
// ★ PRIMARY THREE, then an overflow. This is forced by arithmetic, not taste: every control needs a
// 44px target, and seven of them plus their counts do not fit the 366px a 390px phone leaves — the
// row wrapped to two lines. Reply/repost/like are the ones bsky keeps visible, and they are the ones
// that act on the post itself; quote, bookmark, report, edit and delete are about YOUR relationship
// to it and can cost a tap. Native <details>, the same no-JS pattern the delete confirm already
// uses, so the menu opens with scripting off.
const secondary = quoteBtn + bmBtn + repBtn + editBtn + delBtn
const more = secondary
? `<details class="morew"><summary aria-label="more actions" title="more actions"><span aria-hidden="true">\u22ef</span></summary><div class="moremenu">${secondary}</div></details>`
: ''
const meta = `<div class="meta">${replies}${repostBtn}${likeBtn}${more}${editedMark}</div>`
// The WHOLE ENTRY opens the thread. Until now the only ways in were the timestamp and the ↩ icon —
// two ~10px targets on a card as wide as the page — so the reply view, which is where a conversation
// actually lives, was the hardest thing here to reach.
//
// Done as a "stretched link": ONE real <a> absolutely positioned over the card, with every genuinely
// interactive descendant raised above it in CSS (see .pstretch in STYLE). Deliberately NOT a wrapping
// anchor — a post contains anchors (byline, tags, mentions) and forms (like, repost), and an <a> may
// legally contain neither. Deliberately NOT a JS click handler either: this is a real link, so
// middle-click, ctrl-click, "copy link address" and no-JS reading all keep working.
//
// aria-hidden + tabindex="-1" because the timestamp permalink and the ↩ link already point HERE and
// are already in the tab order. A third identical link would make a keyboard or screen-reader user
// walk the same destination three times per post. The overlay adds a MOUSE target for a destination
// that is already reachable — it does not add a destination.
//
// Suppressed on the focused post of a thread page (`p.focused`): a page linking to itself is a dead
// click, and the overlay would sit on top of the one post the reader opened the page to read.
const stretch = p.focused ? '' : `<a class="pstretch" href="${postHref(p)}" aria-hidden="true" tabindex="-1"></a>`
// ★ TWO COLUMNS: avatar beside the content, not above it. This is the layout every mainstream client
// uses and the largest single thing our timeline was missing — full-width text under a small avatar
// reads as a log, not a feed.
//
// Done as an inner .prow rather than by making .post itself a grid, because .post is ALSO the
// <details> element in the collapsed-moderation case; a grid there would lay the <summary> out as a
// column. The row wraps only the body, so the summary keeps sitting above it untouched.
//
// .pbody carries min-width:0 in CSS — without it a long unbroken word in a post makes the text
// column refuse to shrink and pushes the card wider than the screen.
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>`
const body = `${stretch}${row}`
const collapse = p.treatment === 'hide' || p.treatment === 'warn' || p.muted
if (collapse) {
const reason = p.muted ? '🔇 muted account' : (p.treatment === 'hide' ? `◼ hidden by a label (${esc(labels.join(', '))})` : `⚠ ${esc(labels.join(', '))}`)
return `<details class="post collapsed"><summary>${reason}</summary>${body}</details>`
}
return `<article class="post">${body}</article>`
}
// Stamp the per-viewer affordances onto a post list. Kept OUT of renderPost so the render stays a pure
// function of its input: whether a like button appears is a property of the page, not of the post.
export function withViewer (posts, viewer, returnTo = '/') {
if (!Array.isArray(posts)) return posts
const canLike = !!(viewer && viewer.did)
for (const p of posts) { if (p) { p.canLike = canLike; p.returnTo = returnTo; p.isOwn = !!(viewer && viewer.did && p.did === viewer.did) } }
return posts
}
// An empty state must distinguish "nothing here" from "something is broken". Grey italic text did not:
// an empty timeline, a profile with no posts, and a failed index all rendered the same single line.
//
// `hint` is the second half — what to DO, when there is something. Deliberately optional: inventing an
// action for a state that has none ("no notifications yet — why not get some?") is worse than silence.
// `hintHtml` is for the rare hint carrying a link; every other path is escaped text.
export function emptyState (title, hint = '', hintHtml = '') {
const h = hintHtml || (hint ? esc(hint) : '')
// NOT `.empty` — that class is also carrying explanatory helper text all over the settings page, so
// restyling it would turn eight paragraphs of prose into dashed empty-boxes. Distinct class instead.
return `<div class="emptyst"><span class="empty-t">${esc(title)}</span>${h ? `<span class="empty-h">${h}</span>` : ''}</div>`
}
// Accepts a plain string (every existing caller) or { title, hint, hintHtml }. Keeping the string form
// working means this is additive — no caller has to change to keep rendering what it rendered before.
export function renderPosts (posts, empty = 'no posts yet') {
if (posts && posts.length) return posts.map(renderPost).join('')
if (empty && typeof empty === 'object') return emptyState(empty.title || 'nothing here', empty.hint, empty.hintHtml)
return emptyState(String(empty))
}
// Accept EITHER shape: the { post, replies } tree getPostThread returns, or a flat array of posts (what
// the one-level reply list used to be). A caller that passes the old shape gets a one-level thread rather
// than silence — rendering nothing with no error is the worst failure mode there is, and it is exactly
// what happened here when the shape changed under a caller that had not been updated.
function asThreadNodes (replies) {
if (!Array.isArray(replies)) return []
return replies.map((r) => (r && r.post) ? r : (r ? { post: r, replies: [] } : null)).filter(Boolean)
}
// A reply TREE, not a list. Children are nested INSIDE their parent's element, so one CSS rule
// (`.tnode .tnode`) produces the accumulating indent and thread line — no depth arithmetic here, and no
// inline styles. Depth is already bounded by getPostThread, so this recursion is bounded too.
export function renderThread (replies, viewer, returnTo) {
const nodes = asThreadNodes(replies)
if (!nodes.length) return ''
return nodes.map((n) => {
const p = withViewer([n.post], viewer, returnTo)[0]
return `<div class="tnode">${renderPost(p)}${renderThread(n.replies, viewer, returnTo)}</div>`
}).join('')
}
// Count the whole subtree, so the heading says "8 replies" when six of them are nested rather than
// claiming 2 because only the direct children were counted.
export function countThread (replies) {
let n = 0
for (const node of asThreadNodes(replies)) n += 1 + countThread(node.replies)
return n
}
// ── page shell ────────────────────────────────────────────────
const STYLE = `
body{font-family:system-ui,sans-serif;max-width:620px;margin:2rem auto;padding:0 1rem;color:var(--fg)}
a{color:var(--accent)}h1{font-size:var(--f4)}h1 small{color:var(--fg-faint);font-weight:400}
nav{margin:.4rem 0 1rem;display:flex;gap:1rem;font-size:var(--f1)}
.post{position:relative;border-bottom:1px solid var(--line);padding:.8rem 0}.post .text{white-space:pre-wrap}
/* The whole-entry link emitted by renderPost. It covers the card and sits UNDER everything, so the
card is one big click target for "open this thread". */
.pstretch{position:absolute;inset:0;z-index:0}
/* With scripting on, POSTCLICK takes the overlay out of the hit test and handles the click itself, so
that dragging across a post still SELECTS its text. Scripting off: this class is never added and the
overlay above does the whole job. */
.js-postclick .pstretch{pointer-events:none}
/* …and everything genuinely interactive is lifted above it, keeping its own behaviour: the byline goes
to a profile, a tag/mention/url in the text goes where it points, like/repost stay buttons, and a
collapsed post's <summary> still toggles rather than navigating. Anything NOT listed here (the text
itself, images, empty space, padding) falls through to the thread — which is the point. */
.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}
/* A click target with no hover state is invisible until you happen to click it. Kept faint: it must
read as "this row is live", not as a selected state. */
.post:hover{background:var(--bg-soft)}
/* No hover where there is no link to follow: the focused post of a thread, and a collapsed post while
it is still closed (its overlay lives inside the hidden content, so there is nothing to click yet). */
.focus .post:hover,details.post.collapsed:not([open]):hover{background:none}
.post .media{margin-top:.5rem}.post .media img{max-width:100%;height:auto;border-radius:.5rem;display:block;margin:.3rem 0;background:var(--bg-soft)}
details.post>summary{cursor:pointer;color:var(--fg-faint);font-style:italic;list-style:revert}
.meta{color:var(--fg-faint);font-size:var(--f0);margin-top:.35rem}.meta .did{color:var(--fg-faint)}
.controls{display:flex;gap:.5rem;align-items:center;margin-top:.4rem}.controls select,.controls button{font:inherit;padding:.3rem}
.badge{display:inline-block;font-size:var(--f0);font-weight:600;padding:.05rem .4rem;border-radius:.6rem;margin-left:.4rem;background:var(--badge-bg);color:var(--accent)}
.lbl{display:inline-block;font-size:var(--f0);font-weight:600;padding:.05rem .4rem;border-radius:.6rem;margin-left:.4rem;background:var(--danger-bg);color:var(--danger-text)}
.tag{text-decoration:none}.tag:hover{text-decoration:underline}
.viewhdr{margin:.6rem 0;font-weight:600}.empty{color:var(--fg-faint);font-style:italic}
.who{font-size:var(--f1);color:var(--fg-faint);margin-left:auto}
.phead{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.35rem}
.byline{display:flex;align-items:center;gap:.5rem;text-decoration:none;color:inherit;min-width:0}
.byline:hover .name{text-decoration:underline}
.byline .name{font-weight:600;font-size:var(--f2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pfp{width:42px;height:42px;border-radius:50%;object-fit:cover;background:var(--line);flex:none}
.pfp.none{display:inline-block;background:var(--line)}
.pfp.big{width:72px;height:72px}
.meta .permalink{color:var(--fg-faint);text-decoration:none}.meta .permalink:hover{text-decoration:underline}
.mention{text-decoration:none;font-weight:500}.mention:hover{text-decoration:underline}
.profhdr{display:flex;gap:.9rem;align-items:flex-start;margin:.6rem 0 1rem}
.profname{font-size:var(--f3);font-weight:700}
.profdid code{font-size:var(--f0);color:var(--fg-faint);word-break:break-all}
.profbio{margin:.4rem 0 0;white-space:pre-wrap}
.aturi code{font-size:var(--f0);word-break:break-all}
.replyc{color:var(--fg-faint);text-decoration:none}.replyc:hover{text-decoration:underline}
.rpf{display:inline;margin-left:.15rem}
.rpbtn{font:inherit;font-size:var(--f1);line-height:1;border:none;background:none;color:var(--fg-faint);cursor:pointer;padding:0 .15rem}
.rpbtn.on{color:var(--repost)}.rpbtn:hover{color:var(--repost)}
.rps{color:var(--fg-faint);font-size:var(--f0)}
.inreply{font-size:var(--f0);color:var(--fg-faint);margin-bottom:.25rem}
.replyf{margin:.8rem 0;padding:.6rem;border:1px solid var(--line);border-radius:.5rem}
.replyf textarea{width:100%;box-sizing:border-box;font:inherit;padding:.4rem;border:1px solid var(--line);border-radius:.3rem}
.replyf p{display:flex;align-items:center;gap:.5rem;margin:.4rem 0 0}
.replyf button{font:inherit;padding:.3rem .9rem;cursor:pointer}
.thread{margin-top:1rem;border-top:2px solid var(--line);padding-top:.5rem}
.thread h3{font-size:var(--f1);color:var(--fg-dim);margin:.3rem 0}
/* Nested replies: children live INSIDE the parent node, so this one rule gives the accumulating indent
and the thread line at every depth. Capped padding keeps a deep chain from marching off-screen. */
.tnode .tnode{margin-left:.5rem;padding-left:.7rem;border-left:2px solid var(--line-soft)}
.focus{border-left:3px solid #444;padding-left:.6rem;margin:.4rem 0}
/* A lens is a NAMED FOREIGN source. Styled visibly apart from our own posts — a reader must never have
to guess whether a reply is one this node holds or one it is relaying from someone else's AppView. */
.lens{margin-top:1rem;border-top:1px dashed var(--lens-line);padding-top:.5rem}
/* The ancestor fill sits ABOVE the focused post, so its rule belongs on the bottom edge — otherwise the
section reads as separating itself from the post it precedes. */
.lensup{margin:.3rem 0 .6rem;border-top:0;border-bottom:1px dashed var(--lens-line);padding:0 0 .4rem}
.lens h3{font-size:var(--f1);color:var(--fg-dim);font-weight:600;margin:.3rem 0}
.lensname{color:var(--lens)}
.lpost{border-left:2px solid var(--lens-edge);padding:.3rem 0 .3rem .7rem;margin:.4rem 0}
.lwho{font-size:var(--f1);font-weight:600}
.lhandle{font-weight:400;color:var(--fg-faint)}
.ltext{white-space:pre-wrap;margin:.15rem 0}
.lensoff{font-size:var(--f0);margin-top:1rem}
.lensnote{font-size:var(--f0);color:var(--fg-dim);margin:.4rem 0 0}
.qbtn{color:var(--fg-faint);text-decoration:none}.qbtn:hover{color:var(--accent)}
.delw{display:inline}
.delw>summary{display:inline;list-style:none;cursor:pointer;color:var(--fg-faint)}
.delw>summary::-webkit-details-marker{display:none}
.delw>summary:hover{color:var(--danger)}
.delf{display:inline;margin-left:.3rem}
.delbtn{font:inherit;font-size:var(--f0);border:1px solid var(--danger);background:var(--danger);color:#fff;border-radius:999px;padding:.15rem .5rem;cursor:pointer}
.delbtn:hover{filter:brightness(1.1)}
.modrow{display:flex;align-items:center;gap:.6rem;margin-top:.5rem;font-size:var(--f1)}
.modf{display:inline}
.mutebtn{font:inherit;font-size:var(--f1);border:none;background:none;color:var(--fg-faint,#888);cursor:pointer;padding:.2rem 0;text-decoration:underline}
.mutebtn:hover{color:var(--fg,#111)}.mutebtn.on{color:var(--fg-dim,#666)}
.modw{display:inline}
.modw>summary{display:inline;list-style:none;cursor:pointer;color:var(--fg-faint,#888);text-decoration:underline}
.modw>summary::-webkit-details-marker{display:none}
.modw>summary:hover{color:var(--danger)}
.blockbtn{font:inherit;font-size:var(--f0);border:1px solid var(--danger);background:var(--danger);color:#fff;border-radius:999px;padding:.2rem .6rem;cursor:pointer;margin-left:.3rem}
.blockbtn.on{background:none;color:var(--danger)}
.navsearch{margin-left:auto}
.navsearch input{font:inherit;font-size:var(--f1);padding:.2rem .5rem;border:1px solid var(--line,#e5e5e5);border-radius:999px;background:var(--bg,#fff);color:inherit;width:8rem}
.navsearch input:focus{outline:2px solid var(--accent,#1a56db);outline-offset:1px;width:11rem}
.searchbox{display:flex;gap:.5rem;margin:.6rem 0 1rem}
.searchbox input{flex:1;font:inherit;padding:.45rem .7rem;border:1px solid var(--line,#e5e5e5);border-radius:999px;background:var(--bg,#fff);color:inherit}
.searchbox button{font:inherit;padding:.4rem 1rem;border:none;border-radius:999px;background:var(--accent,#1a56db);color:var(--bg);font-weight:600;cursor:pointer}
.actorrow{padding:.4rem 0;border-bottom:1px solid var(--line-soft,#f0f1f3)}
.gatenote{font-size:var(--f1);color:var(--fg-dim,#666);margin:.6rem 0 0}
.gateoff{font-size:var(--f1);margin:.6rem 0 0}
.draftnote{font-size:var(--f0);color:var(--fg-dim,#5c6672);margin:0 0 .3rem}
/* The composer control row. It was a single non-wrapping flex line, which was fine with ONE select and
broke once scope/lang/quotes/replies were all added: the row overflowed its container and the image
picker and post button ended up outside it. Four dropdowns is simply more than one line holds at 640px,
and less on a phone — so it wraps, and every item is allowed to shrink. */
.controls{flex-wrap:wrap;gap:.4rem .7rem;row-gap:.5rem}
.controls label{display:inline-flex;align-items:center;gap:.3rem;min-width:0;font-size:var(--f1);color:var(--fg-dim,#5c6672)}
/* min-width:0 is the load-bearing half: a flex item defaults to min-width:auto, so a select refuses to
shrink below its longest option and pushes its siblings out of the box instead of wrapping. */
.controls select,.controls button{min-width:0;max-width:100%;border:1px solid var(--line);border-radius:var(--r1);background:var(--bg);color:inherit}
/* The post button always ends the row, on whatever line it lands on. */
#post-btn{margin-left:auto;flex:none}
.cc{flex:none}
#img-controls{flex:none}
.composerhint{font-size:var(--f0);color:var(--fg-faint,#6b7480);margin:.4rem 0 0}
/* Which identity this form writes as. Above the textarea because it has to be read BEFORE typing, not
discovered after posting; strong enough to be seen, quiet enough not to shout on every page. */
/* Staleness. Above everything, because it qualifies everything below it — but a notice, not an alarm:
it is frequently true and correctly ignorable on a P2P node. */
.stale{font-size:var(--f1);background:var(--bg-soft);border:1px solid var(--line);border-left:3px solid var(--warn);border-radius:var(--r1);padding:.45rem .6rem;margin:0 0 var(--s3)}
.composerwho{font-size:var(--f0);color:var(--fg-faint,#6b7480);margin:0 0 .4rem}
.composerwho strong{color:var(--fg);font-weight:600}
@media(max-width:640px){
/* On a phone the labels alone fill a line, so give the selects room and let each pair own its width. */
.controls{gap:.35rem .5rem}
.controls label{flex:1 1 45%}
.controls select{flex:1 1 auto}
#post-btn{margin-left:0;flex:1 1 100%}
}
.reportbtn{font:inherit;font-size:var(--f1);border:none;background:none;color:var(--fg-faint,#6b7480);cursor:pointer;padding:.3rem .35rem;margin:-.3rem -.1rem}
.reportbtn:hover{color:var(--danger)}
.reportbox{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-top:.4rem;padding:.5rem;border:1px solid var(--line,#e5e5e5);border-radius:var(--r1,.375rem);font-size:var(--f1)}
.reportbox select,.reportbox input,.reportbox button{font:inherit;font-size:var(--f1);padding:.25rem .4rem}
.reportbox input{flex:1;min-width:8rem}
.lmedia{display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.35rem}
.lthumb{width:110px;height:110px;object-fit:cover;border-radius:var(--r1,.375rem);background:var(--bg-soft,#f3f4f6)}
.listrow{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;padding:.6rem 0;border-bottom:1px solid var(--line,#e5e5e5);text-decoration:none;color:inherit}
.listrow:hover{background:var(--bg-soft,#f7f8fa)}
.listname{font-weight:600}
.listkind{font-size:var(--f0);color:var(--fg-faint,#6b7480);border:1px solid var(--line,#e5e5e5);border-radius:999px;padding:.05rem .5rem}
.listkind.mod{color:var(--danger);border-color:var(--danger)}
.listdesc{font-size:var(--f1);color:var(--fg-dim,#5c6672);width:100%}
.profbanner{margin:0 0 .6rem;border-radius:var(--r2,.625rem);overflow:hidden;background:var(--bg-soft,#f7f8fa)}
.profbanner img{display:block;width:100%;aspect-ratio:3/1;object-fit:cover}
.profbanner.small img{aspect-ratio:4/1}
.bmf{display:inline}
.bmbtn{font:inherit;font-size:var(--f1);line-height:1;border:none;background:none;color:var(--fg-faint);cursor:pointer;padding:.3rem .45rem;margin:-.3rem -.1rem;transition:transform .08s ease,color .12s ease;touch-action:manipulation}
.bmbtn:hover{color:var(--bookmark)}.bmbtn.on{color:var(--bookmark)}
.bmbtn:active{transform:scale(.88)}
.bmbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:.3rem}
/* a11y. A skip link is the difference between reaching the content and tabbing the whole nav on every
page; it is visually hidden until focused, which is the one time it matters. */
.skip{position:absolute;left:-9999px;top:0;z-index:200;background:var(--accent,#1a56db);color:var(--bg);padding:.5rem .9rem;border-radius:0 0 .4rem 0}
.skip:focus{left:0}
main:focus{outline:none}
/* Visually hidden but ANNOUNCED — for labels that would be redundant on screen and essential aloud.
Deliberately not display:none, which removes it from the accessibility tree entirely. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* Every interactive element gets a visible focus ring, not just the ones styled by hand. */
a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent,#1a56db);outline-offset:2px;border-radius:.2rem}
.quote{display:block;border:1px solid var(--line,#e5e5e5);border-radius:var(--r2,.625rem);padding:.5rem .6rem;margin-top:.5rem;text-decoration:none;color:inherit}
.quote:hover{background:var(--bg-soft,#fafafa)}
.qhead{display:flex;align-items:center;gap:.35rem;font-size:var(--f1);color:var(--fg-dim,#666)}
.qname{font-weight:600;color:var(--fg,#111)}
.qpfp{width:20px;height:20px}
.qtext{display:block;margin-top:.25rem;white-space:pre-wrap;font-size:var(--f1)}
.missingq{color:var(--fg-faint,#888);font-style:italic;font-size:var(--f1)}
.card{display:flex;gap:.6rem;align-items:stretch;border:1px solid var(--line,#e5e5e5);border-radius:var(--r2,.625rem);overflow:hidden;margin-top:.5rem;text-decoration:none;color:inherit}
.card:hover{background:var(--bg-soft,#fafafa)}
.cardimg{width:88px;height:88px;object-fit:cover;flex:none}
.cardtxt{display:flex;flex-direction:column;justify-content:center;padding:.5rem .6rem;min-width:0}
.cardhost{font-size:var(--f0);color:var(--fg-faint,#888);text-transform:lowercase}
.cardtitle{font-weight:600;font-size:var(--f1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.carddesc{font-size:var(--f1);color:var(--fg-dim,#666);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.vid{width:100%;max-height:70vh;border-radius:var(--r2,.625rem);background:#000;display:block}
.qpreview{font-size:var(--f1);color:var(--fg-dim,#666);border-left:3px solid var(--accent,#1a56db);padding:.2rem .5rem;margin-bottom:.5rem}
.qptext{font-style:italic}
.qcancel{font-size:var(--f0)}
.stath{font-size:var(--f1);color:var(--fg-dim);margin:1rem 0 .3rem;text-transform:uppercase;letter-spacing:.03em}
.statblk{border:1px solid var(--line);border-radius:4px;padding:.5rem .6rem;margin:.4rem 0}
.statblk.alarm{border-color:var(--danger);background:var(--danger-bg)}
.statdid{font-size:var(--f0);color:var(--fg-dim)}
.statsum{font-size:var(--f1);margin:.2rem 0 .4rem}
.stattbl{border-collapse:collapse;font-size:var(--f0);width:100%}
.stattbl th{text-align:left;color:var(--fg-faint);font-weight:600;padding:.15rem .4rem .15rem 0}
.stattbl td{padding:.15rem .4rem .15rem 0;border-top:1px solid #f1f1f1}
.stattbl tr.chosen td{font-weight:600;background:#f8f9ff}
.alarmnote{color:var(--danger);font-size:var(--f1);margin:.4rem 0 0}
.ok{color:var(--ok)}.bad{color:var(--danger)}
.ptabs{display:flex;gap:.15rem;border-bottom:1px solid var(--line);margin:.6rem 0 .3rem;flex-wrap:wrap}
.ptab{padding:.35rem .7rem;font-size:var(--f1);color:var(--fg-dim);text-decoration:none;border-bottom:2px solid transparent}
.ptab:hover{color:var(--fg)}
.ptab.on{color:var(--fg);font-weight:600;border-bottom-color:#444}
.ancestor{opacity:.72}
.missing{font-size:var(--f0);border-left:2px dashed var(--line);padding-left:.6rem}
.likes{color:var(--fg-faint);font-size:var(--f0)}
.likef{display:inline;margin-left:.5rem}
.likebtn{font:inherit;font-size:var(--f1);line-height:1;border:none;background:none;color:var(--fg-faint);cursor:pointer;padding:0 .15rem}
.likebtn.on{color:var(--like)}.likebtn:hover{color:var(--like)}
/* Responsive controls. These used to be plain form POSTs that reloaded the whole page to toggle a heart —
correct, and it felt broken. /static/actions.js now posts JSON and updates in place; this is the part
that makes the press FEEL like it landed:
- a tactile :active scale, so the click registers before the network does
- a wider hit area (touch targets were ~14px; a thumb needs far more)
- .failed briefly reddens a control whose write did NOT land, because the optimistic flip is rolled
back and a silent revert is indistinguishable from never having clicked. */
.likebtn,.rpbtn,.followbtn{transition:transform .08s ease,color .12s ease,opacity .12s ease;touch-action:manipulation}
/* The hit area is stated, not inherited. It used to fall out of glyph size plus padding, which meant
dropping the font from 14.4px to 14px silently took it to 23.59px — under WCAG 2.2 AA 2.5.8, which
asks 24x24. A target size that moves when someone edits a font scale is not a target size.
24px is the floor; Material asks 48dp and Apple 44pt. Getting there by PADDING would make the
controls row 44px tall and push every post apart, so the target is expanded without expanding the
box: a transparent ::after stretched past the button's bounds. That is what bsky does natively —
PostControls sets an explicit hitSlop on every control — and this is the CSS equivalent. The inset
is half the row gap horizontally, so neighbouring targets meet exactly and never overlap.
First attempt grew the target with a transparent ::after, the way bsky's hitSlop does. Measured, it
only half worked: it fixed height everywhere but could not fix width, because a fixed inset applied
to a 6px-wide glyph still yields 22px (.qbtn measured 22x39, .replyc 36x39). And insets that DID
reach 44 would have overlapped their neighbours, so the wrong control gets tapped.
So the controls are real 44x44 boxes. Target equals box, which means neighbours cannot overlap by
construction, and the row can use a small gap: 7 controls x 44 + 6 x 4 = 332px, inside the 366px a
390px phone gives. */
.likebtn,.rpbtn,.bmbtn,.reportbtn,.replyc,.qbtn,.editlink,.delw>summary,.morew>summary{
display:inline-flex;align-items:center;justify-content:center;
min-width:44px;min-height:44px;box-sizing:border-box}
.likebtn,.rpbtn{padding:0;margin:0}
/* Overflow menu. A native <details>, like the delete confirm, so it opens with scripting off. The
panel is absolutely positioned so opening it never reflows the timeline underneath — a menu that
pushes every post below it down is how you lose your place in a feed. */
.morew{position:relative;display:inline-flex}
.morew>summary{list-style:none;cursor:pointer;color:var(--fg-faint);border-radius:999px}
.morew>summary::-webkit-details-marker{display:none}
.morew>summary:hover{color:var(--fg);background:var(--bg-soft)}
.morew[open]>summary{color:var(--fg);background:var(--bg-soft)}
.moremenu{position:absolute;right:0;top:100%;z-index:20;display:flex;align-items:center;gap:var(--s1);
padding:var(--s1);background:var(--bg);border:1px solid var(--line);border-radius:var(--r2);
box-shadow:0 4px 16px rgba(0,0,0,.12)}
/* The menu's own controls keep their 44px targets — an overflow is not an excuse for a smaller one. */
.moremenu>*{flex:none}
.likebtn:active,.rpbtn:active,.followbtn:active{transform:scale(.88)}
.likebtn[disabled],.rpbtn[disabled],.followbtn[disabled]{opacity:.55;cursor:default}
.likebtn.failed,.rpbtn.failed,.followbtn.failed{color:var(--danger);animation:shake .25s}
@keyframes shake{25%{transform:translateX(-2px)}75%{transform:translateX(2px)}}
/* Keyboard users get the same affordance the mouse does — these are real buttons, so make focus visible. */
.likebtn:focus-visible,.rpbtn:focus-visible,.followbtn:focus-visible,.replyc:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:.3rem}
@media(prefers-reduced-motion:reduce){.likebtn,.rpbtn,.followbtn{transition:none}.likebtn.failed,.rpbtn.failed,.followbtn.failed{animation:none}}
.followf{margin:.5rem 0 0}
.followbtn{font:inherit;border:1px solid var(--accent);background:var(--accent);color:var(--bg);border-radius:.4rem;padding:.3rem .9rem;cursor:pointer}
.followbtn.on{background:none;color:var(--accent)}
.scopenote{color:var(--fg-faint);font-weight:400}
.profnet{margin:.3rem 0;font-size:var(--f1)}
.fol{display:inline-block;margin:.1rem .3rem .1rem 0;color:#356}
.folmore{color:var(--fg-faint)}
.profgraph{margin:.4rem 0 0;font-size:var(--f1);color:var(--fg-dim)}
.cc{font-size:var(--f0);color:var(--fg-faint);margin-left:auto}.cc.over{color:var(--danger-text);font-weight:600}
.proform label{font-size:var(--f1);color:var(--fg-dim)}
.proform input,.proform textarea{width:100%;box-sizing:border-box;font:inherit;padding:.4rem;border:1px solid var(--line);border-radius:.3rem}
.proform button{font:inherit;padding:.35rem .9rem;cursor:pointer}
.proform .cc{margin-left:0;display:inline-block;margin-top:.15rem}
.imgpick{display:inline-block;cursor:pointer;border:1px solid var(--line);border-radius:.3rem;padding:.25rem .6rem;font-size:var(--f1)}
.imgprev{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem}
.imgprev .thumb{display:flex;flex-direction:column;gap:.2rem;width:140px}
.imgprev .thumb img{width:140px;height:100px;object-fit:cover;border-radius:.3rem;background:var(--bg-soft)}
.imgprev .thumb input,.imgprev .thumb button{font:inherit;font-size:var(--f0);padding:.2rem;width:100%;box-sizing:border-box}
.imgprev .thumb button{cursor:pointer}
/* The base layer's dark patch lived here: 5 lines hand-repainting body, a/.tag, .post, .post:hover,
.controls, .badge, .lbl, .meta/.did/.who/.permalink, .pfp, .profdid and .cc. Every one of those now
reads from a token, so the single :root override in the design pass below covers them and this block
had nothing left to do. It existed only because those rules were written as literals. */
/* ── design pass ──────────────────────────────────────────────────────────────────────────────────
Everything above grew rule-by-rule as features landed and had no shared scale: spacing was ad-hoc,
type sizes were chosen per-component, and there was no token to change. This layer sits last (so it
wins at equal specificity) and introduces the scale rather than restyling from scratch — the goal is a
consistent rhythm, not a different look for its own sake. Colours stay in the same family. */
:root{
/* --fg-faint is timestamps, handles, meta rows and card hosts — most of the secondary text in the
app. It must clear WCAG AA 4.5:1 on --bg for small text. #8b95a1 measured 3.04:1 and failed; it
had already been fixed once further up the sheet, and this block silently reverted it by being
defined later and winning. Measured on white: #6b7480 = 4.74:1. Do not lighten without checking. */
--fg:#111; --fg-dim:#5c6672; --fg-faint:#6b7480;
--bg:#fff; --bg-soft:#f7f8fa; --line:#e6e8eb; --line-soft:#f0f1f3;
--accent:#1a56db; --like:#e0245e; --repost:#17bf63;
/* Paired with --accent and with a text input's background, so the theme block only has to restate
tokens rather than restate rules. */
--bg-input:#fff; --on-accent:#fff;
/* Semantic colours. These existed only as literals, which is why the dark patch below had to
hand-repaint every element that used one. Measured on --bg #fff: --danger-text 6.47:1 and --lens
5.67:1 pass AA for text. Two were corrected on the way in rather than tokenising a known-failing
value a second time — --ok was #2b8a3e (4.37:1, text, just under), --bookmark was #f59f00
(2.13:1 as an icon, under even the 3:1 non-text bar). --warn is only ever a border, where 3:1
applies and 3.25:1 clears it. */
--danger:#c92a2a; --danger-text:#b91c1c; --danger-bg:#fde8e8;
--warn:#b8860b; --ok:#2a833b; --bookmark:#c77800; --badge-bg:#e8f0fe;
/* The lens family names a FOREIGN source — content this node relays but does not hold. Audit #45
lists that distinction as load-bearing, so it stays a visible treatment, not a hint. */
--lens:#3b5bdb; --lens-line:#cbd5e1; --lens-edge:#dbe4ff;
--s1:.25rem; --s2:.5rem; --s3:.75rem; --s4:1rem; --s5:1.5rem; --s6:2rem;
/* Radii and type land on whole pixels at a 16px root. The previous values (6/10px, and
12.48/13.6/15.2/16.8/21.6px) were on no grid at all, which is what fractional rem buys you:
sub-pixel text rendering and no two components agreeing. Read off bluesky-social/social-app's
alf tokens, which are the closest thing to a reference for this kind of client — theirs are
radius 2/4/8/12/16 and type 10/12/14/16/18/20/22/26/32/40. Ours is a subset that lands on it.
Kept in rem, not px, so a reader who raises their browser font size still gets it. */
--r1:.5rem; --r2:.75rem; --r3:1rem; /* 8 / 12 / 16 */
--f0:.75rem; --f1:.875rem; --f2:1rem; --f3:1.125rem; --f4:1.375rem; /* 12 / 14 / 16 / 18 / 22 */
}
body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
max-width:640px;margin:0 auto;padding:var(--s5) var(--s4) var(--s6);color:var(--fg);
line-height:1.55;-webkit-font-smoothing:antialiased;font-size:var(--f2);background:var(--bg)}
h1{display:flex;align-items:center;gap:var(--s2);font-size:var(--f4);letter-spacing:-.01em;margin:0 0 var(--s3)}
h1 small{font-size:var(--f1)}
/* The search form is a fixed 8rem and a flex item defaults to min-width:auto, so on a nowrap row it
cannot shrink and cannot drop — it just pushes the page wider than the viewport. Measured: at 360px
(a mainstream Android portrait width) the document was 377px, overflowing by exactly the amount
.navsearch could not give up. 390px was already fine, so this is narrow-phone only. Wrapping lets the
form take its own line instead of widening the document; min-width:0 lets it shrink first and stay on
line one where there is room. */
nav{gap:var(--s4);margin:0 0 var(--s5);padding-bottom:var(--s2);border-bottom:1px solid var(--line);font-size:var(--f1);flex-wrap:wrap}
.navsearch{min-width:0}
.navsearch input{max-width:100%}
header nav a{color:var(--fg-dim);text-decoration:none;padding:var(--s1) 0;border-bottom:2px solid transparent}
header nav a:hover{color:var(--fg)}
/* Scoped to the header: .bottomnav is also a <nav>, and this underline was reaching it by accident.
The bar marks its own active item (colour + weight) rather than inheriting a rule meant for a
horizontal text row. */
header nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)}
/* Posts read as rows in a stream, not as boxed cards — the hairline is the separator and the padding
gives each one room to breathe. */
.post{padding:var(--s4) var(--s2);border-color:var(--line);border-radius:var(--r2)}
.post .text{font-size:var(--f2);line-height:1.6;overflow-wrap:anywhere}
.post .media{margin-top:var(--s3)}
.post .media img{border-radius:var(--r2);border:1px solid var(--line-soft)}
/* Two or more images share a row instead of stacking full-width down the page. */
.media.multi{display:grid;grid-template-columns:1fr 1fr;gap:var(--s2)}
.media.multi img{height:100%;object-fit:cover;aspect-ratio:1/1;margin:0}
.media a{display:block;line-height:0}
.phead{gap:var(--s2);margin-bottom:var(--s2)}
.byline .name{font-size:var(--f2)}
.handle{color:var(--fg-faint);font-weight:400;font-size:var(--f1)}
/* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between
them is padding INSIDE a target, not dead space between two small ones. */
.meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)}
/* ── bottom navigation (phones) ────────────────────────────────────────────────────────────────────
Read off bluesky-social/social-app BottomBarWeb.tsx + BottomBarStyles.tsx: fixed to the bottom,
full width, hairline top rule, items at flex:1 with roughly 13px above and 4px below. Five
destinations, icon over label.
Phones only. bsky uses a left sidebar at desktop widths and our content column is 640px — a phone
chrome pinned under it would just be wrong. Above 640px this does not render as a bar at all.
The four destinations it shares with the top nav are hidden FROM that row under 640px, so a phone
shows each place once; saved / lists / settings / RSS stay up there, which is why the top row is
not simply hidden. */
.bottomnav{display:none}
@media(max-width:640px){
.bottomnav{display:flex;position:fixed;left:0;right:0;bottom:0;z-index:50;
background:var(--bg);border-top:1px solid var(--line);padding:0 var(--s2);
/* Clears the home indicator on phones that have one. */
padding-bottom:env(safe-area-inset-bottom,0)}
.bnitem{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
padding:13px 0 4px;min-height:44px;text-decoration:none;color:var(--fg-faint);font-size:11px}
.bnitem .nicon{display:block}
.bnitem.on{color:var(--accent);font-weight:600}
.bnitem:hover{color:var(--fg)}
/* Shown in the bar instead — one destination, one place. */
header nav a.navprimary{display:none}
}
/* ── appearance control ────────────────────────────────────────────────────────────────────────────
A segmented row of submit buttons, one per state. Buttons rather than a select because the current
value should be visible without opening anything, and 44px because this is a touch control like any
other. */
.themesec{margin:0 0 var(--s5)}
.sech{font-size:var(--f2);margin:0 0 var(--s2)}
.themeform{display:flex;gap:0;border:1px solid var(--line);border-radius:999px;overflow:hidden;width:fit-content}
.themebtn{font:inherit;font-size:var(--f1);min-height:44px;padding:0 var(--s4);border:0;
background:var(--bg);color:var(--fg-dim);cursor:pointer}
.themebtn+.themebtn{border-left:1px solid var(--line)}
.themebtn:hover{background:var(--bg-soft);color:var(--fg)}
.themebtn.on{background:var(--accent);color:var(--on-accent);font-weight:600}
.themenote{font-size:var(--f0);color:var(--fg-faint);margin:var(--s2) 0 0;max-width:44ch}
.vernote{font-size:var(--f0);color:var(--fg-faint);margin:var(--s5) 0 0}
.vernote code{font-size:var(--f0)}
/* ── composer ──────────────────────────────────────────────────────────────────────────────────────
The composer was ~40% of the first screen on a phone, permanently expanded, with four gate selects
competing with the timeline before a reader had seen a single post. The gates move behind a native
<details> so the default is what people actually do — type, maybe attach, post.
The post button and image picker stay OUTSIDE the disclosure deliberately: making someone open a
twisty before they can post would trade one bad default for a worse one, and with scripting off
there is no other way to submit. */
.copts{margin:var(--s2) 0 0}
.copts>summary{display:inline-flex;align-items:center;min-height:44px;cursor:pointer;list-style:none;
font-size:var(--f0);color:var(--fg-dim);text-decoration:underline}
.copts>summary::-webkit-details-marker{display:none}
.copts>summary:hover{color:var(--fg)}
.copts[open]>summary{margin-bottom:var(--s1)}
/* The submit row. Post sits at the end on whatever line it lands on, as it did when it lived in
.controls. */
.optshint{font-size:var(--f0);color:var(--fg-faint);margin:var(--s1) 0 0;flex-basis:100%}
.cbar{display:flex;align-items:center;gap:var(--s2);flex-wrap:wrap;margin-top:var(--s2)}
.cbar #post-btn{margin-left:auto;flex:none}
@media(max-width:640px){
.cbar #post-btn{margin-left:0;flex:1 1 100%}
}
/* ── two-column post row ───────────────────────────────────────────────────────────────────────────
Avatar column, then everything else. 42px and a 10px gutter, matching bsky's PostFeedItem
(size={42}, gap: 10). Applied to an inner .prow rather than to .post, because .post doubles as the
<details> element for collapsed moderation and a grid there would column-lay the <summary>. */
.prow{display:grid;grid-template-columns:42px 1fr;column-gap:10px;align-items:start}
/* Load-bearing. A grid item's min-width defaults to auto, so one long unbroken word (a URL, a DID)
would refuse to shrink and push the whole card past the viewport instead of wrapping. */
.pbody{min-width:0}
.pavatar{display:block;line-height:0;text-decoration:none}
/* The avatar stays 42px on a phone too — bsky does not shrink it, and it is what anchors the row. */
/* The timestamp now rides the byline row. Muted and small: it answers "when", it is not a control. */
.phead .permalink{color:var(--fg-faint);font-size:var(--f0);text-decoration:none;white-space:nowrap}
.phead .permalink:hover{text-decoration:underline}
.inreply{color:var(--fg-faint);font-size:var(--f0)}
.composer{background:var(--bg-soft);border:1px solid var(--line);border-radius:var(--r3);padding:var(--s3);margin-bottom:var(--s5)}
.composer textarea{border:1px solid var(--line);border-radius:var(--r1);background:var(--bg);resize:vertical}
.composer textarea:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:transparent}
#post-btn{background:var(--accent);color:#fff;border:none;border-radius:999px;padding:.4rem 1.1rem;font-weight:600;cursor:pointer}
#post-btn:hover{filter:brightness(1.08)}
.viewhdr{font-size:var(--f1);color:var(--fg-dim)}
.empty{color:var(--fg-faint)}
/* "older posts" — a full-width target, because on a phone a small text link at the end of 50 posts is a
miserable thing to hit. */
.more{display:block;text-align:center;margin:var(--s5) 0 0;padding:var(--s3);border:1px solid var(--line);
border-radius:999px;color:var(--accent);text-decoration:none;font-weight:600;font-size:var(--f1)}
.more:hover{background:var(--bg-soft)}
/* Lightbox. Fixed overlay; the image is capped to the viewport so a tall picture never needs scrolling. */
.lbox{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.88);display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:var(--s3);padding:var(--s4);cursor:zoom-out}
.lbox img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:var(--r1);cursor:default}
.lbalt{color:#e8eaed;font-size:var(--f1);max-width:60ch;text-align:center;margin:0}
.lbclose{position:absolute;top:var(--s3);right:var(--s3);width:2.25rem;height:2.25rem;border-radius:999px;
border:none;background:rgba(255,255,255,.14);color:#fff;font-size:1rem;cursor:pointer}
.lbclose:hover{background:rgba(255,255,255,.26)}
.thread{border-color:var(--line)}
.focus{border-left-width:3px;border-color:var(--accent)}
/* Small screens: the page had desktop margins on a phone, wasting ~15% of a narrow viewport. */
@media(max-width:640px){
/* Body stays --f2 (16px) here rather than stepping up to --f3. The step-up was added when the base
was 15.2px and read small on a phone; now that the base IS 16px it would overshoot to 18px, which
is larger than any mainstream client sets body text. The padding reduction below is the part that
was actually buying the space. */
/* The bottom bar is fixed and sits ON TOP of the page, so the last post needs room or it is
unreachable behind it. This lives in the shorthand rather than as a separate padding-bottom:
an earlier padding-bottom loses to this rule, which is exactly what happened first time —
measured 32px where 64 was intended. env() clears the home indicator where there is one. */
body{padding:var(--s4) var(--s3) calc(64px + env(safe-area-inset-bottom,0));font-size:var(--f2)}
h1{flex-wrap:wrap}
.who{margin-left:0;width:100%}
.post{padding:var(--s4) 0;border-radius:0}
.media.multi{gap:var(--s1)}
.meta{gap:var(--s2)}
}
/* ── theme ─────────────────────────────────────────────────────────────────────────────────────────
THREE states, not two: system (no attribute), explicit light, explicit dark.
It cannot be prefers-color-scheme alone. Android's WebView does not report that query truthfully to
page content unless the app opts in (setAlgorithmicDarkeningAllowed, or the deprecated
setForceDark), and favela-android does neither — so the phone has ALWAYS been told "light" whatever
the handset is set to. An explicit attribute does not consult the query at all, which is why the
settings toggle and the Android bug are one fix.
The attribute is written server-side from a cookie, so the first paint is already correct — a
class flipped by script would flash white on every navigation.
Only the token block is duplicated. The rules that used to live in here alongside it now read from
--bg-input and --on-accent, so there is one place per theme rather than one place per component —
the same reason the hand-written dark patch was deleted earlier. */
@media(prefers-color-scheme:dark){
/* System dark, unless the reader explicitly chose light. */
:root:not([data-theme="light"]){--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
--line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff;
--bg-input:#0e0f12;--on-accent:#0b0d10;
/* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1.
--danger keeps its light value because it is a fixed button background that white sits on
(5.46:1) rather than something read against the page. */
--danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e;
--warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f;
--lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f}
}
/* Explicit dark wins in BOTH directions: it must also override a light system preference. */
:root[data-theme="dark"]{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
--line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff;
--bg-input:#0e0f12;--on-accent:#0b0d10;
/* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1.
--danger keeps its light value because it is a fixed button background that white sits on
(5.46:1) rather than something read against the page. */
--danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e;
--warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f;
--lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f}
.post .media img{border-color:var(--line)}
.composer textarea{background:var(--bg-input);color:var(--fg)}
#post-btn{background:var(--accent);color:var(--on-accent)}
.more:hover{background:var(--bg-soft)}
/* ── error surface ────────────────────────────────────────────────────────────────────────────────
A failed write used to be a 1.2s flash plus a title attribute, which needs a hover — so on touch it did not
exist, and no screen reader announced it. The control reverted correctly and said nothing, which
reads as "my tap didn't register". See web/static/toast.js. JS-only by construction; nothing in the
no-JS path renders or depends on it. */
/* edit — a plain link, lighter than the delete control it sits beside */
.edited{color:var(--fg-faint);font-size:var(--f0)}
.editlink{text-decoration:none;color:var(--fg-faint)}
.editlink:hover{color:var(--accent)}
.edith{font-size:var(--f3);margin:var(--s4) 0 var(--s2)}
.editform textarea{width:100%}
.editnote,.editreach{font-size:var(--f0);color:var(--fg-dim);margin:var(--s2) 0}
.editreach em{color:var(--fg-faint);font-style:normal}
.editerr{color:var(--like);font-size:var(--f1)}
.editcancel{margin-left:var(--s3);color:var(--fg-dim);font-size:var(--f1)}
.toasts{position:fixed;left:50%;transform:translateX(-50%);bottom:var(--s4);z-index:60;
display:flex;flex-direction:column;gap:var(--s2);width:min(30rem,calc(100vw - var(--s5)));pointer-events:none}
.toast{pointer-events:auto;display:flex;align-items:flex-start;gap:var(--s3);
padding:var(--s3) var(--s4);border-radius:var(--r2);font-size:var(--f1);line-height:1.45;
border:1px solid var(--line);background:var(--bg);color:var(--fg);
box-shadow:0 6px 24px rgba(0,0,0,.16)}
.toast-err{border-color:var(--like);border-left-width:3px}
.toast-x{margin-left:auto;flex:none;border:0;background:none;cursor:pointer;
color:var(--fg-faint);font-size:var(--f1);line-height:1;padding:0 var(--s1)}
.toast-x:hover{color:var(--fg)}
@media(prefers-reduced-motion:no-preference){
.toast{animation:toastin .18s ease-out}
@keyframes toastin{from{opacity:0;transform:translateY(.4rem)}to{opacity:1;transform:none}}
}
/* ── empty states ─────────────────────────────────────────────────────────────────────────────────
The .empty class was grey italic text and nothing else, so an empty timeline looked identical to a broken
one. An empty state should say what is absent, and — where there IS one — what to do about it. */
.emptyst{color:var(--fg-dim);text-align:center;
padding:var(--s6) var(--s4);border:1px dashed var(--line);border-radius:var(--r2);background:var(--bg-soft)}
.emptyst .empty-t{display:block;font-weight:600;color:var(--fg);font-size:var(--f2);margin-bottom:var(--s1)}
.emptyst .empty-h{display:block;font-size:var(--f1);color:var(--fg-faint)}
.emptyst .empty-h a{color:var(--accent)}
/* ── reach badges ─────────────────────────────────────────────────────────────────────────────────
Where a post went. OBSERVED (fediverse — the outbox names it) is solid; ELIGIBLE (bsky — the
lexicon is one it indexes, but we did not check that it landed) is outlined and dimmer. The visual
weight difference IS the honesty: they are different claims.
Demoted from a filled pill to a hairline underline: these appear on EVERY row, and as boxes they
competed with the post text for attention on a surface where the post is the point. The claim they
make is unchanged and so is the channel that encodes it — solid underline for observed, dashed for
eligible — but it now sits at the weight of a timestamp rather than a button. A lens is deliberately
left louder than this: relaying a whole section from a foreign source is a bigger claim than "this
one post was also offered to bsky". */
.badge.reach{display:inline;border:0;border-bottom:1px solid var(--line);border-radius:0;
background:none;color:var(--fg-dim);font-weight:400;font-size:var(--f0);
padding:0;margin-left:var(--s2);letter-spacing:.01em}
.badge.reach.weak{color:var(--fg-faint);border-bottom-style:dashed}
/* ── follower / following list ────────────────────────────────────────────────────────────────────
A row per account rather than the inline run of names the profile shows. */
.folist{list-style:none;padding:0;margin:var(--s4) 0}
.folist li{display:flex;align-items:center;gap:var(--s3);padding:var(--s3) var(--s2);border-bottom:1px solid var(--line-soft)}
.folist li:last-child{border-bottom:0}
.folist .fo-id{min-width:0;flex:1}
.folist .fo-name{display:block;font-weight:600;overflow-wrap:anywhere}
.folist .fo-sub{display:block;font-size:var(--f0);color:var(--fg-faint);overflow-wrap:anywhere}
.folist .fo-src{flex:none;font-size:var(--f0);color:var(--fg-faint);
border:1px solid var(--line);border-radius:var(--r1);padding:0 var(--s2)}
.subtabs{display:flex;gap:var(--s4);margin:0 0 var(--s3);font-size:var(--f1)}
.subtabs a{color:var(--fg-dim);text-decoration:none;padding-bottom:var(--s1);border-bottom:2px solid transparent}
.subtabs a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)}
`
// The composer is a plain <form> that POSTs to /api/post (works with NO JS — the handler redirects
// back). scope select mirrors DESIGN §3 POSSE. Shown only when the viewer can write (a signed-in DID
// or the node's relay writer).
// The image picker is PROGRESSIVE ENHANCEMENT and starts hidden: attaching images needs JS (the bytes
// go to /api/upload, which returns blob refs that /api/post takes as JSON). With JS off the form is
// byte-for-byte the plain text+scope POST it always was — the no-JS path is not regressed.
// Post languages. Deliberately a SHORT list of common tags rather than every ISO code — a 180-entry
// select is not a usable control, and the field only has to be right, not exhaustive. `langs` is not
// cosmetic: bsky's custom-feed tuner language-detects posts that omit it and drops what it does not read
// as the viewer's language, so declaring the wrong one makes a post vanish from feeds while looking
// perfectly fine everywhere else (memory: bsky-custom-feed-lande-langs-drop).
const LANGS = [
['en', 'English'], ['es', 'Español'], ['pt', 'Português'], ['fr', 'Français'], ['de', 'Deutsch'],
['it', 'Italiano'], ['nl', 'Nederlands'], ['ja', '日本語'], ['ko', '한국어'], ['zh', '中文'],
['ru', 'Русский'], ['uk', 'Українська'], ['ar', 'العربية'], ['hi', 'हिन्दी'], ['tr', 'Türkçe'],
['pl', 'Polski'], ['sv', 'Svenska'], ['da', 'Dansk'], ['fi', 'Suomi'], ['cs', 'Čeština']
]
function composer (canWrite, quoting = null, lists = [], viewer = null) {
if (!canWrite) return ''
// ⛔ SAY WHICH IDENTITY THIS WRITES AS. A favela node hosts MANY DIDs — your own plus every co-owned
// community — and this form named none of them, so "post" meant "post as whichever DID the session
// happens to be bound to". On 2026-08-18 that sent a post intended for a community out as a personal
// identity, to bsky, where it cannot be recalled. The page header's `whoami` is not this: it answers
// "who is signed in" in the opposite corner, and with no handle resolved it prints 32 characters of
// raw DID, which reads as decoration.
//
// Rendered only when the identity is actually KNOWN. `canWrite` is also true for the node's own relay
// writer, where this page holds no DID — and a composer that says "posting as this node" when it has
// not established that would be the same lie one step quieter.
const who = viewer && viewer.did ? (viewer.handle || shortDid(viewer.did)) : null
const whoLine = who ? `<p class="composerwho">posting as <strong>${esc(who)}</strong></p>` : ''
// Quoting: a hidden quoteOf plus a PREVIEW of what is being quoted. Without the preview the composer
// looks identical to a normal one and you cannot tell what (or whether) you are quoting.
const q = quoting
? `<input type="hidden" name="quoteOf" value="${esc(quoting.uri)}">
<div class="qpreview">❝ quoting <strong>${esc(quoting.who || shortDid(quoting.did || ''))}</strong>${quoting.text ? `: <span class="qptext">${esc(String(quoting.text).slice(0, 140))}</span>` : ''} <a class="qcancel" href="/">cancel</a></div>`
: ''
return `<form class="composer" method="POST" action="/api/post">${whoLine}${q}
<label class="sr-only" for="composer-text">what do you want to post?</label><textarea id="composer-text" name="text" rows="2" placeholder="Say something…" style="width:100%;box-sizing:border-box;font:inherit;padding:.5rem" required></textarea>
<details class="copts"><summary>post options</summary><div class="controls"><label>scope <select name="scope">
<option value="public">public</option>
<option value="local">local only</option>
</select></label><label>lang <select name="lang">
${LANGS.map(([code, label]) => `<option value="${code}">${esc(label)}</option>`).join('')}
</select></label><label>quotes <select name="quoteGate">
<option value="anyone">anyone</option>
<option value="nobody">nobody</option>
</select></label><label>replies <select name="gate">
<option value="everyone">anyone</option>
<option value="following">people you follow</option>
<option value="mentioned">mentioned</option>
<option value="nobody">nobody</option>
${lists.map((l) => `<option value="${esc(l.uri)}">only: ${esc(l.name)}</option>`).join('')}
</select></label>
<p class="optshint">a line with only --- starts a new post in a thread</p></div></details>
<div class="cbar"><span id="img-controls" hidden><label class="imgpick">📎 image<input type="file" id="post-image" accept="image/*" multiple hidden></label></span><span id="charcount" class="cc" aria-live="polite"></span><button id="post-btn">post</button></div>
<p class="composerhint">public posts syndicate to bsky · local-only stays on favela</p>
<div id="img-preview" class="imgprev"></div></form>`
}
// Settings: the avatar. This exists because a lost avatar is otherwise unfixable — the blob bytes are
// gone from every node, so nothing can restore it except the owner re-uploading. Doing so also lands
// the first blob in the content-addressed OverlayBlobStore, so it migrates as a side effect.
// Appearance. Deliberately available SIGNED OUT as well: a theme is a property of the reader's eyes,
// not of an account, and the public instance serves mostly anonymous readers.
//
// Three states, and "system" is the ABSENCE of a preference rather than a third stored value — so a
// reader who never touches this is in exactly the state everyone was in before it existed.
//
// A plain form with a submit per option: no JS, one tap, and it works in the Android WebView, which is
// the case that prompted it. `return` sends you back where you were.
export function themeControl (theme = null, returnTo = '/settings') {
const opts = [
['system', 'System', 'follow the device setting'],
['light', 'Light', ''],
['dark', 'Dark', '']
]
const cur = (theme === 'light' || theme === 'dark') ? theme : 'system'
const buttons = opts.map(([v, label, hint]) => {
const on = v === cur
return `<button class="themebtn${on ? ' on' : ''}" name="theme" value="${v}"${on ? ' aria-current="true"' : ''}${hint ? ` title="${esc(hint)}"` : ''}>${esc(label)}</button>`
}).join('')
return `<section class="themesec"><h2 class="sech">Appearance</h2>
<form class="themeform" method="POST" action="/api/theme"><input type="hidden" name="return" value="${esc(returnTo)}">${buttons}</form>
<p class="themenote">System follows your device. On Android the in-app browser does not report that setting, so choose Light or Dark there.</p></section>`
}
// The version of the RUNNING code. `pgh app status` reports the version on DISK, which an app that
// installed an update and never restarted will report while still serving the old one — so a number
// rendered by the process itself is the only one that answers "what am I actually looking at".
//
// Shown SIGNED OUT as well, and on the phone this is the only place a version appears at all: the
// handset has no `pgh app status`, and its worklet bundle arrives by OTA independently of any APK.
export function versionNote () {
return `<p class="vernote">favela-web <code>${esc(VERSION)}</code></p>`
}
// App updates, on a handset only. Rendered ONLY when the native shell has said it can service these
// buttons (`shell.ota`), because the shell and the worklet bundle version INDEPENDENTLY — the bundle
// arrives by OTA, the shell only when an APK is installed — so a new bundle can find itself inside an
// old shell that has no message handler. Buttons that silently do nothing are worse than no buttons.
//
// ⛔ WHY THIS EXISTS AT ALL. Staging and applying are deliberately separate (applying to a running node
// means swapping code under itself), so an update discovered during boot N can only take effect at boot
// N+1 — and with a 6-hour poll and no way to restart the node, "up to date" is a thing the phone can
// only become by accident. Measured on a handset 2026-08-24: the device ran a bundle whose replacement
// had been staged for over half an hour with no user-reachable way to apply it.
//
// This does NOT make updates event-driven; the 6h poll is still the last polling loop in the stack and
// announce-over-swarm is still the real fix. It makes the existing design operable by the person holding
// the phone.
//
// Signed OUT as well, on purpose: a stale session is one of the states a restart repairs, and gating the
// repair on being signed in would put it behind the thing that is broken.
export function appUpdateSection (shell = null) {
if (!shell || !shell.ota) return ''
const short = (h) => (typeof h === 'string' && h.length > 12 ? h.slice(0, 12) + '…' : (h || ''))
const running = shell.running ? `<code>${esc(short(shell.running))}</code>` : '<span class="empty">the bundle shipped in the app</span>'
// "staged" is only news when it is NOT what is already running — otherwise it reads as a pending
// update to someone who is already on it, which is the exact lie the OTA log used to tell.
const pending = shell.staged && shell.staged !== shell.running
const stagedRow = pending
? `<p class="otapend">An update is staged: <code>${esc(short(shell.staged))}</code> — restart the node to run it.</p>`
: '<p class="empty">No update staged.</p>'
const checked = shell.checkedAt ? `<p class="empty">last checked ${esc(relTime(shell.checkedAt))}</p>` : ''
return `<section class="authbox otasec">
<h3>App updates</h3>
<p class="empty">running <span class="otarun">${running}</span></p>
${stagedRow}
${checked}
<p><button class="devbtn" data-ota="check">check for updates</button>
<button class="devbtn${pending ? ' otaprim' : ''}" data-ota="restart">restart the node</button></p>
<p class="empty">Checking downloads in the background; nothing changes until the node restarts. A restart
also re-signs this page in, which is the fix if it has started refusing you.</p>
<style>.otapend{margin:.3rem 0}.otaprim{border-color:#2563eb}.otarun code{font-size:.85rem}</style>
<script>
// One-way to the shell, deliberately: it acts and then reloads this page, so the state above is always
// SERVER-rendered from what the worklet actually knows. A reply channel would be a second source of
// truth for the same three fields.
document.querySelectorAll('[data-ota]').forEach(function (b) {
b.addEventListener('click', function () {
var bridge = window.ReactNativeWebView
if (!bridge) return
b.disabled = true
b.textContent = b.dataset.ota === 'restart' ? 'restarting…' : 'checking…'
bridge.postMessage(JSON.stringify({ t: 'app-update', op: b.dataset.ota }))
})
})
</script>
</section>`
}
// Pairing, on a handset, from the page instead of an overlay.
//
// ⛔ WHAT THIS OPENS IS NOT A LOGIN. The ceremony hands this phone the DID's SHARED SIGNING KEY, after
// which it authors as that identity on its own, offline — and removing a device later stops it being
// ADMITTED but cannot un-know a key it already holds. The full warning lives on the native screen this
// button opens (app/Pair.jsx, where the wording is explicitly part of the security model), so this says
// enough to set expectations and does not paraphrase it into something softer.
//
// It lives here because it always should have: favela-android's own note says "the old UI reached
// pairing from settings for exactly this reason", and it only became a floating button when the native
// settings screen went away with the move to a WebView. On the handset that overlay sat on top of the
// bottom nav's Home item, so tapping Home opened the pairing screen — reported 2026-08-25.
//
// Gated on `shell.pair`, a SEPARATE capability from `shell.ota`: the page arrives by OTA and the shell
// by APK, so a page that lands on a shell which cannot service this must render nothing at all rather
// than a button that silently does nothing.
export function pairSection (shell = null) {
if (!shell || !shell.pair) return ''
return `<section class="authbox pairsec">
<h3>Connect this phone</h3>
<p class="empty">Paste an invite minted by a device that already holds your identity — favela's
<em>Generate a device invite</em>, above or on your desktop. Use this when setting the phone up, or
when it holds your identity but posts are being refused because it is not on the room's member list.</p>
<p class="empty"><strong>It receives the key that signs your posts.</strong> From then on this phone can
post as you, by itself. The next screen says so in full before anything happens.</p>
<p><button class="devbtn" data-ota="pair">open the pairing screen</button></p>
<script>
document.querySelectorAll('[data-ota="pair"]').forEach(function (b) {
b.addEventListener('click', function () {
var bridge = window.ReactNativeWebView
if (!bridge) return
b.disabled = true
bridge.postMessage(JSON.stringify({ t: 'app-update', op: 'pair' }))
})
})
</script>
</section>`
}
// Where this node may read from, as a setting rather than a deployment detail.
//
// ⛔ THIS WAS ONLY REACHABLE BY EDITING apps.json AND RESTARTING — and on a handset there is no
// apps.json at all, so it could not be changed there by any means. The consequence was visible rather
// than theoretical: a bsky account's page rendered a real name and avatar and "no posts from this
// author", because reading their posts needs a lens and no node had one configured (hyper-social #53).
//
// Shown to the node's OWN identity only. Repointing where a node sends the DIDs it looks up is an
// operator act, not a viewer preference, and on a public instance a signed-in guest must not have it.
//
// A plain form: it works with scripting off, like themeControl and the profile form.
export function lensControl ({ lens = null, isOperator = false, returnTo = '/settings' } = {}) {
if (!isOperator) return ''
const on = !!(lens && lens.enabled)
const current = (lens && lens.lenses && lens.lenses[0] && lens.lenses[0].base) || ''
const names = (lens && lens.lenses ? lens.lenses : []).map((l) => l.name).filter(Boolean)
const state = on
? `<p class="empty">Reading from <strong>${esc(names.join(', ') || 'a lens')}</strong> at <code>${esc(current)}</code>.</p>`
: '<p class="empty">Off. Posts by people this node does not index \u2014 anyone on bsky \u2014 show as an empty profile.</p>'
return `<section class="authbox lenssec">
<h3>Remote reading</h3>
<p class="empty">Point this at a bridge and profiles this node has never indexed can show their posts,
clearly marked as coming from that source. Nothing is stored and nothing is federated \u2014 it fills
in a page that would otherwise be blank. Leave it empty to read nothing remote, the default.</p>
${state}
<form method="POST" action="/api/lens" class="rowform">
<input type="hidden" name="return" value="${esc(returnTo)}">
<p><label for="lens-url">bridge url</label><br>
<input id="lens-url" name="bridgeUrl" type="url" inputmode="url" placeholder="https://pds.example.net"
value="${esc(current)}" size="34"></p>
<p><button type="submit">save</button>
<span class="empty">applies immediately \u2014 no restart</span></p>
</form>
</section>`
}
export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null, lens = null, isOperator = false } = {}) {
if (!viewer) {
// Appearance still renders here — it is not an account setting. So does App updates: a restart is
// what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when
// it is needed.
return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + pairSection(shell) + lensControl({ lens, isOperator }) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
}
const cid = profile && profile.avatar && blobCid(profile.avatar.ref)
const current = (cid && did && isSafeCid(cid))
? `<img class="avatar" src="${esc(blobUrl(did, cid))}" alt="current avatar">`
: '<div class="avatar none" title="no avatar set">no avatar</div>'
// Server-rendered so the list is readable with JS off. A revoked device is shown rather than hidden:
// "no devices" and "one revoked device" are different facts, and the second is the one worth seeing.
const ymd = (ms) => { try { return new Date(ms).toISOString().slice(0, 10) } catch { return '' } }
const rows = devices.length
? devices.map((d) => `<li class="dev${d.revoked ? ' revoked' : ''}" data-subkey="${esc(d.subKey)}">
<code>${esc(String(d.subKey).slice(0, 20))}…</code>
<span class="devmeta">${d.revoked ? 'revoked' : 'active'}${d.addedAt ? ' · ' + esc(ymd(d.addedAt)) : ''}${d.label ? ' · ' + esc(d.label) : ''}</span>
<button class="devbtn" data-action="${d.revoked ? 'restore' : 'revoke'}">${d.revoked ? 'restore' : 'revoke'}</button>
</li>`).join('')
: '<li class="empty">no browsers paired yet</li>'
return page({
title: 'settings · favela',
viewer,
active: null,
theme,
body: `<div class="viewhdr">Settings <a href="/">← back</a></div>
${themeControl(theme)}
${versionNote()}
${appUpdateSection(shell)}
${pairSection(shell)}
${lensControl({ lens, isOperator })}
<section class="authbox">
<h3>Profile picture</h3>
<p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p>
<div class="avatarrow">${current}
<div>
<p><label class="imgpick">choose an image<input type="file" id="avatar-file" accept="image/*" hidden></label></p>
<p class="empty">PNG, JPEG, GIF or WebP · max 1 MB. Your hpp stores it and signs the profile update; favela never holds your key.</p>
<p id="avatar-status" aria-live="polite"></p>
</div>
</div>
</section>
<section class="authbox">
<h3>Connect a device</h3>
<p class="empty">
For another <strong>node</strong> — a phone running favela-android, or a second machine. It receives
the key that signs your posts, so from then on it posts as you on its own, offline, and replicates
your repo directly. That is different from signing a browser in below, where the key never leaves
this machine.
</p>
<p class="empty">
<strong>⚠ Whoever reads this code before your device uses it can post as you.</strong> It is
single-use and short-lived. Losing the device means rotating your identity key, which is permanent.
</p>
<form method="post" action="/api/devices/invite" class="rowform">
<button type="submit">Generate a device invite</button>
</form>
<p id="device-invite" aria-live="polite"></p>
<p class="empty">
Paste the code into the device and keep this page open — the tunnel closes when the invite is used
or expires. Do <em>not</em> use the browser sign-in code below for a node: it delegates a browser
sub-key, and a node will silently wait until it times out.
</p>
</section>
<section class="authbox">
<h3>Banner</h3>
<p class="empty">The wide image across the top of your profile. Same limits as the avatar: PNG, JPEG, GIF or WebP, max 1 MB.</p>
${(profile && profile.banner && blobCid(profile.banner.ref) && isSafeCid(blobCid(profile.banner.ref)) && did)
? `<div class="profbanner small"><img src="${esc(blobUrl(did, blobCid(profile.banner.ref)))}" alt="current banner"></div>`
: '<p class="empty">no banner set</p>'}
<p><label class="imgpick">choose a banner<input type="file" id="banner-file" accept="image/*" hidden></label></p>
<p id="banner-status" aria-live="polite"></p>
</section>
<section class="authbox">
<h3>Name & bio</h3>
<p class="empty">This is what other peers see instead of your DID. A plain form — it works with JavaScript off.</p>
<form method="POST" action="/api/profile" class="proform">
<p><label for="pf-name">display name</label><br>
<input id="pf-name" name="displayName" type="text" maxlength="640" value="${esc((profile && profile.displayName) || '')}" placeholder="${esc(shortDid(did || ''))}">
<span id="pf-name-cc" class="cc"></span></p>
<p><label for="pf-bio">bio</label><br>
<textarea id="pf-bio" name="description" rows="3" maxlength="2560" placeholder="a sentence about you">${esc((profile && profile.description) || '')}</textarea>
<span id="pf-bio-cc" class="cc"></span></p>
<p><button type="submit">save</button> <span class="empty">your hpp signs the update — leaving a field empty clears it</span></p>
</form>
</section>
<section class="authbox">
<h3>Devices</h3>
<p class="empty">Browsers allowed to sign in as this DID. Pairing is minted by your hpp — favela never holds your key.</p>
<ul class="devlist">${rows}</ul>
<p><label class="imgpick" for="pair-label">name this device</label>
<input id="pair-label" type="text" placeholder="my phone" maxlength="64">
<button id="pair-new" class="devbtn">generate join code</button></p>
<div id="pair-out" hidden>
<p class="empty">Open this on the device you want to sign in — or paste the code into its login box. One use, expires in 5 minutes.</p>
<p><a id="pair-url" href="#" rel="noreferrer"></a></p>
<p><code id="pair-code" class="paircode"></code> <button id="pair-copy" class="devbtn">copy</button></p>
</div>
<p id="dev-status" aria-live="polite"></p>
</section>`,
clientScript: `<style>
.authbox{border:1px solid #e5e5e5;border-radius:.5rem;padding:1rem;margin:1rem 0}
.avatarrow{display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap}
.avatar{width:96px;height:96px;border-radius:50%;object-fit:cover;background:#f3f4f6}
.avatar.none{display:flex;align-items:center;justify-content:center;font-size:.75rem;color:#888;border:1px dashed #ccc}
.imgpick{display:inline-block;cursor:pointer;border:1px solid #ccc;border-radius:.3rem;padding:.35rem .7rem;font:inherit}
#avatar-status.ok{color:#15803d}#avatar-status.err{color:#b91c1c}
.devlist{list-style:none;padding:0;margin:.5rem 0}
.dev{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap;padding:.4rem 0;border-bottom:1px solid #eee}
.dev.revoked code,.dev.revoked .devmeta{opacity:.55;text-decoration:line-through}
.devmeta{color:#666;font-size:.85rem}
.devbtn{font:inherit;border:1px solid #ccc;border-radius:.3rem;padding:.25rem .6rem;background:#fafafa;cursor:pointer}
.devbtn[disabled]{opacity:.5;cursor:default}
.paircode{word-break:break-all;font-size:.8rem;background:#f3f4f6;padding:.2rem .35rem;border-radius:.25rem}
#pair-label{font:inherit;padding:.3rem .4rem;border:1px solid #ccc;border-radius:.3rem}
#dev-status.ok{color:#15803d}#dev-status.err{color:#b91c1c}
@media(prefers-color-scheme:dark){.proform input,.proform textarea,.proform button{background:#222;color:#eee;border-color:#444}.proform label{color:#9ca3af}.dev{border-color:#333}.devbtn{background:#222;color:#eee;border-color:#444}.paircode{background:#1f2937}#pair-label{background:#222;color:#eee;border-color:#444}.devmeta{color:#9ca3af}#dev-status.ok{color:#4ade80}#dev-status.err{color:#f87171}.authbox{border-color:#333}.avatar{background:#1f2937}.imgpick{background:#222;color:#eee;border-color:#444}#avatar-status.ok{color:#4ade80}}
</style>
<script type="module" src="/static/settings.js"></script>`
})
}
// The signed-in/anonymous indicator + sign-in/out link (the DID session layer wires these routes).
// Is what you are reading current? The one state this UI never had.
//
// ⛔ THIS NODE IS DESIGNED TO BE INTERMITTENTLY ONLINE (favela-web/spec/mobile-node.md §5) and every page
// rendered identically whether the index advanced a second ago or froze two days ago. The freeze
// signature is invisible by construction: peers connected, daemon up, backfill "successful", profile
// renders — just missing everything appended since. On 2026-08-18 the handset's node was dead for 68
// minutes behind a timeline that looked merely quiet.
//
// frontsDiag has computed the answer since the ashatron freeze; it was only ever rendered on /status,
// which a reader never opens. This is the one sentence a reader needs, on the page they are on.
//
// ⛔ NEVER CLAIMS CURRENCY. The freshest front we can SEE may not be the freshest that exists, so "up to
// date" is not a statement this node is entitled to make. Two things are sayable — "no peer is serving
// this right now" and "we are behind by N on the line we are on" — and everything else renders NOTHING.
// Silence here means "no reason to doubt", not "verified fresh"; a green tick would be the lie.
export function freshnessNote (diag) {
if (!diag || !Array.isArray(diag.dids) || !diag.dids.length) return ''
// No live front = nobody answered for this identity. This is the handset's case, and the reader's
// question ("is this all there is?") has a real answer we were withholding.
const dark = diag.dids.filter((d) => !((d && d.fronts) || []).some((f) => f && f.live))
if (dark.length) {
const n = dark.length
const total = diag.dids.length
return `<p class="stale">⚠ this view may be <strong>out of date</strong> — ${n} of ${total} ${n === 1 ? 'identity has' : 'identities have'} no live peer right now. <a href="/status">why</a></p>`
}
// On a line, but a shorter one than we can see: the freeze signature, in a reader's words. Stated as a
// COUNT, because "syncing…" is what the old empty state said while nothing was happening.
const behind = diag.dids.filter((d) => d && Number.isFinite(d.behindBy) && d.behindBy > 0)
if (behind.length) {
const worst = behind.reduce((a, b) => (b.behindBy > a.behindBy ? b : a))
return `<p class="stale">⚠ <strong>behind</strong> by ${worst.behindBy} record${worst.behindBy === 1 ? '' : 's'} on ${esc(shortDid(worst.did))}. <a href="/status">why</a></p>`
}
return ''
}
function whoami (viewer) {
if (viewer && viewer.did) return `<span class="who">${esc(viewer.handle || viewer.did)} · <a href="/logout">sign out</a></span>`
return '<span class="who"><a href="/login">sign in</a></span>'
}
// Icons for the bottom bar. Inline SVG rather than Unicode glyphs: a nav drawn in ⌂ ⌕ ☰ is the single
// most reliable way to look unfinished, and these inherit currentColor so they theme for free. 24px to
// match the rest of the touch furniture. aria-hidden because each sits beside a real text label.
const ICON = {
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
following: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M17 5.2a3.5 3.5 0 0 1 0 6.6"/><path d="M18.5 20a6.5 6.5 0 0 0-2.5-5.1"/>',
bell: '<path d="M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6"/><path d="M10.5 20a2 2 0 0 0 3 0"/>',
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>'
}
const icon = (k) => `<svg class="nicon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[k]}</svg>`
// The phone bar. Five destinations, matching bsky's shape (home / search / a people feed /
// notifications / you) — read from their BottomBarWeb.tsx rather than recalled.
//
// Signed out, `following`, `notifications` and a profile do not exist, so it degrades to three rather
// than offering links to permanently empty pages. Each item is flex:1, so a target is a fifth of the
// screen wide — the same "make it unmissable" instinct as bsky's hitSlop on the post controls.
//
// The items here are HIDDEN from the top nav under 640px (see .navprimary in STYLE), so a phone shows
// each destination once. Everything that is not here — saved, lists, settings, RSS — stays in the top
// nav, which is why that row is not simply hidden on mobile.
function bottomNav (active, viewer = null) {
const signedIn = !!(viewer && viewer.did)
const item = (href, label, key, ic) =>
`<a href="${href}" class="bnitem${active === key ? ' on' : ''}"${active === key ? ' aria-current="page"' : ''}>${icon(ic)}<span>${label}</span></a>`
const items = [
item('/', 'Home', 'timeline', 'home'),
item('/search', 'Search', 'search', 'search'),
signedIn ? item('/following', 'Following', 'following', 'following') : '',
signedIn ? item('/notifications', 'Alerts', 'notifications', 'bell') : '',
signedIn ? item(`/profile/${encodeURIComponent(viewer.did)}`, 'You', 'profile', 'user') : ''
].join('')
return `<nav class="bottomnav" aria-label="primary">${items}</nav>`
}
function nav (active, viewer = null) {
// `primary` marks the destinations the bottom bar also carries, so they can be hidden from this row
// on a phone without hiding the row itself — the overflow lives here and must stay reachable.
const item = (href, label, key, primary = false) => `<a href="${href}"${primary ? ' class="navprimary"' : ''}${active === key ? ' aria-current="page"' : ''}>${label}</a>`
// Following is only meaningful signed in — an anonymous viewer has no follow graph, so offering the
// link would lead to a permanently empty page.
const following = viewer && viewer.did ? item('/following', 'following', 'following', true) : ''
const bookmarks = viewer && viewer.did ? item('/bookmarks', 'saved', 'bookmarks') : ''
const notifs = viewer && viewer.did ? item('/notifications', 'notifications', 'notifications', true) : ''
const lists = viewer && viewer.did ? item('/lists', 'lists', 'lists') : ''
// A plain GET form: search works with scripting off and the result is a shareable URL.
const search = `<form class="navsearch" method="GET" action="/search" role="search"><input type="search" name="q" placeholder="search" aria-label="search"></form>`
return `<nav>${item('/', 'timeline', 'timeline', true)}${following}${notifs}${bookmarks}${lists}${item('/search', 'search', 'search', true)}${item('/settings', 'settings', 'settings')}${item('/feed.xml', 'RSS', 'rss')}${search}</nav>`
}
// Progressive enhancement for the whole-entry link, and the one thing the pure-CSS version costs.
//
// .pstretch is a real anchor lying ON TOP of the card — that is what makes the card clickable with no
// JavaScript at all. But an anchor on top of text also swallows the drag that selects that text, and
// selecting a post's words is part of reading it. That would be a regression traded for the click.
//
// So when scripting IS available, the overlay is taken out of the hit test (pointer-events:none, via
// the js-postclick class this adds) and the same job is done by a handler that knows two things CSS
// cannot: whether the reader is currently selecting text, and whether they asked for a new tab. With
// scripting off, none of this runs and the pure-CSS overlay stands unchanged.
//
// Delegated on `document` rather than bound per-post, so it keeps working for posts swapped in by the
// live refresh without anything having to re-bind.
const POSTCLICK = `<script>
(function () {
var d = document
d.documentElement.className += ' js-postclick'
var CONTROL = 'a,button,form,summary,input,textarea,select,label'
function href (e) {
if (!e.target || !e.target.closest) return null
var post = e.target.closest('.post')
if (!post) return null
if (e.target.closest(CONTROL)) return null
var link = post.querySelector(':scope > .pstretch')
if (!link) return null
var sel = d.getSelection && d.getSelection()
if (sel && !sel.isCollapsed && post.contains(sel.anchorNode)) return null
return link.getAttribute('href')
}
d.addEventListener('click', function (e) {
if (e.button !== 0) return
var h = href(e)
if (!h) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) window.open(h, '_blank', 'noopener')
else location.href = h
})
d.addEventListener('auxclick', function (e) {
if (e.button !== 1) return
var h = href(e)
if (!h) return
e.preventDefault()
window.open(h, '_blank', 'noopener')
})
})()
</script>`
// Full HTML document. `client` (optional) is the progressive-enhancement <script> src/inline appended
// before </body>; with it absent the page is fully functional server-rendered.
export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null, theme = null } = {}) {
// Written server-side so the FIRST paint is already correct. A class flipped by script would flash
// the wrong theme on every navigation, and with scripting off would never apply at all. Absent
// attribute = follow the system, which is the default and the only state that existed before.
const themeAttr = (theme === 'dark' || theme === 'light') ? ` data-theme="${theme}"` : ''
return `<!doctype html><html lang="en"${themeAttr}><head><meta charset="utf-8">
<!-- viewport-fit=cover is what makes env(safe-area-inset-*) resolve to anything. Without it those
insets are INERT — they compute to 0 — and .bottomnav's padding-bottom plus body's
calc(64px + env(safe-area-inset-bottom)) were both written expecting them to work. On the handset
the effect was a bottom bar floating ABOVE the gesture strip with page content sliding underneath
it, because the WebView paints edge-to-edge while the layout viewport stops at the safe area.
Observed on a moto g power, 2026-08-25. The rules were already correct; this is the token that
switches them on. -->
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>${esc(title)}</title>
<link rel="alternate" type="application/atom+xml" title="favela timeline" href="/feed.xml">
<meta name="favela-pds" content="${esc(pds)}">
<style>${STYLE}</style></head><body>
<a class="skip" href="#main">skip to content</a>
<header>
<h1>${esc(heading || 'favela')} <small>· native P2P view</small>${whoami(viewer)}</h1>
${nav(active, viewer)}
</header>
<main id="main" tabindex="-1">
${freshnessNote(freshness)}
${composer(canWrite, quoting, composerLists, viewer)}
${body}
</main>
${bottomNav(active, viewer)}
${clientScript}
${POSTCLICK}
<script type="module" src="/static/actions.js"></script>
<script type="module" src="/static/draft.js"></script>
<script type="module" src="/static/report.js"></script>
</body></html>`
}
// ── whole-page views ──────────────────────────────────────────
export function timelinePage ({ result, viewer = null, canWrite = false, clientScript = '', active = 'timeline', emptyMsg = 'no posts yet (indexing…)', quoting = null, composerLists = [], apRkeys = null, freshness = null } = {}) {
const base = active === 'following' ? '/following' : '/'
const posts = withReach(withViewer(result && result.posts, viewer, base), apRkeys)
return page({
title: 'favela',
viewer,
active,
canWrite,
quoting,
composerLists,
freshness,
body: renderPosts(posts, emptyMsg) + moreLink(result && result.cursor, base),
// Composer image picker is progressive enhancement — only loaded when there is a composer to enhance.
clientScript: clientScript || (canWrite ? '<script type="module" src="/static/compose.js"></script>' : '')
})
}
// Profile tabs, each one an app.bsky.feed.getAuthorFeed filter value — the same split bsky makes, so a
// tab means the same thing here as anywhere else in atproto.
//
// `posts_with_video` is a valid filter and is deliberately NOT a tab: our composer only ever writes
// app.bsky.embed.images (writer.js), so a Videos tab would be permanently empty on this node. The filter
// stays reachable via ?filter= for records ingested from peers that do post video.
//
// Order and default follow the bsky app: Posts first, and Posts is what you land on. NOTE this CHANGES
// what a profile shows by default — this page used to list replies inline, and now they live behind the
// Replies tab. Deliberate, for parity; it is the one behaviour change in this feature.
//
// (The appview's own default stays the LEXICON default, posts_with_replies. Which tab a page opens on is
// a UI decision, so it lives here and is passed in, rather than being baked into the query layer.)
const PROFILE_TABS = [
['posts_no_replies', 'Posts', 'no posts from this author'],
['posts_with_replies', 'Replies', 'no posts or replies from this author'],
['posts_and_author_threads', 'Threads', 'no self-threads from this author'],
['posts_with_media', 'Media', 'no posts with images from this author']
]
const PROFILE_DEFAULT = 'posts_no_replies'
// Network-wide followers, from a backlink index (DESIGN §7.6 Tier C — referenced, never retained).
//
// ⛔ DELIBERATELY A SEPARATE LINE FROM THE LOCAL COUNT. Our number counts follows by DIDs this node
// indexes; this one counts the whole network. Different denominators, so a sum would be meaningless and
// silently replacing one with the other would make a partial view look total. They are labelled and kept
// apart — the same refusal lens-import makes for remote counts.
//
// Only VERIFIED handles are shown as names. An unverified follower renders as a short DID, because a
// follower list is exactly the surface where claiming someone else's handle would pay.
function netFollowers (net) {
if (!net || !net.count) return ''
const names = (net.followers || []).map((f) => {
const label = f.verified && f.handle ? '@' + f.handle : shortDid(f.did)
return `<a class="fol" href="/profile/${encodeURIComponent(f.did)}" title="${esc(f.did)}">${esc(label)}</a>`
}).join(' ')
const more = net.truncated ? `<span class="folmore"> +${net.count - net.shown} more</span>` : ''
return `<p class="profnet"><strong>${net.count}</strong> follower${net.count === 1 ? '' : 's'}<span class="scopenote"> across the network</span>${names ? `<br>${names}${more}` : ''}</p>`
}
// Mute and block, offered together but deliberately NOT alike.
//
// MUTE is reversible, private, and nobody else ever learns of it — a quiet control, plain text, one
// click. BLOCK writes a PUBLIC record that federates, so it asks first via the same native <details>
// the delete confirm uses (no JS), and is styled as the heavier action. Presenting them identically
// would invite someone to publish a block when they meant to quietly mute.
//
// Both are plain forms; neither needs scripting. Hidden entirely on your own profile and when signed out.
// Add someone to one of YOUR lists, from their profile — the only place it makes sense. Shown only when
// you actually have lists; offering an empty picker would be a dead control.
function listPicker (did, graph) {
const ls = (graph && graph.viewerLists) || []
if (!ls.length) return ''
const opts = ls.map((l) => `<option value="${esc(l.uri)}">${esc(l.name)}${l.member ? ' ✓' : ''}</option>`).join('')
return `<form class="modf" method="POST" action="/api/list-member">
<input type="hidden" name="subject" value="${esc(did)}">
<input type="hidden" name="return" value="/profile/${encodeURIComponent(did)}">
<select name="list" aria-label="list">${opts}</select>
<button class="mutebtn">add / remove</button>
</form>`
}
function modControls (did, graph) {
if (!graph || !graph.canFollow || graph.isSelf) return ''
const back = `/profile/${encodeURIComponent(did)}`
const hidden = `<input type="hidden" name="did" value="${esc(did)}"><input type="hidden" name="return" value="${esc(back)}">`
const mute = `<form class="modf" method="POST" action="/api/mute">${hidden}<button class="mutebtn${graph.viewerMuted ? ' on' : ''}">${graph.viewerMuted ? 'unmute' : 'mute'}</button></form>`
const block = graph.viewerBlocked
? `<form class="modf" method="POST" action="/api/block">${hidden}<button class="blockbtn on">unblock</button></form>`
: `<details class="modw"><summary>block</summary><form class="modf" method="POST" action="/api/block">${hidden}<button class="blockbtn">really block · this is public</button></form></details>`
return `<div class="modrow">${mute}${block}${listPicker(did, graph)}</div>`
}
// A remote account's posts, via a lens. Same attribution rule as every other borrowed view: a NAMED
// source, escaped text, and NO like/reply/quote controls, because acting on a post we do not hold would
// be a lie. Media thumbs load from the lens's CDN — we hold no blob for these — with no-referrer so
// browsing here does not leak the page you are on.
function renderLensPosts (lp) {
if (!lp || !lp.posts || !lp.posts.length) return ''
const rows = lp.posts.map((r) => {
const when = r.createdAt ? `<time datetime="${esc(r.createdAt)}">${esc(relTime(r.createdAt))}</time>` : ''
const imgs = (r.images || []).map((im) => `<img class="lthumb" src="${esc(im.thumb)}" alt="${esc(im.alt || '')}" loading="lazy" referrerpolicy="no-referrer">`).join('')
return `<article class="lpost">
${r.text ? `<div class="ltext">${esc(r.text)}</div>` : ''}
${imgs ? `<div class="lmedia">${imgs}</div>` : ''}
<div class="meta">${when}${r.replyCount ? ` · ${r.replyCount} repl${r.replyCount === 1 ? 'y' : 'ies'} there` : ''}</div>
</article>`
}).join('')
return `<section class="lens"><h3>${lp.posts.length} post${lp.posts.length === 1 ? '' : 's'} via <span class="lensname">${esc(lp.name)}</span> · not indexed by this node</h3>${rows}</section>`
}
// The profile's own base href, needed above the `base` const below (the counts render before it).
const base0 = (did) => `/profile/${encodeURIComponent(did)}`
export function profilePage ({ did, result, viewer = null, profile = null, graph = null, filter = PROFILE_DEFAULT, lensProfile = null, lensPosts = null, apRkeys = null } = {}) {
// We hold no profile record for this DID (a bsky account reached via a backlink is the common case), so
// a lens answered instead. Shown, but never passed off as ours: a named source, no follow/act
// affordances beyond what the graph already allows, and the avatar loads from the LENS's CDN because we
// hold no blob for them. §7.6 Tier C — referenced, never retained; nothing here is written to a store.
const lp = !profile && lensProfile ? lensProfile : null
const name = (profile && profile.displayName) || (lp && (lp.displayName || (lp.handle && '@' + lp.handle))) || shortDid(did)
const cid = profile && (profile.avatarCid || blobCid(profile.avatar && profile.avatar.ref))
const av = (cid && isSafeCid(cid))
? `<img class="pfp big" src="${esc(blobUrl(did, cid))}" alt="" width="72" height="72">`
: (lp && lp.avatarUrl && /^https:\/\//i.test(lp.avatarUrl)
? `<img class="pfp big" src="${esc(lp.avatarUrl)}" alt="" width="72" height="72" loading="lazy" referrerpolicy="no-referrer">`
: '<span class="pfp big none" aria-hidden="true"></span>')
// The DID is shown in FULL here — unlike the timeline, this is the page where verifying exactly whose
// account you are looking at is the point, so it is selectable text rather than a hover title.
// The banner sits ABOVE the header row, full-bleed within the column. Absent → nothing renders, not an
// empty grey band: a blank placeholder reads as a broken image rather than as "no banner set".
const bcid = profile && blobCid(profile.banner && profile.banner.ref)
const banner = (bcid && isSafeCid(bcid))
? `<div class="profbanner"><img src="${esc(blobUrl(did, bcid))}" alt="" loading="lazy"></div>`
: ''
const head = `${banner}<header class="profhdr">${av}
<div><div class="profname">${esc(name)}</div>
<div class="profdid"><code>${esc(did)}</code></div>
${profile && profile.description ? `<p class="profbio">${renderText(profile.description)}</p>` : ''}
${lp && lp.description ? `<p class="profbio">${esc(lp.description)}</p>` : ''}
${lp ? `<p class="lensnote">profile via <span class="lensname">${esc(lp.lens)}</span> · this node holds no records for this account${(lp.followersCount != null || lp.postsCount != null) ? `<br><span class="scopenote">${lp.followersCount != null ? lp.followersCount + ' followers' : ''}${(lp.followersCount != null && lp.postsCount != null) ? ' · ' : ''}${lp.postsCount != null ? lp.postsCount + ' posts' : ''} there</span>` : ''}</p>` : ''}
${(graph && (graph.following != null || graph.followers != null)) ? `<p class="profgraph"><a href="${base0(did)}/following"><strong>${graph.following || 0}</strong> following</a> · <a href="${base0(did)}/followers"><strong>${graph.followers || 0}</strong> follower${graph.followers === 1 ? '' : 's'}</a><span class="scopenote"> here</span></p>` : ''}
${netFollowers(graph && graph.network)}
${(graph && graph.canFollow && !graph.isSelf) ? `<form method="POST" action="/api/follow" class="followf"><input type="hidden" name="did" value="${esc(did)}"><input type="hidden" name="return" value="/profile/${encodeURIComponent(did)}"><button class="followbtn${graph.viewerFollows ? ' on' : ''}">${graph.viewerFollows ? 'following ✓' : 'follow'}</button></form>` : ''}
${modControls(did, graph)}</div></header>`
const known = PROFILE_TABS.some(([v]) => v === filter) ? filter : PROFILE_DEFAULT
const base = `/profile/${encodeURIComponent(did)}`
// The active tab rides in the returnTo, so liking from Media comes back to Media, not to Posts.
const hrefFor = (v) => v === PROFILE_DEFAULT ? base : `${base}?filter=${encodeURIComponent(v)}`
const here = hrefFor(known)
const empty = lp
? 'this node holds none of this account\'s posts — it knows them only by reference'
: ((PROFILE_TABS.find(([v]) => v === known) || [])[2] || 'no posts from this author')
const tabs = `<nav class="ptabs">${PROFILE_TABS.map(([v, label]) =>
`<a class="ptab${v === known ? ' on' : ''}" href="${hrefFor(v)}">${esc(label)}</a>`).join('')}</nav>`
return page({
title: `${name} · favela`,
heading: 'favela',
viewer,
active: null,
body: `<div class="viewhdr"><a href="/">← all posts</a></div>${head}${tabs}` +
renderPosts(withReach(withViewer(result && result.posts, viewer, here), apRkeys), lensPosts ? '' : empty) + moreLink(result && result.cursor, here) + renderLensPosts(lensPosts),
})
}
// ── edit ──────────────────────────────────────────────────────────────────────────────────────────
//
// A plain form prefilled from the record, so editing works with scripting off like every other write
// here. Deliberately its own PAGE rather than an inline box: the composer at the top of the timeline
// is for new posts, and reusing it would make "am I writing or rewriting?" a matter of scroll
// position.
//
// ⚠ The reach notice is NOT decoration. atproto allows the edit and bsky's AppView ignores it, so the
// same post reads as edited via getRecord and stale via getPostThread. A user cannot be told "it did
// not sync", because partly it did — so we say exactly what is true per surface
// (spec/record-vocabulary.md §10.5.4).
export function editPage ({ post = null, viewer = null, returnTo = '/', error = null } = {}) {
if (!post) {
return page({ title: 'edit · favela', viewer, active: null,
body: `<div class="viewhdr"><a href="${esc(returnTo)}">← back</a></div>` + emptyState('post not found', 'It may not have reached this node yet.') })
}
const rec = post.record || {}
const uri = post.uri || ''
const media = (rec.embed && (rec.embed.images || (rec.embed.media && rec.embed.media.images))) ? true : false
return page({
title: 'edit post · favela',
heading: 'favela',
viewer,
active: null,
body: `<div class="viewhdr"><a href="${esc(returnTo)}">← back</a></div>
<h2 class="edith">Edit post</h2>
${error ? `<p class="editerr" role="alert">${esc(error)}</p>` : ''}
<form class="composer editform" method="POST" action="/api/edit">
<input type="hidden" name="uri" value="${esc(uri)}">
<input type="hidden" name="return" value="${esc(returnTo)}">
<textarea name="text" rows="5" maxlength="3000" aria-label="post text">${esc(rec.text || '')}</textarea>
<p class="editnote">
Editing the text only. The original time, thread and any attachments are kept.
${media ? '<br>Images are preserved and cannot be changed here.' : ''}
</p>
<p class="editreach">
<strong>Where this edit lands:</strong>
<span class="badge reach">favela</span> updated ·
<span class="badge reach">fediverse</span> updated ·
<span class="badge reach weak">bsky</span> <em>the record updates, but bsky's AppView ignores
post edits — the old text keeps showing in threads there.</em>
</p>
<p><button type="submit">save changes</button>
<a class="editcancel" href="${esc(returnTo)}">cancel</a></p>
</form>`
})
}
// ── follower / following list ─────────────────────────────────────────────────────────────────────
//
// The profile shows counts and an inline run of names, which stops being usable past a handful and gives
// no way to page. This is the dedicated list.
//
// ★ `source` is why this page is shaped the way it is. Follows arrive from more than one place and the
// difference is NOT cosmetic: a follow we hold as a signed `app.bsky.graph.follow` record is a different
// epistemic thing from one a lens reported, and — once ap-bridge's follower state is readable — from a
// fediverse follow whose only proof is an HTTP Signature verified at receipt. Same list, labelled
// sources, never silently merged into one number. This mirrors the profile's existing choice to render
// local and network-wide counts as SEPARATE lines rather than summing them.
//
// Accounts: [{ did, displayName, handle, verified, source }]. `source` is a short label or null for ours.
export function followListPage ({ did, kind = 'followers', accounts = [], viewer = null, subjectName = null, cursor = null } = {}) {
const who = subjectName || shortDid(did)
const base = `/profile/${encodeURIComponent(did)}`
const isFollowers = kind !== 'following'
const tab = (k, label) => `<a href="${base}/${k}"${(isFollowers ? 'followers' : 'following') === k ? ' aria-current="page"' : ''}>${esc(label)}</a>`
const rows = accounts.map((a) => {
const label = a.displayName || (a.verified && a.handle ? '@' + a.handle : shortDid(a.did))
const sub = (a.verified && a.handle && a.displayName) ? '@' + a.handle : (a.did || a.url || '')
// The source badge is rendered ONLY when it is not our own signed record — an unlabelled row means
// "we hold the record", and that default must stay silent or the label stops carrying information.
const src = a.source ? `<span class="fo-src" title="where this follow is known from">${esc(a.source)}</span>` : ''
// A row we hold a DID for links to OUR profile page. One we do not — a fediverse follower — links
// OUT to their own server, marked external. Sending them to /profile/<nothing> would render an
// empty stranger, and pretending we have a local page for an account we hold nothing of is the
// same lie the lens rules already forbid (§7.6 Tier C: referenced, never retained).
const href = a.did
? `/profile/${encodeURIComponent(a.did)}`
: (typeof a.url === 'string' && /^https:\/\//i.test(a.url) ? a.url : null)
const rel = a.did ? '' : ' rel="noopener nofollow external" target="_blank"'
const inner = `<span class="fo-name">${esc(label)}</span><span class="fo-sub">${esc(sub)}</span>`
const id = href ? `<a class="fo-id" href="${esc(href)}"${rel}>${inner}</a>` : `<span class="fo-id">${inner}</span>`
return `<li>${id}${src}</li>`
}).join('')
const body = accounts.length
? `<ul class="folist">${rows}</ul>` + (cursor ? `<a class="more" href="${base}/${isFollowers ? 'followers' : 'following'}?cursor=${encodeURIComponent(cursor)}" rel="next">more →</a>` : '')
: emptyState(
isFollowers ? 'no followers here yet' : 'not following anyone yet',
isFollowers
? 'This node only knows follows it has indexed — someone may follow this account elsewhere without it showing here.'
: '')
return page({
title: `${who} · ${isFollowers ? 'followers' : 'following'} · favela`,
heading: 'favela',
viewer,
active: null,
body: `<div class="viewhdr"><a href="${base}">← ${esc(who)}</a></div>
<nav class="subtabs">${tab('followers', 'Followers')}${tab('following', 'Following')}</nav>${body}`
})
}
// A single post on its own page — the target of every permalink. Also the natural home for a thread view
// when replies land; today it renders the one post plus its at:// URI, which is the identifier anything
// else in the atproto world needs to address it.
// A remote AppView's replies, rendered as a NAMED, SEPARATE source.
//
// Deliberately not our post component. Two reasons, both about not lying:
// - No like/reply buttons. Acting on a remote post is not wired through this node, so an affordance
// that silently does nothing is worse than its absence.
// - Plain escaped text, not renderText. Our rich-text pass resolves facets against records we hold;
// remote text is untrusted input from another service and gets escaped, full stop.
// Counts are shown per-post as the lens reported them and are never added to ours — bsky's numbers are
// network-wide, ours are window-local, and a sum of the two would mean nothing.
// One remote post row, shared by the reply and ancestor sections.
function lensRow (r) {
const who = r.displayName || (r.handle ? '@' + r.handle : shortDid(r.did || ''))
const when = r.createdAt ? `<time datetime="${esc(r.createdAt)}">${esc(relTime(r.createdAt))}</time>` : ''
return `<article class="lpost">
<div class="lwho">${esc(who)}${r.handle && r.displayName ? ` <span class="lhandle">@${esc(r.handle)}</span>` : ''}</div>
<div class="ltext">${esc(r.text)}</div>
<div class="meta">${when}${r.replyCount ? ` · ${r.replyCount} repl${r.replyCount === 1 ? 'y' : 'ies'} there` : ''}</div>
</article>`
}
// The ancestors our own walk could not reach, rendered IN THE GAP where it stopped. Same attribution
// rule as replies: a named foreign source, no affordances, escaped text. The point is that the reader
// sees the conversation actually begins earlier and where it came from — not that our chain got longer.
function renderLensAncestors (groups) {
if (!groups || !groups.length) return ''
return groups.filter((g) => g.posts && g.posts.length).map((g) => `<section class="lens lensup">
<h3>earlier in this thread, via <span class="lensname">${esc(g.lens)}</span> · not indexed by this node</h3>
${g.posts.map(lensRow).join('')}
</section>`).join('')
}
function renderLensReplies (groups) {
if (!groups || !groups.length) return ''
return groups.map((g) => `<section class="lens">
<h3>${g.replies.length} repl${g.replies.length === 1 ? 'y' : 'ies'} via <span class="lensname">${esc(g.lens)}</span> · not indexed by this node</h3>
${g.replies.map(lensRow).join('')}
</section>`).join('')
}
export function postPage ({ post, viewer = null, replies = [], canWrite = false, ancestors = [], lensReplies = [], lensAncestors = [], lensesOn = false, gateNote = '', viewerCanReply = true, quoteNote = '', viewerCanQuote = true } = {}) {
if (!post) {
return page({ title: 'post not found · favela', viewer, active: null, body: '<div class="viewhdr">Post <a href="/">← all posts</a></div><p class="empty">no such post — it may not have reached this node yet.</p>' })
}
const who = (post.author && post.author.displayName) || shortDid(post.did)
const backTo = postHref(post)
// The ancestor chain, oldest first, above the focused post. A `missing` marker is an HONEST gap: the
// parent exists somewhere in atproto but not in this node's window, and saying so beats rendering a
// thread that appears to begin mid-conversation.
// A lens fills the gap IN PLACE, where our own walk stopped — so the conversation reads in order
// rather than having its beginning tacked on somewhere else. If no lens answered, the honest marker
// stands. `filled` is what any lens actually returned for the gap.
const filled = (lensAncestors || []).filter((g) => g.posts && g.posts.length)
const above = (ancestors || []).map((a) => a && a.missing
? (filled.length
? renderLensAncestors(filled)
: `<p class="empty missing">⤒ earlier replies are outside this node's window<br><code>${esc(a.uri || '')}</code></p>`)
: (a ? `<div class="tnode ancestor">${renderPost(withViewer([a], viewer, backTo)[0])}</div>` : '')).join('')
const replyTotal = countThread(replies)
// This post IS this page — see the `stretch` note in renderPost. Its own whole-entry link would point
// here, and would sit over the text the reader came for.
const focused = withViewer([post], viewer, backTo)[0]
focused.focused = true
focused.quotable = viewerCanQuote
return page({
title: `${who} · favela`,
viewer,
active: null,
body: `<div class="viewhdr">Thread <a href="/">← all posts</a></div>${above}<div class="focus">${renderPost(focused)}</div>
${gateNote ? `<p class="gatenote">🔒 ${esc(gateNote)}</p>` : ''}
${quoteNote ? `<p class="gatenote">❝ ${esc(quoteNote)}</p>` : ''}
${(viewer && canWrite && viewerCanReply) ? `<form class="replyf" method="POST" action="/api/post">
<input type="hidden" name="replyTo" value="${esc(post.uri || uriOf(post))}">
<textarea name="text" rows="2" placeholder="reply…" required></textarea>
<p><span id="reply-cc" class="cc"></span><button>reply</button></p>
</form>` : (viewer && canWrite && !viewerCanReply
// The box is GONE and the reason is stated. Silently removing it looks like a broken page, and a
// threadgate is the author's choice — worth naming rather than hiding.
? `<p class="empty gateoff">The author limited who can reply to this thread, and this account is not included.</p>`
: '')}
${replyTotal ? `<div class="thread"><h3>${replyTotal} repl${replyTotal === 1 ? 'y' : 'ies'}</h3>${renderThread(replies, viewer, backTo)}</div>` : ''}
${renderLensReplies(lensReplies)}
${lensesOn ? '' : `<p class="empty lensoff">This node reads no remote AppView, so replies from outside its own window are not shown here. A thread with unseen replies looks identical to one with none — that is why this line exists.</p>`}
<p class="empty aturi"><code>${esc(uriOf(post))}</code></p>`
})
}
// The search page. TWO SOURCES, never merged.
//
// What this node holds is rendered as our own posts, with the ordinary affordances. What bsky returns is
// rendered as a NAMED foreign source with escaped text and no like/reply controls — the same rule the
// thread view's lens replies follow, and for the same reason: acting on a post we do not hold would be a
// lie, and blending remote hits into ours would make a partial index look total.
export function searchPage ({ q = '', local = null, actors = [], lens = null, viewer = null, canWrite = false } = {}) {
const query = String(q || '')
const box = `<form class="searchbox" method="GET" action="/search" role="search"><input type="search" name="q" value="${esc(query)}" placeholder="search posts and people" aria-label="search" autofocus><button>search</button></form>`
if (!query.trim()) {
return page({ title: 'search · favela', viewer, active: 'search', body: `<div class="viewhdr">Search</div>${box}<p class="empty">Search this node\'s posts and people — and bsky, shown separately.</p>` })
}
const people = actors.length
? `<h3 class="stath">people here</h3>` + actors.map((a) => {
const name = (a.profile && a.profile.displayName) || shortDid(a.did)
const cid = a.profile && blobCid(a.profile.avatar && a.profile.avatar.ref)
const av = (cid && isSafeCid(cid))
? `<img class="pfp" src="${esc(blobUrl(a.did, cid))}" alt="" loading="lazy" width="36" height="36">`
: '<span class="pfp none" aria-hidden="true"></span>'
return `<a class="byline actorrow" href="/profile/${encodeURIComponent(a.did)}" title="${esc(a.did)}">${av}<span class="name">${esc(name)}</span></a>`
}).join('')
: ''
const posts = (local && local.posts && local.posts.length)
? `<h3 class="stath">posts here</h3>` + renderPosts(withViewer(local.posts, viewer, `/search?q=${encodeURIComponent(query)}`))
: ''
// A scan cap is a COST ceiling, and hitting it means the answer is incomplete. Saying so beats
// returning a short list that looks complete.
const truncated = local && local.truncated
? '<p class="empty">…this node\'s index was only scanned to a bounded depth, so older matches may be missing.</p>'
: ''
const nothingLocal = !people && !posts ? '<p class="empty">nothing on this node matches</p>' : ''
const remote = lens && lens.posts && lens.posts.length
? `<section class="lens"><h3>${lens.posts.length} post${lens.posts.length === 1 ? '' : 's'} via <span class="lensname">${esc(lens.name)}</span> · not indexed by this node</h3>${lens.posts.map(lensRow).join('')}</section>`
: ''
const remoteActors = lens && lens.actors && lens.actors.length
? `<section class="lens"><h3>${lens.actors.length} account${lens.actors.length === 1 ? '' : 's'} via <span class="lensname">${esc(lens.name)}</span></h3>${lens.actors.map((a) => `<article class="lpost"><div class="lwho">${esc(a.displayName || a.handle || a.did)}${a.handle ? ` <span class="lhandle">@${esc(a.handle)}</span>` : ''}</div>${a.description ? `<div class="ltext">${esc(String(a.description).slice(0, 160))}</div>` : ''}<div class="meta"><a href="/profile/${encodeURIComponent(a.did)}">open here</a></div></article>`).join('')}</section>`
: ''
const lensOff = !lens ? '<p class="empty lensoff">This node reads no remote AppView, so results are limited to what it holds.</p>' : ''
return page({
title: `${query} · search · favela`,
viewer,
active: 'search',
canWrite,
body: `<div class="viewhdr">Search</div>${box}${people}${posts}${truncated}${nothingLocal}${remoteActors}${remote}${lensOff}`
})
}
// The bookmarks page. Private by construction — there is no shared state to render, so this is simply
// the viewer's own saved posts, newest-SAVED first (see lib/bookmarks.js on why not newest-posted).
export function bookmarksPage ({ result = null, viewer = null, missing = 0 } = {}) {
if (!viewer) {
return page({ title: 'bookmarks · favela', viewer, active: 'bookmarks', body: '<div class="viewhdr">Bookmarks</div><p class="empty">Sign in to save posts. <a href="/login">sign in →</a></p>' })
}
const posts = withViewer((result && result.posts) || [], viewer, '/bookmarks')
// A bookmark can outlive the post it points at — deleted by its author, or simply not held here any
// more. Saying how many could not be loaded beats silently returning a shorter list.
const gone = missing
? `<p class="empty">${missing} bookmarked post${missing === 1 ? '' : 's'} could not be loaded — deleted, or outside this node's window.</p>`
: ''
return page({
title: 'bookmarks · favela',
viewer,
active: 'bookmarks',
body: `<div class="viewhdr">Bookmarks <span class="scopenote">· private to you, stored only on this node</span></div>` +
renderPosts(posts, 'nothing saved yet — tap ☆ on a post') + gone
})
}
// Notifications. The server renders ONLY this shell — every authenticated byte moves between the
// browser and the PDS (web/static/notifications.js explains why). favela-web holds no credential, which
// is the property that would be lost by doing this server-side.
//
// This is the ONE page that requires JavaScript, and it says so rather than appearing broken with
// scripting off. Notifications are inherently authenticated and per-viewer; there is no meaningful
// server-rendered version when the server deliberately holds nothing.
// One notification line. Kind decides the verb, network decides the badge — a reply from Mastodon
// and a reply from bsky are not interchangeable, and a UI that renders them identically is lying
// about where the conversation is happening.
function notifRow (n) {
const verb = n.kind === 'reply' ? 'replied' : n.kind === 'like' ? 'liked' : n.kind === 'boost' ? 'boosted' : 'mentioned you'
const net = n.network === 'activitypub' ? 'fediverse' : 'bsky'
// n.text is already stripped of remote markup by notifications.js; esc() is the second line of
// defence, not the first.
const said = n.text ? `<div class="ntext">${esc(n.text.slice(0, 280))}</div>` : ''
const when = n.when ? `<time datetime="${esc(n.when)}">${esc(relTime(n.when))}</time>` : '<span class="dim">unknown time</span>'
const link = n.url ? `<a href="${esc(n.url)}" rel="noopener">open</a>` : ''
return `<div class="notif"><div class="nhead"><strong>${esc(n.who || 'someone')}</strong> ${verb} <span class="netbadge">${net}</span> · ${when} ${link}</div>${said}</div>`
}
// ⛔ THIS PAGE USED TO ASK FOR A SECOND CREDENTIAL. It rendered a shell and a form for a bsky APP
// PASSWORD, then fetched bsky's notification API in the browser — for an account this node already
// controls, showing bsky's view rather than ours, and blind to ActivityPub entirely (#45: "the 'did
// anyone reply to me' loop does not work").
//
// Now the node answers from what it already knows: the inbound lens folds replies that touch our
// content into the appview, and ap-bridge keeps what the fediverse sends. No credential, works with
// JavaScript off.
//
// ⚠ `apAvailable === false` is NOT "no fediverse replies" — it is "this node cannot see them",
// because ap-bridge is single-instance and may live on another host. Saying so is the difference
// between an empty page that is true and one that is a lie.
export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net', items = [], apAvailable = null } = {}) {
const list = items.length
? items.map(notifRow).join('')
: '<p class="empty">Nothing yet — replies, likes and boosts that touch your posts appear here.</p>'
const apNote = apAvailable === false
? '<p class="scopenote">⚠ The ActivityPub side is not readable from this node, so fediverse replies are not shown here. They are not missing — this node cannot see them.</p>'
: ''
const body = `<div class="viewhdr">Notifications <span class="scopenote">· from this node, no credential</span></div>
${apNote}
<div class="notiflist">${list}</div>
<details class="bskybox"><summary>also fetch bsky's own notifications in this browser</summary>
<noscript><p class="empty">Notifications need JavaScript. Everything else in favela works without it — this page is the exception, because the server holds no credential of yours and so cannot fetch them for you.</p></noscript>
<form id="notif-login" class="authbox" hidden>
<p class="empty">Sign in with an <strong>app password</strong> — not your account password. It is used once to mint a session and is never stored; only the short-lived tokens are kept, in this browser.</p>
<p><label for="notif-handle">handle</label><br><input id="notif-handle" type="text" autocomplete="username" placeholder="you.example.com" value="${esc((viewer && viewer.handle) || '')}"></p>
<p><label for="notif-pass">app password</label><br><input id="notif-pass" type="password" autocomplete="current-password" placeholder="xxxx-xxxx-xxxx-xxxx"></p>
<p><button type="submit">sign in</button></p>
</form>
<p id="notif-status" aria-live="polite" class="empty"></p>
<div id="notif-list"></div>
<p><button id="notif-signout" class="devbtn">forget this browser\'s notification session</button></p>
</details>`
return page({
title: 'notifications · favela',
viewer,
active: 'notifications',
body,
clientScript: `<meta name="favela-pds" content="${esc(pds)}">
<style>
.notif{display:flex;gap:.6rem;align-items:flex-start;padding:.7rem 0;border-bottom:1px solid var(--line,#e5e5e5)}
.notif.unread{background:var(--bg-soft,#f7f8fa);border-radius:var(--r1,.375rem);padding-left:.5rem;padding-right:.5rem}
.ntext{min-width:0;flex:1}
.nquote{color:var(--fg-dim,#5c6672);font-size:.9rem;margin-top:.2rem;white-space:pre-wrap;overflow-wrap:anywhere}
#notif-login input{width:100%;box-sizing:border-box;font:inherit;padding:.4rem;border:1px solid var(--line,#ccc);border-radius:.3rem}
#notif-login button{font:inherit;padding:.4rem 1rem;border:none;border-radius:999px;background:var(--accent,#1a56db);color:#fff;font-weight:600;cursor:pointer}
#notif-status.err{color:#c92a2a}#notif-status.ok{color:var(--fg-dim,#5c6672)}
</style>
<script type="module" src="/static/notifications.js"></script>`
})
}
// Your lists. A list is TWO record types (see lib/lists.js) but one idea, so the page shows the list and
// its size, and the members live on their own page.
//
// PURPOSE IS SHOWN, always. A modlist mutes or blocks everyone on it for anyone who subscribes; rendering
// it identically to a curation list hands someone a footgun.
export function listsPage ({ viewer = null, lists = [], canWrite = false, notFound = null } = {}) {
if (!viewer) {
return page({ title: 'lists · favela', viewer, active: 'lists', body: '<div class="viewhdr">Lists</div><p class="empty">Sign in to make lists. <a href="/login">sign in →</a></p>' })
}
const rows = lists.length
? lists.map((l) => {
const mod = l.record && l.record.purpose === 'app.bsky.graph.defs#modlist'
return `<a class="listrow" href="/lists/${encodeURIComponent(l.rkey)}">
<span class="listname">${esc((l.record && l.record.name) || l.rkey)}</span>
<span class="listkind${mod ? ' mod' : ''}">${mod ? 'moderation list' : 'curation list'}</span>
${l.record && l.record.description ? `<span class="listdesc">${esc(l.record.description)}</span>` : ''}
</a>`
}).join('')
: '<p class="empty">no lists yet</p>'
const form = canWrite
? `<form class="authbox" method="POST" action="/api/list">
<h3>New list</h3>
<p><label for="list-name">name</label><br><input id="list-name" name="name" type="text" maxlength="64" required placeholder="people who post about boats"></p>
<p><label for="list-desc">description</label><br><input id="list-desc" name="description" type="text" maxlength="300"></p>
<p><label for="list-purpose">kind</label><br><select id="list-purpose" name="purpose">
<option value="app.bsky.graph.defs#curatelist">curation list — just a group of people</option>
<option value="app.bsky.graph.defs#modlist">moderation list — subscribers MUTE or BLOCK everyone on it</option>
</select></p>
<p><button type="submit">create list</button></p>
</form>`
: '<p class="empty">this node does not sign for you, so it cannot create lists</p>'
const missing = notFound ? `<p class="empty">no list with that id</p>` : ''
return page({ title: 'lists · favela', viewer, active: 'lists', body: `<div class="viewhdr">Lists</div>${missing}${rows}${form}` })
}
// One list and its members. Removing someone deletes THEIR listitem — the membership record is the thing
// that exists, so the control is per-member rather than an edit of the list.
export function listPage ({ viewer = null, list = null, members = [] } = {}) {
if (!list) return page({ title: 'list · favela', viewer, active: 'lists', body: '<div class="viewhdr">List <a href="/lists">← lists</a></div><p class="empty">no such list</p>' })
const rec = list.record || {}
const mod = rec.purpose === 'app.bsky.graph.defs#modlist'
const rows = members.length
? members.map((m) => `<div class="listrow">
<a class="byline" href="/profile/${encodeURIComponent(m.subject)}" title="${esc(m.subject)}"><span class="name">${esc(m.displayName || shortDid(m.subject))}</span></a>
<form class="modf" method="POST" action="/api/list-member">
<input type="hidden" name="list" value="${esc(list.uri)}">
<input type="hidden" name="subject" value="${esc(m.subject)}">
<input type="hidden" name="return" value="/lists/${encodeURIComponent(list.rkey)}">
<button class="mutebtn">remove</button>
</form>
</div>`).join('')
: '<p class="empty">nobody on this list yet — add someone from their profile</p>'
return page({
title: `${(rec.name || 'list')} · favela`,
viewer,
active: 'lists',
body: `<div class="viewhdr">${esc(rec.name || list.rkey)} <a href="/lists">← lists</a></div>
<p class="lensnote">${mod ? 'moderation list — subscribing to this mutes or blocks everyone on it' : 'curation list'}${rec.description ? ' · ' + esc(rec.description) : ''}</p>
<p class="empty aturi"><code>${esc(list.uri)}</code></p>
${rows}`
})
}
// /status — the operator page. Everything here was previously either a boot-log line, a hand-built curl,
// or nothing at all. The ordering is deliberate: the ingest table first, because a frozen front is the
// failure that looks healthy from every other angle.
export function statusPage ({ fronts = null, lens = null, host = null, viewer = null } = {}) {
const yn = (b) => b ? '<span class="ok">yes</span>' : '<span class="bad">no</span>'
const didRows = ((fronts && fronts.dids) || []).map((d) => {
// The alarm: chosen front is not the highest-rev line, or the line we are on is shorter than one we
// can see. Either is the freeze signature.
const alarm = d.frontCount > 1 && (d.onHighestRev === false || (d.behindBy || 0) > 0)
const rows = (d.fronts || []).map((f) => `<tr class="${f.chosen ? 'chosen' : ''}">
<td><code>${esc(f.short || '')}</code>${f.chosen ? ' ←' : ''}</td>
<td>${esc(String(f.rev || '—'))}</td>
<td>${f.firehoseLength == null ? '—' : f.firehoseLength}</td>
<td>${yn(f.live)}</td><td>${yn(f.verified)}</td></tr>`).join('')
return `<div class="statblk${alarm ? ' alarm' : ''}">
<div class="statdid"><code>${esc(d.did)}</code></div>
<div class="statsum">${d.frontCount} front${d.frontCount === 1 ? '' : 's'} ·
following <code>${esc(d.chosen || 'none')}</code> ·
highest-rev: ${yn(d.onHighestRev)}${d.behindBy ? ` · <strong>behind by ${d.behindBy} frames</strong>` : ''}</div>
${d.frontCount ? `<table class="stattbl"><tr><th>front</th><th>rev</th><th>firehose</th><th>live</th><th>verified</th></tr>${rows}</table>` : '<p class="empty">no fronts known</p>'}
${alarm ? '<p class="alarmnote">⚠ following a line that is not the freshest — this is the shape of a frozen index.</p>' : ''}
</div>`
}).join('')
const s = lens && lens.stats
const lensBlk = `<div class="statblk">
<div class="statsum">lens import: ${lens && lens.enabled ? '<span class="ok">on</span>' : '<span class="bad">off</span>'}${(lens && lens.lenses && lens.lenses.length) ? ' · ' + lens.lenses.map((l) => `<code>${esc(l.name)}</code> → ${esc(l.base)}`).join(', ') : ''}</div>
${s ? `<table class="stattbl"><tr><th>hits</th><th>misses</th><th>fetches</th><th>failures</th><th>timeouts</th><th>coalesced</th><th>short-circuited</th><th>cache</th><th>breakers open</th></tr>
<tr><td>${s.hits}</td><td>${s.misses}</td><td>${s.fetches}</td><td>${s.failures}</td><td>${s.timeouts}</td><td>${s.coalesced}</td><td>${s.shortCircuited}</td><td>${s.cacheSize}</td><td>${(s.open || []).length ? '<span class="bad">' + esc((s.open || []).join(', ')) + '</span>' : '0'}</td></tr></table>` : ''}
${(lens && lens.errors && lens.errors.length) ? `<p class="alarmnote">⚠ ${lens.errors.map(esc).join('<br>')}</p>` : ''}
</div>`
return page({
title: 'status · favela',
heading: 'favela',
viewer,
active: null,
body: `<div class="viewhdr">Status${host ? ` · <code>${esc(host)}</code>` : ''} <a href="/">← all posts</a></div>
<h3 class="stath">ingest — which co-owner's firehose each DID follows</h3>${didRows || '<p class="empty">no DIDs</p>'}
<h3 class="stath">lens import</h3>${lensBlk}
<p class="empty">raw: <a href="/api/fronts">/api/fronts</a> · <a href="/api/lens">/api/lens</a></p>`
})
}
export function tagPage ({ tag, result, viewer = null } = {}) {
return page({
title: `#${tag} · favela`,
heading: 'favela',
viewer,
active: null,
body: `<div class="viewhdr">#${esc(tag)} <a href="/">← all posts</a></div>` + renderPosts(withViewer(result && result.posts, viewer, `/tag/${encodeURIComponent(tag)}`), `no posts for #${esc(tag)}`) + moreLink(result && result.cursor, `/tag/${encodeURIComponent(tag)}`),
})
}
// The DID sign-in page. Server-renders the scaffolding; the client script (/static/login.js) generates
// the browser's secp256k1 sub-key, drives pairing (paste the hpp-minted delegation) + the challenge→
// sign→session flow. favela is KEYLESS — the sub-key secret never leaves the browser and no key of the
// viewer's is ever held server-side. Model: the DID is the anchor, each browser sub-key is a delegated
// device (the atproto analog of pgv3's root ← device chain).
export function loginPage ({ viewer = null } = {}) {
const body = `<div class="viewhdr">Sign in to favela <a href="/">← back</a></div>
<p class="empty">favela never holds your key. Your browser signs a challenge with a secp256k1 <em>sub-key</em> that your DID's <code>#atproto</code> key has delegated — the atproto-native sign-in (the pgv3 method). No key of yours is stored server-side.</p>
<p id="status" aria-live="polite"></p>
<section id="session-box" class="authbox" style="display:none">
<p>Signed in as <strong id="session-who"></strong>.</p>
<p><button id="logout-btn" type="button">sign out</button> <a href="/">go to timeline →</a></p>
</section>
<section id="signin-box" class="authbox" style="display:none">
<p>This browser is paired to <strong id="paired-did"></strong>.</p>
<p><button id="signin-btn" type="button">sign in</button> <button id="unpair-btn" type="button" class="ghost">unpair this browser</button></p>
</section>
<section id="pair-box" class="authbox" style="display:none">
<h3>Pair this browser to your DID</h3>
<ol class="pair-steps">
<li>On the machine that holds your DID (your <strong>hpp</strong>), run <code>favela-pair</code>. It prints a one-time <strong>pairing invite</strong> (a hash URL).</li>
<li>Paste the invite here. favela opens a peer-to-peer tunnel to your hpp, which delegates this browser's sub-key — favela never sees your key.</li>
</ol>
<form id="pair-form">
<p><textarea id="pair-invite" rows="2" placeholder="pairing invite from favela-pair (a long hex string, or the …/login#pair=… link)" spellcheck="false" required></textarea></p>
<p><button type="submit">pair & sign in</button></p>
</form>
<p class="empty" style="margin-top:.5rem">This browser's sub-key: <code id="subkey" class="subkey"></code> <button id="copy-subkey" type="button" class="ghost">copy</button> <span style="opacity:.6">(the hpp delegates this over the tunnel — you don't need to copy it)</span></p>
</section>`
return page({
title: 'sign in · favela',
viewer,
active: null,
body,
clientScript: `<style>
.authbox{border:1px solid #e5e5e5;border-radius:.5rem;padding:1rem;margin:1rem 0}
.authbox h3{margin:.2rem 0 .6rem}
.pair-steps{padding-left:1.2rem}.pair-steps li{margin:.5rem 0}
.subkey{word-break:break-all;font-size:.8rem;background:#f3f4f6;padding:.15rem .35rem;border-radius:.3rem}
.authbox input,.authbox textarea{width:100%;box-sizing:border-box;font:inherit;padding:.4rem;border:1px solid #ccc;border-radius:.3rem}
.authbox button{font:inherit;padding:.35rem .8rem;cursor:pointer}
.authbox button.ghost{background:none;border:1px solid #ccc;border-radius:.3rem}
#status.ok{color:#15803d}#status.err{color:#b91c1c}
@media(prefers-color-scheme:dark){.authbox{border-color:#333}.subkey{background:#1f2937}.authbox input,.authbox textarea,.authbox button.ghost{background:#222;color:#eee;border-color:#444}#status.ok{color:#4ade80}}
</style>
<script type="module" src="/static/login.js"></script>`
})
}
// ── Atom feed (RSS-family; free off the same timeline view-model) ──────────────
export function renderAtom ({ posts = [], selfUrl = 'http://localhost/feed.xml', title = 'favela — native P2P timeline', updated } = {}) {
const upd = updated || (posts[0] && posts[0].createdAt) || '1970-01-01T00:00:00.000Z'
const entries = posts.map((p) => {
const uri = uriOf(p)
const text = (p.record && p.record.text) || ''
return `<entry>
<id>${esc(uri)}</id>
<title>${esc(text.slice(0, 80) || '(post)')}</title>
<updated>${esc(p.createdAt)}</updated>
<author><name>${esc(p.did)}</name></author>
<content type="text">${esc(text)}</content>
</entry>`
}).join('\n')
return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${esc(title)}</title>
<link rel="self" href="${esc(selfUrl)}"/>
<id>${esc(selfUrl)}</id>
<updated>${esc(upd)}</updated>
${entries}
</feed>`
}