OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / render.test.mjs

1002 lines · javascript ref:
// favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs
import { pairSection, lensControl, withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js'
import { SCOPE_COLLECTION } from '../lib/scopes.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }
const has = (s, sub, m) => ok(String(s).includes(sub), m + ` (expected to contain ${JSON.stringify(sub)})`)
const not = (s, sub, m) => ok(!String(s).includes(sub), m)

const post = (over = {}) => ({ did: 'did:plc:alice', rkey: 'r1', createdAt: '2026-07-14T00:00:00.000Z', record: { $type: 'app.bsky.feed.post', text: 'hello #World everyone' }, ...over })

// escaping + injection safety
has(esc('<script>&"\''), '&lt;script&gt;', 'esc encodes < >')
not(esc('<script>'), '<script>', 'esc removes raw tag')

// hashtag linkify → real /tag/ links (no JS)
const t = renderText('a #Foo b #bar')
has(t, '<a class="tag" href="/tag/foo">#Foo</a>', 'hashtag → lowercased /tag/ link, original case in label')
has(t, '/tag/bar', 'second hashtag linked')

// XSS in post text is escaped, not executed
has(renderPost(post({ record: { $type: 'app.bsky.feed.post', text: '<img src=x onerror=alert(1)>' } })), '&lt;img', 'post text is escaped')

// a post renders as an <article>, did → /profile link
const rp = renderPost(post())
has(rp, '<article class="post">', 'normal post is an <article>')
has(rp, '/profile/did%3Aplc%3Aalice', 'did → /profile/<did> link (encoded)')

// local-only scope badge
has(renderPost(post({ record: { $type: SCOPE_COLLECTION.local, text: 'x' } })), 'local-only', 'local scope shows the badge')

// moderation collapse uses a native <details> (no-JS revealable), NOT hidden-by-JS
const warned = renderPost(post({ treatment: 'warn', labels: ['nsfw'] }))
has(warned, '<details class="post collapsed">', 'warn → native <details> (no-JS reveal)')
has(warned, '<summary>', 'collapsed post has a <summary>')
has(warned, 'hello', 'the post body is still in the DOM under <details> (revealable)')
has(renderPost(post({ muted: true })), '🔇 muted account', 'muted → muted summary')

// blobCid normalizes every ref form → cid string (wj62 records store {$link})
ok(blobCid({ $link: 'bafkreicj4dlhuojeiduwbyzoyuu5a4mcpalcj4v4krcyutcc4w7dednmg4' }) === 'bafkreicj4dlhuojeiduwbyzoyuu5a4mcpalcj4v4krcyutcc4w7dednmg4', 'blobCid reads {$link}')
ok(blobCid({ '/': 'bafcidxyz' }) === 'bafcidxyz', 'blobCid reads dag-json {/}')
ok(blobCid('bafcidstr') === 'bafcidstr', 'blobCid passes a bare string through')
ok(blobCid(null) === null && blobCid({}) === null, 'blobCid → null for empty/unknown')

// image embeds render as <img> pointing at getBlob (the shared overlay bytes) — the real wj62 shape
const CID = 'bafkreicj4dlhuojeiduwbyzoyuu5a4mcpalcj4v4krcyutcc4w7dednmg4'
const imgPost = post({ record: { $type: 'app.bsky.feed.post', text: 'pic', embed: { $type: 'app.bsky.embed.images', images: [{ alt: 'a cat', image: { $type: 'blob', ref: { $link: CID }, mimeType: 'image/jpeg' } }] } } })
const rendered = renderPost(imgPost)
has(rendered, `/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&amp;cid=${CID}`, 'image embed → getBlob URL with did+cid (& escaped for HTML)')
has(rendered, 'alt="a cat"', 'alt text is carried into the <img>')
has(rendered, '<div class="media">', 'images wrapped in .media')
// recordWithMedia (quote + images) also renders its media images
has(renderEmbed(post({ record: { embed: { $type: 'app.bsky.embed.recordWithMedia', media: { $type: 'app.bsky.embed.images', images: [{ alt: '', image: { ref: { $link: CID } } }] } } } })), 'getBlob', 'recordWithMedia media images render')
// a ref with an unsafe/missing cid is never emitted as a src (defense in depth)
not(renderEmbed(post({ record: { embed: { $type: 'app.bsky.embed.images', images: [{ alt: 'x', image: { ref: { $link: '../../etc/passwd' } } }] } } })), 'getBlob', 'path-traversal cid is skipped, no src emitted')
// alt text is escaped (no XSS via alt)
has(renderEmbed(post({ record: { embed: { $type: 'app.bsky.embed.images', images: [{ alt: '"><script>', image: { ref: { $link: CID } } }] } } })), 'alt="&quot;&gt;&lt;script&gt;"', 'alt text is escaped')
// a post with no embed renders no media
ok(renderEmbed(post()) === '', 'no embed → no media block')

// empty state
has(renderPosts([], 'nothing here'), 'nothing here', 'empty feed shows the message')

// whole-page SSR: document + composer gating + RSS link
const tl = timelinePage({ result: { posts: [post()] }, viewer: { did: 'did:plc:alice', handle: 'alice.test' }, canWrite: true })
has(tl, '<!doctype html>', 'timelinePage is a full document')
has(tl, 'application/atom+xml', 'advertises the Atom feed in <head>')
has(tl, 'class="composer"', 'canWrite → composer form present')
has(tl, 'alice.test', 'signed-in viewer shown')
not(timelinePage({ result: { posts: [] }, canWrite: false }), 'class="composer"', 'no-write viewer → no composer')
has(timelinePage({ result: { posts: [] } }), '/login', 'anonymous viewer → sign-in link')

// profile + tag pages
has(profilePage({ did: 'did:plc:alice', result: { posts: [post()] } }), 'did:plc:alice', 'profile page shows the did')
has(tagPage({ tag: 'foo', result: { posts: [post()] } }), '#foo', 'tag page shows the tag')

// Atom feed is well-formed-ish + escapes
const atom = renderAtom({ posts: [post()], selfUrl: 'http://x/feed.xml' })
has(atom, '<?xml version="1.0"', 'atom has an xml decl')
has(atom, '<feed xmlns="http://www.w3.org/2005/Atom">', 'atom feed root')
has(atom, '<entry>', 'atom has an entry')
has(renderAtom({ posts: [post({ record: { text: 'a & b <c>' } })] }), 'a &amp; b &lt;c&gt;', 'atom content is escaped')

// ── settings: device management ──────────────────────────────────────────────────────────────────
// The device list is rendered SERVER-SIDE on purpose: with JS off you can still see which browsers can
// sign in as you, which is a security-relevant fact and should not depend on a script running. Only the
// actions (pair/revoke) are progressive enhancement.
const viewer = { did: 'did:plc:alice', handle: 'alice.test' }
const devs = [
  { subKey: 'aaaabbbbccccddddeeeeffff0000111122223333', label: 'my laptop', addedAt: 1785000000000, revoked: false },
  { subKey: '9999888877776666555544443333222211110000', label: 'old phone', addedAt: 1784000000000, revoked: true }
]
const sp = settingsPage({ viewer, profile: null, did: viewer.did, devices: devs })
has(sp, 'aaaabbbbccccddddeeee', 'device sub-key prefix is shown')
has(sp, 'my laptop', 'device label is shown')
has(sp, 'data-subkey="9999888877776666555544443333222211110000"', 'full sub-key rides the row for the revoke call')

// A revoked device must be VISIBLE, not filtered out — "no devices" and "one revoked device" are
// different facts and the second is the one worth seeing.
has(sp, 'class="dev revoked"', 'a revoked device is shown, marked revoked')
has(sp, '>restore<', 'a revoked device offers restore rather than revoke')
has(sp, '>revoke<', 'an active device offers revoke')

// The mint half of the loop: generating a join code was CLI-only before, which meant shell access on
// the favela-web box just to add a phone.
has(sp, 'id="pair-new"', 'settings offers a generate-join-code control')
has(sp, 'id="pair-code"', 'and a slot for the generated code')
has(sp, 'id="pair-url"', 'and the /login#pair= deep link')

// Labels are attacker-controlled (they come from whoever paired) so they must be escaped.
const evil = settingsPage({ viewer, profile: null, did: viewer.did, devices: [{ subKey: 'ab'.repeat(20), label: '<img src=x onerror=alert(1)>', addedAt: 0, revoked: false }] })
has(evil, '&lt;img', 'a hostile device label is escaped')
not(evil, '<img src=x', 'and never rendered as a tag')

has(settingsPage({ viewer, profile: null, did: viewer.did, devices: [] }), 'no browsers paired yet', 'empty device list says so')

// Signed out: no device list at all — it is gated on a session server-side, not hidden with css.
const anon = settingsPage({ viewer: null })
not(anon, 'id="pair-new"', 'signed out, no pairing control is rendered')
not(anon, 'class="devlist"', 'signed out, no device list is rendered')

// ── UX pass: byline, relative time, permalink, rich text, profile header ─────────────────────────
// A raw did:plc: on every post made a timeline unreadable — fifty opaque 32-char strings, authors
// distinguishable only by squinting at hex. The data was always on disk; it was never joined on.

ok(shortDid('did:plc:wj62ebbusln6gxo5azo6ogzo') === 'wj62ebbu…ogzo', 'a did shortens to a scannable form')
ok(shortDid('did:plc:short') === 'short', 'a short id is left alone')

// Relative time. The exact value must survive in datetime/title — shortening the display must not
// destroy the data.
const now = Date.parse('2026-07-20T12:00:00.000Z')
ok(relTime('2026-07-20T11:59:30.000Z', now) === '30s', 'seconds')
ok(relTime('2026-07-20T10:00:00.000Z', now) === '2h', 'hours')
ok(relTime('2026-07-17T12:00:00.000Z', now) === '3d', 'days')
ok(relTime('not-a-date', now) === 'not-a-date', 'an unparseable date falls back to the raw string')

const withAuthor = post({ author: { did: 'did:plc:alice', displayName: 'Alice', avatar: { ref: { $link: 'bafkreiabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs' } } } })
const ra = renderPost(withAuthor)
has(ra, 'class="byline"', 'post carries a byline')
has(ra, '>Alice<', 'display name is shown instead of the raw did')
has(ra, 'class="pfp"', 'author avatar is rendered from the appview\'s raw blob ref')
has(ra, 'title="did:plc:alice"', 'the did is preserved as a hover, not printed on every row')
not(ra, '>did:plc:alice</a> ·', 'the raw did is no longer the visible author line')

// No author attached (cold cache / pure render) must still produce a usable line.
has(renderPost(post()), 'plc:alice'.slice(4), 'without a resolved profile the short did is shown')
has(renderPost(post()), 'class="pfp none"', 'and a placeholder avatar, not a broken image')

// Permalink — uriOf was computed and thrown away before this, so a post could not be linked or shared.
ok(postHref({ did: 'did:plc:alice', rkey: 'r1' }) === '/post/did%3Aplc%3Aalice/r1', 'permalink is encoded')
has(renderPost(post()), 'href="/post/did%3Aplc%3Aalice/r1"', 'the timestamp links to the post')
has(renderPost(post()), '<time datetime="2026-07-14T00:00:00.000Z"', 'exact timestamp kept in datetime')

// Rich text: facets are AUTHORITATIVE. A regex cannot see a link whose display text differs from its
// target, and re-deriving them disagrees with what the author published.
const facet = renderRichText('see my site now', [{ index: { byteStart: 4, byteEnd: 11 }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://example.com' }] }])
has(facet, '<a class="link" href="https://example.com"', 'a link facet becomes an anchor')
has(facet, '>my site</a>', 'with the AUTHOR\'s display text, not the url')
has(renderRichText('hi @bob', [{ index: { byteStart: 3, byteEnd: 7 }, features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:bob' }] }]), '/profile/did%3Aplc%3Abob', 'a mention facet links to the profile')

// Byte offsets, not string offsets — slicing the JS string mis-cuts past the first non-ASCII char.
const uni = renderRichText('héllo link', [{ index: { byteStart: 7, byteEnd: 11 }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://x.test' }] }])
has(uni, '>link</a>', 'facet offsets are decoded as UTF-8 bytes, not string indices')

// A malformed facet set degrades to plain text rather than interleaved tags.
const bad = renderRichText('hello', [{ index: { byteStart: 99, byteEnd: 200 }, features: [] }])
has(bad, 'hello', 'an out-of-range facet leaves the text intact')
not(bad, '<a', 'and invents no link')

// javascript: and other schemes must never become an href.
not(renderRichText('x', [{ index: { byteStart: 0, byteEnd: 1 }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'javascript:alert(1)' }] }]), 'javascript:', 'a non-http facet uri is refused')

// Bare URLs in plain text — people post links constantly and they used to render inert.
has(renderText('see https://example.com/x now'), '<a class="link" href="https://example.com/x"', 'bare url is linkified')
has(renderText('see https://example.com.'), '>https://example.com</a>', 'trailing punctuation is not swallowed')

// Profile header
const pp = profilePage({ did: 'did:plc:alice', result: { posts: [] }, profile: { displayName: 'Alice', description: 'hi #there', avatar: { ref: { $link: 'bafkreiabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs' } } } })
has(pp, 'class="profname">Alice<', 'profile shows a display name')
has(pp, 'class="pfp big"', 'and a large avatar')
has(pp, 'did:plc:alice', 'the FULL did stays visible on the profile — this is where verifying it matters')
has(pp, '/tag/there', 'bio text is rendered, hashtags linked')

// Post page
has(postPage({ post: post() }), 'at://did:plc:alice/app.bsky.feed.post/r1', 'post page shows the at:// uri')
has(postPage({ post: null }), 'no such post', 'a missing post says so rather than 500ing')

// ── settings: name + bio editor ──────────────────────────────────────────────────────────────────
// There was no way to set either. Settings did the avatar only and no hpp subcommand writes records, so
// every peer's display name was whatever it was at onboarding and "change my name" meant hand-editing a
// DAG-CBOR record. Rendered as a PLAIN FORM so it works with scripting off — the counters are the only
// part that needs JS.
const sp2 = settingsPage({ viewer, profile: { displayName: 'Alice', description: 'hi there' }, did: viewer.did, devices: [] })
has(sp2, 'action="/api/profile"', 'the editor posts to /api/profile')
has(sp2, 'method="POST"', 'as a real form — no JS required to save')
has(sp2, 'name="displayName"', 'display name field')
has(sp2, 'name="description"', 'bio field')
has(sp2, 'value="Alice"', 'current display name is pre-filled')
has(sp2, '>hi there<', 'current bio is pre-filled')

// The placeholder shows what other peers WOULD see with no name set — the short did, not empty space.
has(settingsPage({ viewer, profile: null, did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', devices: [] }), 'placeholder="wj62ebbu…ogzo"', 'with no name set, the placeholder shows the did fallback')

// Existing values are attacker-controlled in the same sense device labels are (they round-trip through a
// record), so they must be escaped inside the value attribute.
has(settingsPage({ viewer, profile: { displayName: '"><script>x</script>' }, did: viewer.did, devices: [] }), '&quot;&gt;&lt;script&gt;', 'a hostile display name is escaped in the value attribute')
not(settingsPage({ viewer, profile: { displayName: '"><script>x</script>' }, did: viewer.did, devices: [] }), '<script>x</script>', 'and never breaks out of the attribute')

not(anon, 'action="/api/profile"', 'signed out, no profile editor is rendered')

// ── social graph: like + follow affordances ──────────────────────────────────────────────────────
// Both are PLAIN FORMS so they work with scripting off, and both carry a `return` so the redirect lands
// back where you clicked. Whether the button renders at all is a property of the PAGE (is there a
// viewer), not of the post — hence withViewer stamping it rather than renderPost guessing.
const liked = withViewer([post({ likeCount: 3, viewerLiked: true, uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], viewer, '/')[0]
const rl = renderPost(liked)
has(rl, 'action="/api/like"', 'a signed-in viewer gets a like form')
has(rl, 'name="uri" value="at://did:plc:alice/app.bsky.feed.post/r1"', 'carrying the post uri')
has(rl, 'name="return" value="/"', 'and where to return to')
has(rl, 'class="likebtn on"', 'an already-liked post shows the active state')
has(rl, '>3<', 'and the count')

const unliked = withViewer([post({ likeCount: 0, viewerLiked: false })], viewer, '/')[0]
has(renderPost(unliked), 'class="likebtn"', 'an unliked post still gets the button')
not(renderPost(unliked), 'likebtn on', 'without the active state')

// Anonymous: no button, and no zero-count noise either.
const anonPost = withViewer([post({ likeCount: 2 })], null, '/')[0]
not(renderPost(anonPost), 'action="/api/like"', 'an anonymous viewer gets NO like form')
has(renderPost(anonPost), '>2<', 'but still sees the count')
not(renderPost(withViewer([post({ likeCount: 0 })], null, '/')[0]), 'likef', 'a zero count on an anonymous view renders nothing at all')

// Follow button: only when signed in, and never on your own profile — following yourself is meaningless.
const others = profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: { following: 1, followers: 2, viewerFollows: false, isSelf: false, canFollow: true } })
has(others, 'action="/api/follow"', 'another profile offers a follow button')
has(others, '>follow<', 'reading "follow" when not yet following')
has(profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: { viewerFollows: true, isSelf: false, canFollow: true, following: 0, followers: 0 } }), 'following ✓', 'and "following" once you do')
not(profilePage({ did: viewer.did, result: { posts: [] }, viewer, graph: { isSelf: true, canFollow: true, following: 0, followers: 0 } }), 'action="/api/follow"', 'your OWN profile offers no follow button')
not(profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer: null, graph: { canFollow: false, following: 0, followers: 0 } }), 'action="/api/follow"', 'anonymous gets no follow button')
has(others, '<strong>2</strong> followers', 'follower count is rendered')

// Nav: Following only when signed in — an anonymous viewer has no graph, so the link would dead-end.
has(timelinePage({ result: { posts: [] }, viewer }), 'href="/following"', 'signed in, the nav offers Following')
not(timelinePage({ result: { posts: [] }, viewer: null }), 'href="/following"', 'anonymous, it does not')
has(timelinePage({ result: { posts: [] }, viewer, active: 'following', emptyMsg: 'nobody yet' }), 'nobody yet', 'the following feed can state its own empty message')

// ── replies: thread view, reply box, counts ──────────────────────────────────────────────────────
const withReplies = withViewer([post({ replyCount: 2 })], viewer, '/')[0]
has(renderPost(withReplies), 'class="replyc"', 'a post with replies shows a reply count')
has(renderPost(withReplies), 'href="/post/did%3Aplc%3Aalice/r1"', 'and it LINKS into the thread — the affordance for "there is more here"')
// SUPERSEDED intent: 0.2.43 hid the icon at zero, which left no way to START a thread from a timeline.
// The icon is now always present; only the NUMBER is conditional.
not(renderPost(withViewer([post({ replyCount: 0 })], viewer, '/')[0]), '↩ 0', 'zero replies shows the icon but no number')

// A reply in a timeline reads as a non-sequitur unless it says what it answers.
const isReply = post({ record: { $type: 'app.bsky.feed.post', text: 'agreed', reply: { root: { uri: 'at://did:plc:bob/app.bsky.feed.post/x' }, parent: { uri: 'at://did:plc:bob/app.bsky.feed.post/x' } } } })
has(renderPost(isReply), 'class="inreply"', 'a reply is marked as one')
has(renderPost(isReply), '/post/did%3Aplc%3Abob/x', 'and links to the post it answers')
not(renderPost(post()), 'class="inreply"', 'a normal post is not marked')

// Thread view + reply box on the permalink page.
const thread = postPage({ post: post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1' }), viewer, canWrite: true, replies: [post({ rkey: 'c1', record: { $type: 'app.bsky.feed.post', text: 'a reply' } })] })
has(thread, 'class="replyf"', 'signed in + writable, the post page offers a reply box')
has(thread, 'name="replyTo" value="at://did:plc:alice/app.bsky.feed.post/r1"', 'carrying the parent uri')
has(thread, 'method="POST" action="/api/post"', 'as a plain form — replying needs no JS')
has(thread, 'class="thread"', 'and renders the thread')
has(thread, '1 reply', 'with a singular count')
has(thread, 'a reply', 'and the reply text')
has(postPage({ post: post(), viewer, canWrite: true, replies: [post({ rkey: 'c1' }), post({ rkey: 'c2' })] }), '2 replies', 'pluralised correctly')

// No box when there is nobody to sign, or nobody signed in — a form that cannot post is worse than none.
not(postPage({ post: post(), viewer: null, canWrite: true }), 'class="replyf"', 'anonymous gets no reply box')
not(postPage({ post: post(), viewer, canWrite: false }), 'class="replyf"', 'a node that cannot write offers no reply box')

// The reply ICON must be present whether or not there are replies. 0.2.43 rendered it only when
// replyCount > 0, which meant a timeline offered no way to START a thread — you could only join one that
// already existed. The icon is the affordance; the number is incidental.
const fresh = withViewer([post({ replyCount: 0 })], viewer, '/')[0]
has(renderPost(fresh), 'class="replyc"', 'a post with NO replies still shows the reply affordance')
has(renderPost(fresh), 'aria-label="reply to this post"', 'labelled as an ACTION rather than a count — and as an aria-label, since a bare ↩ is meaningless read aloud')
has(renderPost(withViewer([post({ replyCount: 2 })], viewer, '/')[0]), '↩ 2', 'and shows the count when there is one')

// ── whole-entry click target ─────────────────────────────────────────────────────────────────────
// The thread view is where a conversation lives, and the only ways into it were the timestamp and the
// ↩ icon — two tiny targets on a full-width card. The whole entry is now a link to its own thread.
const clickable = renderPost(post())
has(clickable, '<a class="pstretch" href="/post/did%3Aplc%3Aalice/r1"', 'the whole post entry is a link to its thread view')
has(clickable, 'aria-hidden="true" tabindex="-1"', 'the overlay is hidden from AT + the tab order — the permalink and ↩ already lead there')
// A REAL anchor, not a JS click handler: this must survive scripting being off, and must keep
// middle-click / ctrl-click / copy-link-address working.
not(clickable, 'onclick', 'it is a real anchor, not an inline click handler')
// It must not WRAP the card: a post contains anchors (byline, tags) and forms (like, repost), and an
// <a> may legally contain neither. Nesting them would produce invalid HTML and dead buttons.
ok(clickable.indexOf('<a class="pstretch"') > clickable.indexOf('<article'), 'the link is INSIDE the article, not wrapping it')
ok(clickable.indexOf('</a>') < clickable.indexOf('class="phead"'), 'and closes before the content — it overlays, it does not contain')

// Every post entry, not just timeline rows: a reply in a thread links to its OWN thread.
const nested = postPage({ post: post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1' }), viewer, replies: [post({ rkey: 'c1', record: { $type: 'app.bsky.feed.post', text: 'a reply' } })] })
has(nested, 'href="/post/did%3Aplc%3Aalice/c1"', 'a reply in the thread is itself a link to its own thread')

// …except the post the page is already showing. Linking a page 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 focusOnly = postPage({ post: post(), viewer })
not(focusOnly, 'class="pstretch"', 'the focused post of a thread page gets no whole-entry link')
// Ancestors are OTHER posts, so they keep theirs.
has(postPage({ post: post({ rkey: 'r2' }), viewer, ancestors: [post({ rkey: 'anc' })] }), 'href="/post/did%3Aplc%3Aalice/anc"', 'an ancestor above the focused post is still clickable into its own thread')

// A collapsed (moderated/muted) post still carries it — inside the <details>, so it is only reachable
// once revealed, and the <summary> is raised above it in CSS so it toggles rather than navigating.
has(renderPost(post({ treatment: 'warn', labels: ['nsfw'] })), 'class="pstretch"', 'a collapsed post carries the link inside its <details>')

// The CSS contract the overlay depends on. These are load-bearing: without the raise rules the byline,
// tags, like and repost controls would all be swallowed by the overlay sitting on top of them.
const shell = timelinePage({ result: { posts: [post()] }, viewer })
has(shell, '.pstretch{position:absolute;inset:0', 'the overlay is absolutely positioned over the card')
has(shell, '.post{position:relative', 'and the card is its containing block')
for (const sel of ['.post .byline', '.post .text a', '.post .meta a', '.post .meta form', 'details.post>summary']) {
  has(shell, sel, `interactive descendants are raised above the overlay: ${sel}`)
}

// The overlay is an anchor lying over the text, so it would swallow the drag that selects that text.
// With scripting available the overlay leaves the hit test and a handler does the job instead — the one
// place it must NOT navigate is mid-selection. With scripting off, none of this exists.
has(shell, '.js-postclick .pstretch{pointer-events:none}', 'with JS on, the overlay leaves the hit test so text stays selectable')
has(shell, 'js-postclick', 'and the page arms that class itself')
has(shell, 'sel.isCollapsed', 'the handler refuses to navigate while text is selected')
has(shell, 'auxclick', 'middle-click still opens the thread in a new tab')
has(shell, 'e.target.closest(CONTROL)', 'clicks on a real control (byline, tag, like, repost) are left alone')

// Reposts mirror likes: form toggle, count hidden at zero, anonymous sees counts but gets no button.
const rpPost = withViewer([post({ repostCount: 4, viewerReposted: true, uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], viewer, '/')[0]
has(renderPost(rpPost), 'action="/api/repost"', 'a signed-in viewer gets a repost form')
has(renderPost(rpPost), 'class="rpbtn on"', 'an already-reposted post shows the active state')
has(renderPost(rpPost), 'class="rps">4<', 'with the count')
not(renderPost(withViewer([post({ repostCount: 0 })], null, '/')[0]), 'rpf', 'anonymous + zero reposts renders nothing')
not(renderPost(withViewer([post({ repostCount: 3 })], null, '/')[0]), 'action="/api/repost"', 'anonymous gets no repost form')
has(renderPost(withViewer([post({ repostCount: 3 })], null, '/')[0]), '3', 'but still sees the count')


// ── responsive controls ──────────────────────────────────────────────────────────────────────────
// like/repost/follow were plain form POSTs that RELOADED THE PAGE to toggle a heart. Correct with
// scripting off — and it felt broken: scroll lost, timeline re-fetched, the whole page stalled behind one
// click on a slow P2P read. The forms stay (that IS the no-JS path); /static/actions.js posts JSON to the
// same routes, which already answer {ok, liked, likeCount} instead of redirecting.
has(shell, '/static/actions.js', 'the responsive-controls script is loaded')
has(shell, 'method="POST" action="/api/like"', 'and the plain form REMAINS — enhancement, not replacement')
// The press must be felt before the network answers.
has(shell, '.likebtn:active', 'a press has a tactile active state')
has(shell, 'transition:transform', 'transitions are declared on the controls')
// A rolled-back optimistic flip is indistinguishable from never having clicked unless it is shown.
has(shell, '.likebtn.failed', 'a failed write is visibly marked, not silently reverted')
// Touch + keyboard are first-class, not afterthoughts.
has(shell, 'touch-action:manipulation', 'controls opt out of the double-tap delay')
has(shell, ':focus-visible', 'keyboard focus is visible on the controls')
has(shell, 'prefers-reduced-motion', 'motion is disabled for readers who ask for that')


// ── pagination + lightbox ────────────────────────────────────────────────────────────────────────
// The cursor was computed on EVERY feed request and thrown away, so a reader could never see past the
// newest 50 posts. It is a plain <a href="?cursor=…"> so paging works with scripting off.
const paged = timelinePage({ result: { posts: [post()], cursor: 'k123' }, viewer })
has(paged, 'class="more" href="/?cursor=k123"', 'a cursor renders an older-posts link')
has(paged, 'rel="next"', 'marked rel=next for crawlers and readers')
not(timelinePage({ result: { posts: [post()], cursor: null }, viewer }), 'class="more"', 'no cursor (stream ended) → no link')
has(timelinePage({ result: { posts: [post()], cursor: 'k' }, viewer, active: 'following' }), '/following?cursor=k', 'the following feed pages itself, not the main timeline')
has(tagPage({ tag: 'foo', result: { posts: [post()], cursor: 'k' }, viewer }), '/tag/foo?cursor=k', 'tag pages paginate')
// The profile keeps its active tab across pages — paging out of Media into Posts would be a bug.
has(profilePage({ did: 'did:plc:alice', result: { posts: [post()], cursor: 'k' }, viewer, filter: 'posts_with_media' }),
  'filter=posts_with_media&amp;cursor=k', 'profile paging preserves the selected tab (& escaped, as an HTML attribute requires)')

// Images are REAL LINKS to their own bytes: with no JS a click opens the full picture (previously there
// was no way to see one full-size at all), and with JS actions.js shows the lightbox. It must be a link
// regardless — the whole post is a stretched anchor, so an image without its own element would open the
// thread instead of the picture.
const lbPost = renderPost(imgPost)
has(lbPost, `<a class="lb" href="/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&amp;cid=${CID}"`, 'each image links to its own full-size bytes')
has(lbPost, 'data-alt="a cat"', 'alt text rides the link so the viewer can DISPLAY it, not just carry it')
has(paged, '.post .media a', 'and images are raised above the whole-entry overlay so they are clickable (CSS lives in the shell, not the post)')
// Two images share a row rather than stacking full-width.
const two = { alt: '', image: { ref: { $link: CID } } }
has(renderEmbed(post({ record: { embed: { $type: 'app.bsky.embed.images', images: [two, two] } } })), 'class="media multi"', 'multiple images get the grid layout')
has(renderEmbed(post({ record: { embed: { $type: 'app.bsky.embed.images', images: [two] } } })), 'class="media"', 'a single image does not')

// Design tokens exist, so spacing/type/colour have ONE place to change rather than per-component values.
has(paged, '--s4:1rem', 'a spacing scale is defined')
has(paged, '@media(max-width:640px)', 'and a real small-screen layout, not just a fluid width')


// ── quote posts + link cards + foreign profiles ──────────────────────────────────────────────────
// A QUOTE embeds a strongRef, not the quoted content, and used to render as NOTHING: the commentary
// appeared with the post it was about invisible, reading as a non-sequitur. The appview resolves it
// (_resolveQuoted) and attaches `quoted`, the same way `author` rides the view.
const quoted = { did: 'did:plc:bob', rkey: 'q1', record: { $type: 'app.bsky.feed.post', text: 'the original', createdAt: '2026-07-30T00:00:00.000Z' }, author: { displayName: 'Bob' } }
const qPost = post({ record: { $type: 'app.bsky.feed.post', text: 'look at this', embed: { $type: 'app.bsky.embed.record', record: { uri: 'at://did:plc:bob/app.bsky.feed.post/q1', cid: 'bafy' } } }, quoted })
const qr = renderPost(qPost)
has(qr, 'class="quote"', 'a quote post renders the quoted card')
has(qr, 'the original', 'including the quoted TEXT — the whole point')
has(qr, '>Bob<', 'and who wrote it')
has(qr, 'href="/post/did%3Aplc%3Abob/q1"', 'the card links to the quoted post')
// A quoted post gets NO controls of its own — acting on it belongs on its own page.
not(qr.slice(qr.indexOf('class="quote"')), 'action="/api/like"', 'the quoted card carries no like form')

// 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 reads as a broken render.
const unres = renderPost(post({ record: { $type: 'app.bsky.feed.post', text: 'x', embed: { $type: 'app.bsky.embed.record', record: { uri: 'at://did:plc:zed/app.bsky.feed.post/z1', cid: 'b' } } } }))
has(unres, 'outside this node', 'an unresolvable quote is stated honestly')
has(unres, 'at://did:plc:zed', 'and names the uri it could not reach')

// LINK CARDS are self-contained in the record — no resolution needed.
const card = renderPost(post({ record: { $type: 'app.bsky.feed.post', text: 'read this', embed: { $type: 'app.bsky.embed.external', external: { uri: 'https://example.com/a', title: 'A Title', description: 'A description' } } } }))
has(card, 'class="card"', 'an external embed renders a link card')
has(card, 'A Title', 'with its title')
has(card, 'example.com', 'and the host, so the destination is visible before the click')
// A card is attacker-supplied content: a non-http uri must never become an href.
not(renderPost(post({ record: { embed: { $type: 'app.bsky.embed.external', external: { uri: 'javascript:alert(1)', title: 'x' } } } })), 'javascript:', 'a non-http card uri is refused')

// FOREIGN PROFILES. A bsky account reached via a backlink has no local profile record, so the page showed
// a short DID, a placeholder avatar and an empty feed — honest, and indistinguishable from broken.
const lensProf = profilePage({ did: 'did:plc:bob', result: { posts: [] }, lensProfile: { lens: 'bsky', displayName: 'Bob', handle: 'bob.bsky.social', description: 'a bio', avatarUrl: 'https://cdn.bsky.app/a.jpg', followersCount: 42, postsCount: 7 } })
has(lensProf, 'class="profname">Bob<', 'a lens supplies the display name instead of a bare DID')
has(lensProf, 'cdn.bsky.app/a.jpg', 'and the avatar, loaded from the lens (we hold no blob for them)')
has(lensProf, 'a bio', 'and the bio')
// Never passed off as ours.
has(lensProf, 'profile via', 'attributed to a NAMED foreign source')
has(lensProf, 'holds no records for this account', 'and says plainly that we hold nothing')
has(lensProf, 'knows them only by reference', 'the empty feed explains itself rather than reading as broken')
// A LOCAL profile must not be overridden by a lens.
const localProf = profilePage({ did: 'did:plc:alice', result: { posts: [] }, profile: { displayName: 'Alice' }, lensProfile: { lens: 'bsky', displayName: 'WRONG' } })
has(localProf, 'Alice', 'a profile we hold wins')
not(localProf, 'WRONG', 'the lens never overrides local data')
not(localProf, 'profile via', 'and no foreign attribution appears on a local profile')


// ── quote CREATION + video ───────────────────────────────────────────────────────────────────────
// Quoting is a plain link to the composer, so it works with scripting off. Only for a viewer who can
// write — an anonymous reader must not be given a control that goes nowhere.
has(renderPost(withViewer([post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], viewer, '/')[0]), 'class="qbtn"', 'a signed-in viewer gets a quote affordance')
has(renderPost(withViewer([post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], viewer, '/')[0]), '/?quote=at%3A%2F%2Fdid%3Aplc%3Aalice', 'which carries the post uri to the composer')
not(renderPost(withViewer([post()], null, '/')[0]), 'class="qbtn"', 'an anonymous viewer gets none')

// The composer must SHOW what is being quoted, or it is indistinguishable from a normal one.
const qc = timelinePage({ result: { posts: [] }, viewer, canWrite: true, quoting: { uri: 'at://did:plc:bob/app.bsky.feed.post/q1', did: 'did:plc:bob', who: 'Bob', text: 'the original' } })
has(qc, 'name="quoteOf" value="at://did:plc:bob/app.bsky.feed.post/q1"', 'the composer carries quoteOf as a form field (no JS needed)')
has(qc, 'class="qpreview"', 'and previews what is being quoted')
has(qc, 'Bob', 'naming the author')
has(qc, 'the original', 'and showing their text')
not(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'name="quoteOf"', 'a normal composer carries no quoteOf')

// VIDEO. embed.video is a blob like an image, and we already serve the bytes — so play it.
const vid = renderPost(post({ record: { $type: 'app.bsky.feed.post', text: 'clip', embed: { $type: 'app.bsky.embed.video', video: { ref: { $link: CID } }, alt: 'a clip' } } }))
has(vid, '<video class="vid"', 'a video embed renders a real player')
has(vid, 'controls', 'with controls')
has(vid, 'preload="metadata"', 'and preload=metadata so a timeline of videos does not pull megabytes')
has(vid, `cid=${CID}`, 'pointing at the blob bytes')
not(renderPost(post({ record: { embed: { $type: 'app.bsky.embed.video', video: { ref: { $link: '../../etc/passwd' } } } } })), '<video', 'an unsafe cid renders no player')


// ── lens avatars in the byline ───────────────────────────────────────────────────────────────────
// A bsky author whose profile record this node never indexed had a picture on their PROFILE (0.3.12) and
// a grey circle in the TIMELINE, because the fix landed on the page and not on the byline.
const lensAuthor = post({ author: { did: 'did:plc:bob', displayName: 'Bob', avatarUrl: 'https://cdn.bsky.app/b.jpg' } })
const lb2 = renderPost(lensAuthor)
has(lb2, 'src="https://cdn.bsky.app/b.jpg"', 'a lens avatar renders in the byline')
has(lb2, 'referrerpolicy="no-referrer"', 'without leaking which page is being browsed')
has(lb2, '>Bob<', 'and the lens display name is used')
// A blob we hold always wins over a remote URL.
has(renderPost(post({ author: { did: 'did:plc:alice', displayName: 'Alice', avatar: { ref: { $link: CID } }, avatarUrl: 'https://cdn.bsky.app/WRONG.jpg' } })), `cid=${CID}`, 'our own blob wins over a lens url')
not(renderPost(post({ author: { did: 'did:plc:alice', avatar: { ref: { $link: CID } }, avatarUrl: 'https://cdn.bsky.app/WRONG.jpg' } })), 'WRONG.jpg', 'and the lens url is not used at all when we hold the blob')
// Only https — an http or javascript avatar url must never reach a src.
not(renderPost(post({ author: { did: 'did:plc:bob', avatarUrl: 'http://insecure.example/x.jpg' } })), 'insecure.example', 'a non-https avatar url is refused')
not(renderPost(post({ author: { did: 'did:plc:bob', avatarUrl: 'javascript:alert(1)' } })), 'javascript:', 'a javascript: avatar url is refused')
// A handle is an IDENTITY CLAIM and stays gated on verification — the lens supplies a name, never a handle.
not(renderPost(post({ author: { did: 'did:plc:bob', displayName: 'Bob', handle: 'bob.bsky.social', handleVerified: false, avatarUrl: 'https://cdn.bsky.app/b.jpg' } })), '@bob.bsky.social', 'an unverified handle is still never rendered as one')


// The quoted card is a person too — a quote of a bsky post showed a blank card author on every surface.
has(renderPost(post({ record: { $type: 'app.bsky.feed.post', text: 'x', embed: { $type: 'app.bsky.embed.record', record: { uri: 'at://did:plc:bob/app.bsky.feed.post/q1', cid: 'b' } } },
  quoted: { did: 'did:plc:bob', rkey: 'q1', record: { text: 'orig', createdAt: '2026-07-30T00:00:00.000Z' }, author: { did: 'did:plc:bob', displayName: 'Bob', avatarUrl: 'https://cdn.bsky.app/b.jpg' } } })),
  'class="pfp qpfp" src="https://cdn.bsky.app/b.jpg"', 'a quoted card shows the lens avatar too')


// ── delete ───────────────────────────────────────────────────────────────────────────────────────
// Only on YOUR OWN posts. isOwn is stamped by withViewer from the viewer's DID — the render stays a pure
// function of its input, and the server re-checks ownership regardless (a hidden control is not a
// permission check).
const mine = withViewer([post({ did: 'did:plc:alice', uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], { did: 'did:plc:alice' }, '/')[0]
const theirs = withViewer([post({ did: 'did:plc:bob', uri: 'at://did:plc:bob/app.bsky.feed.post/r1' })], { did: 'did:plc:alice' }, '/')[0]
has(renderPost(mine), 'action="/api/delete"', 'your own post offers delete')
has(renderPost(mine), 'name="uri" value="at://did:plc:alice/app.bsky.feed.post/r1"', 'carrying the post uri')
not(renderPost(theirs), 'action="/api/delete"', "someone else's post does not")
not(renderPost(withViewer([post()], null, '/')[0]), 'action="/api/delete"', 'and an anonymous viewer never sees it')

// Deletion is irreversible, so it asks first — and the confirm is a native <details>, not confirm(),
// so it works with scripting off exactly like the moderation collapse.
has(renderPost(mine), '<details class="delw">', 'the confirm is a native details, no JS needed')
has(renderPost(mine), '>really delete<', 'and the destructive act is never the first click')


// ── mute + block ─────────────────────────────────────────────────────────────────────────────────
// Offered together, deliberately NOT alike. A mute is private and reversible; a block writes a PUBLIC
// record that federates. Presenting them identically would invite someone to publish a block when they
// meant to quietly mute.
const modGraph = { canFollow: true, isSelf: false, following: 0, followers: 0 }
const modProf = profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: modGraph })
has(modProf, 'class="modrow"', 'another profile offers moderation controls')
has(modProf, 'action="/api/mute"', 'mute posts to its own route')
has(modProf, 'action="/api/block"', 'and block to its own')
// The block is the heavier action and says so BEFORE it happens.
has(modProf, '<details class="modw">', 'block asks first, via a native details (no JS)')
has(modProf, 'this is public', 'and warns that a block is PUBLIC — a mute never is')
not(modProf.slice(modProf.indexOf('class="modrow"')), '<details class="modw"><summary>mute', 'mute is one click — it is private and reversible')

// State-aware verbs.
const muted = profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: { ...modGraph, viewerMuted: true, viewerBlocked: true } })
has(muted, '>unmute<', 'a muted account offers unmute')
has(muted, '>unblock<', 'a blocked account offers unblock')

// Never on your own profile, never signed out.
not(profilePage({ did: viewer.did, result: { posts: [] }, viewer, graph: { ...modGraph, isSelf: true } }), 'class="modrow"', 'your own profile offers neither')
not(profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer: null, graph: { ...modGraph, canFollow: false } }), 'class="modrow"', 'and an anonymous viewer gets neither')


// ── search page ──────────────────────────────────────────────────────────────────────────────────
// TWO SOURCES, never merged: what this node holds gets the ordinary affordances, what bsky returns is a
// named foreign source with escaped text and no controls. Blending them would make a partial index look
// total — the same rule the thread view's lens replies follow.
const sp3 = searchPage({ q: 'p2p', local: { posts: [post()], truncated: false }, actors: [{ did: 'did:plc:alice', profile: { displayName: 'Alice' } }],
  lens: { name: 'bsky', posts: [{ did: 'did:plc:x', handle: 'x.bsky.social', text: 'remote hit', createdAt: '2026-07-30T00:00:00.000Z' }], actors: [{ did: 'did:plc:y', handle: 'y.bsky.social', displayName: 'Why' }] }, viewer })
has(sp3, 'posts here', 'local posts are labelled as ours')
has(sp3, 'people here', 'and local people too')
has(sp3, 'via <span class="lensname">bsky', 'bsky results are attributed to a NAMED source')
has(sp3, 'not indexed by this node', 'and say plainly that we do not hold them')
has(sp3, 'remote hit', 'the remote text is shown')
// A remote post gets no controls — acting on a post we do not hold would be a lie.
not(sp3.slice(sp3.indexOf('<section class="lens">')), 'action="/api/like"', 'remote results carry no like form (sliced from the SECTION, not the css rule)')
// An empty query is a prompt, not a result set.
has(searchPage({ q: '' }), 'Search', 'an empty query shows the search page')
not(searchPage({ q: '' }), 'posts here', 'with no result sections')
// A bounded scan that hit its cap must SAY so — a short list that looks complete is the worse failure.
has(searchPage({ q: 'x', local: { posts: [], truncated: true } }), 'bounded depth', 'a truncated scan is disclosed')
// Search must work with scripting off: a plain GET form, so the result is also a shareable URL.
has(sp3, 'method="GET" action="/search"', 'search is a plain GET form (no JS, shareable URL)')
has(timelinePage({ result: { posts: [] }, viewer }), 'class="navsearch"', 'and there is a search box in the nav')


// ── threadgates ──────────────────────────────────────────────────────────────────────────────────
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'name="gate"', 'the composer offers who-can-reply')
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), '>nobody<', 'including turning replies off')
// A gated thread SAYS it is gated.
has(postPage({ post: post(), viewer, canWrite: true, gateNote: 'replies are turned off', viewerCanReply: false }), 'replies are turned off', 'a gated thread states the restriction')
// And the box is gone — with the reason, because silently removing it looks like a broken page.
not(postPage({ post: post(), viewer, canWrite: true, gateNote: 'replies are turned off', viewerCanReply: false }), 'class="replyf"', 'a viewer who may not reply gets no box')
has(postPage({ post: post(), viewer, canWrite: true, gateNote: 'replies are turned off', viewerCanReply: false }), 'not included', 'and is told why, rather than left guessing')
// An ungated thread is unchanged.
has(postPage({ post: post(), viewer, canWrite: true }), 'class="replyf"', 'an ungated thread still offers the reply box')
not(postPage({ post: post(), viewer, canWrite: true }), 'class="gatenote"', 'and shows no gate notice (anchored on MARKUP — the css rule always matches)')


// ── language + banner ────────────────────────────────────────────────────────────────────────────
// `langs` was hardcoded ['en']. It 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 a mis-declared language makes a
// post vanish from feeds while looking fine everywhere else.
const comp = timelinePage({ result: { posts: [] }, viewer, canWrite: true })
has(comp, 'name="lang"', 'the composer offers a language')
has(comp, '<option value="en">English</option>', 'defaulting to English')
has(comp, '<option value="ja">', 'with a short list of common tags, not 180 ISO codes')

// Banner. Absent → NOTHING renders, not an empty grey band: a blank placeholder reads as a broken image
// rather than as "no banner set".
const withBanner = profilePage({ did: 'did:plc:alice', result: { posts: [] }, profile: { banner: { ref: { $link: CID } } } })
has(withBanner, 'class="profbanner"', 'a profile with a banner renders it')
has(withBanner, `cid=${CID}`, 'from the blob bytes')
not(profilePage({ did: 'did:plc:alice', result: { posts: [] }, profile: {} }), 'class="profbanner"', 'a profile without one renders no band at all')
// An unsafe cid never reaches a src.
not(profilePage({ did: 'did:plc:alice', result: { posts: [] }, profile: { banner: { ref: { $link: '../../etc/passwd' } } } }), 'class="profbanner"', 'an unsafe banner cid is refused')
// Settings offers the control.
has(settingsPage({ viewer, profile: null, did: viewer.did, devices: [] }), 'id="banner-file"', 'settings offers a banner picker')
has(settingsPage({ viewer, profile: null, did: viewer.did, devices: [] }), 'no banner set', 'and says so when none is set')


// ── bookmarks + self-threads ─────────────────────────────────────────────────────────────────────
// A bookmark is private: no count, no shared state, just on or off.
const bmOn = withViewer([post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1', viewerBookmarked: true })], viewer, '/')[0]
const bmOff = withViewer([post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], viewer, '/')[0]
has(renderPost(bmOff), 'action="/api/bookmark"', 'a signed-in viewer can save a post')
has(renderPost(bmOn), 'class="bmbtn on"', 'a saved post shows the active state')
not(renderPost(bmOn.uri ? bmOn : bmOn), '☆', 'a saved post shows a filled star, not an empty one')
not(renderPost(withViewer([post()], null, '/')[0]), 'action="/api/bookmark"', 'an anonymous viewer gets no bookmark control')

// The page is private BY CONSTRUCTION — there is no shared state to render.
const bmPage = bookmarksPage({ result: { posts: [post()] }, viewer })
has(bmPage, 'private to you', 'the page says plainly that bookmarks are private')
has(bmPage, 'stored only on this node', 'and node-local')
has(bookmarksPage({ viewer: null }), 'Sign in to save posts', 'signed out, it prompts rather than 404s')
// A bookmark can outlive its post — say how many could not be loaded rather than silently returning fewer.
has(bookmarksPage({ result: { posts: [] }, viewer, missing: 2 }), '2 bookmarked posts could not be loaded', 'missing bookmarks are disclosed')
has(bookmarksPage({ result: { posts: [] }, viewer }), 'nothing saved yet', 'and an empty list says so')
// Nav entry only when signed in — an anonymous viewer has no bookmarks.
has(timelinePage({ result: { posts: [] }, viewer }), 'href="/bookmarks"', 'signed in, the nav offers saved')
not(timelinePage({ result: { posts: [] }, viewer: null }), 'href="/bookmarks"', 'anonymous, it does not')

// SELF-THREADS: the composer explains the separator, since it is the only way to discover the feature.
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'a line with only --- starts a new post', 'the composer explains how to thread')


// ── accessibility ────────────────────────────────────────────────────────────────────────────────
// `title` is NOT an accessible name: it is unreliable with screen readers and invisible on touch. Every
// icon-only control needs a real label, and every toggle needs its state exposed.
const a11y = renderPost(withViewer([post({ uri: 'at://did:plc:alice/app.bsky.feed.post/r1', likeCount: 2 })], viewer, '/')[0])
has(a11y, 'aria-label="like this post"', 'the like button has a real accessible name')
has(a11y, 'aria-pressed="false"', 'and exposes its pressed STATE, not just a colour')
has(renderPost(withViewer([post({ viewerLiked: true, uri: 'at://x/y/z' })], viewer, '/')[0]), 'aria-pressed="true"', 'which flips when active')
has(a11y, 'aria-label="repost this post"', 'repost too')
has(a11y, 'aria-label="save this post"', 'and bookmark')
has(a11y, 'aria-label="quote this post"', 'and quote')
// A decorative glyph inside a labelled control must be hidden, or it is read out twice.
has(a11y, '<span aria-hidden="true">❝</span>', 'the icon glyph itself is hidden from the accessibility tree')

// Landmarks + skip link. Without one, a keyboard user tabs the entire nav on every single page.
const shell2 = timelinePage({ result: { posts: [] }, viewer, canWrite: true })
has(shell2, 'class="skip" href="#main"', 'there is a skip link')
has(shell2, '<main id="main"', 'and a main landmark for it to reach')
has(shell2, '<header>', 'the masthead is a header landmark')
// A placeholder is NOT a label — it vanishes on focus and is not reliably announced.
has(shell2, '<label class="sr-only" for="composer-text">', 'the composer textarea has a real label')
has(shell2, 'id="composer-text"', 'wired to the field by id')
// Visually hidden must stay IN the accessibility tree — display:none would remove it entirely.
has(shell2, 'clip:rect(0 0 0 0)', 'sr-only hides visually without hiding from screen readers')
has(shell2, 'a:focus-visible,button:focus-visible', 'every interactive element gets a visible focus ring, not just hand-styled ones')


// ── notifications ────────────────────────────────────────────────────────────────────────────────
// ⛔ THE SERVER NOW ANSWERS. This page used to render a shell and ask the BROWSER for a bsky app
// password, because the node holds no credential of yours — true, and beside the point: it does not
// need one. The inbound lens already folds replies that touch our content into the appview, and
// ap-bridge keeps what the fediverse sends. #45 listed "the 'did anyone reply to me' loop does not
// work" among the five gaps that decide whether this is a product.
//
// The bsky-app-password path is NOT deleted — it fetches something we genuinely cannot (bsky's own
// notification state, incl. things touching accounts we do not host) — it is demoted into a
// <details> so the default page is the one that needs no credential.
const np = notificationsPage({ viewer, pds: 'https://pds.example' })
has(np, 'from this node, no credential', 'the page states where the answer comes from')
has(np, 'id="notif-login"', 'and offers a sign-in form')
has(np, 'app password</strong>', 'asking for an APP password, not the account password')
has(np, 'never stored', 'and says the password is not stored')
has(np, 'type="password"', 'the field is a password field')
has(np, 'name="favela-pds" content="https://pds.example"', 'the PDS origin is passed to the client, not hardcoded')
has(np, '/static/notifications.js', 'and the client script is loaded')
// It is the ONE page that needs JS, so it says so instead of appearing broken.
has(np, '<noscript>', 'it degrades with an explanation, not silence')

// The real list, server-rendered.
{
  const n = (over = {}) => ({ kind: 'reply', network: 'activitypub', who: '@fleeky@prsm.space', when: '2026-08-18T20:21:55Z', text: 'this computer needs a can of air', url: 'https://prsm.space/@fleeky/1', id: '1', ...over })
  const withItems = notificationsPage({ viewer, items: [n()] })
  has(withItems, '@fleeky@prsm.space', 'the author is named')
  has(withItems, 'can of air', 'and what they said is rendered by the SERVER — no JS needed')
  has(withItems, 'replied', 'the verb says what happened')
  has(withItems, 'fediverse', 'and the network is badged — a Mastodon reply is not a bsky reply')
  has(notificationsPage({ viewer, items: [n({ network: 'atproto', who: 'alice.example' })] }), 'bsky', 'the other network is badged too')

  // Remote text is escaped at render even though notifications.js already stripped tags: the store
  // keeps content verbatim by design, so this is the second line of defence, not the first.
  const nasty = notificationsPage({ viewer, items: [n({ text: '<img src=x onerror=alert(1)>' })] })
  not(nasty, '<img src=x', 'remote content cannot inject markup')

  // ⛔ "no replies" and "this node cannot see the fediverse" are different pages.
  has(notificationsPage({ viewer, items: [], apAvailable: false }), 'cannot see them', 'an unreadable AP side SAYS so')
  not(notificationsPage({ viewer, items: [], apAvailable: true }), 'cannot see them', 'and stays quiet when it can see')
  has(notificationsPage({ viewer, items: [] }), 'Nothing yet', 'a genuinely empty list reads as empty')
}
has(np, 'Everything else in favela works without it', 'naming this as the deliberate exception')
// The server must never be handed the credential.
not(np, 'action="/api/', 'the form posts to NO favela endpoint — the browser talks to the PDS directly')
// Nav entry only when signed in.
has(timelinePage({ result: { posts: [] }, viewer }), 'href="/notifications"', 'signed in, the nav offers notifications')
not(timelinePage({ result: { posts: [] }, viewer: null }), 'href="/notifications"', 'anonymous, it does not')


// ── report + draft recovery ──────────────────────────────────────────────────────────────────────
// Report is offered on someone ELSE's post only — reporting your own is meaningless.
const mineR = withViewer([post({ did: 'did:plc:alice', uri: 'at://did:plc:alice/app.bsky.feed.post/r1' })], { did: 'did:plc:alice' }, '/')[0]
const theirsR = withViewer([post({ did: 'did:plc:bob', uri: 'at://did:plc:bob/app.bsky.feed.post/r1', cid: 'bafyxyz' })], { did: 'did:plc:alice' }, '/')[0]
has(renderPost(theirsR), 'class="reportbtn"', "someone else's post can be reported")
has(renderPost(theirsR), 'data-cid="bafyxyz"', 'carrying the cid, since a report subject is a strongRef')
has(renderPost(theirsR), 'aria-label="report this post"', 'with a real accessible name, not just a glyph')
not(renderPost(mineR), 'class="reportbtn"', 'your own post cannot be reported')
not(renderPost(withViewer([post()], null, '/')[0]), 'class="reportbtn"', 'and an anonymous viewer gets no control')
// createReport is user-scoped, so the PDS origin must reach the client on every page, not just /notifications.
has(timelinePage({ result: { posts: [] }, viewer }), 'name="favela-pds"', 'the PDS origin is available to the client everywhere')
has(timelinePage({ result: { posts: [] }, viewer }), '/static/report.js', 'and the report client is loaded')

// Draft recovery is pure progressive enhancement — the composer is unchanged with scripting off.
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), '/static/draft.js', 'the draft client is loaded')
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), '<textarea id="composer-text"', 'and the composer itself is untouched')


// ── postgate + remote author feed ────────────────────────────────────────────────────────────────
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'name="quoteGate"', 'the composer offers a quote gate')
has(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'name="quoteGate"', 'including turning quotes off (labels shortened in 0.3.31 — a select is as wide as its longest option)')
// A post whose author disabled quoting must not offer the control — a button whose write we would refuse
// is worse than no button.
has(postPage({ post: post(), viewer, canWrite: true, quoteNote: 'the author turned off quotes for this post', viewerCanQuote: false }), 'turned off quotes', 'a quote-gated post says so')
not(postPage({ post: post(), viewer, canWrite: true, viewerCanQuote: false }), 'class="qbtn"', 'and offers no quote control')
has(postPage({ post: post(), viewer, canWrite: true, viewerCanQuote: true }), 'class="qbtn"', 'an ungated one still does')

// A bsky account's POSTS, not just their name. Without this, clicking a bsky user gave an avatar and an
// empty page — a profile is mostly a list of posts.
const remoteFeed = profilePage({
  did: 'did:plc:bob', result: { posts: [] },
  lensProfile: { lens: 'bsky', displayName: 'Bob' },
  lensPosts: { name: 'bsky', posts: [{ text: 'a remote post', createdAt: '2026-07-30T00:00:00.000Z', replyCount: 2, images: [{ thumb: 'https://cdn.bsky.app/t.jpg', alt: 'pic' }] }] }
})
has(remoteFeed, 'a remote post', "their posts are shown")
has(remoteFeed, 'class="lthumb"', 'and their media')
has(remoteFeed, 'cdn.bsky.app/t.jpg', 'loaded from the lens CDN, since we hold no blob')
has(remoteFeed, 'referrerpolicy="no-referrer"', 'without leaking the page being browsed')
has(remoteFeed, 'via <span class="lensname">bsky', 'attributed to a NAMED source')
has(remoteFeed, 'not indexed by this node', 'and honest that we do not hold them')
// No affordances on a post we do not hold.
not(remoteFeed.slice(remoteFeed.indexOf('<section class="lens">')), 'action="/api/like"', 'remote posts carry no controls')


// ── user lists ───────────────────────────────────────────────────────────────────────────────────
const myLists = [
  { rkey: 'r1', uri: 'at://did:plc:alice/app.bsky.graph.list/r1', record: { name: 'boats', purpose: 'app.bsky.graph.defs#curatelist', description: 'boat people' } },
  { rkey: 'r2', uri: 'at://did:plc:alice/app.bsky.graph.list/r2', record: { name: 'blocked', purpose: 'app.bsky.graph.defs#modlist' } }
]
const lp2 = listsPage({ viewer, lists: myLists, canWrite: true })
has(lp2, '>boats<', 'your lists are listed')
has(lp2, 'action="/api/list"', 'and there is a create form')
// PURPOSE IS SHOWN, always — a modlist mutes or blocks everyone on it for subscribers, so rendering it
// identically to a curation list hands someone a footgun.
has(lp2, 'moderation list', 'a modlist is labelled as one')
has(lp2, 'class="listkind mod"', 'and marked distinctly')
has(lp2, 'subscribers MUTE or BLOCK everyone on it', 'the create form spells out what a modlist DOES')
has(listsPage({ viewer: null }), 'Sign in to make lists', 'signed out, it prompts')
has(listsPage({ viewer, lists: [], canWrite: true }), 'no lists yet', 'and an empty state says so')

// One list: members, and removal is per-MEMBER because the listitem is the record that exists.
const one = listPage({ viewer, list: myLists[0], members: [{ rkey: 'i1', subject: 'did:plc:bob', displayName: 'Bob' }] })
has(one, '>Bob<', 'members are named where we know them')
has(one, 'action="/api/list-member"', 'with a control to remove them')
has(one, 'name="list" value="at://did:plc:alice/app.bsky.graph.list/r1"', 'carrying the list uri')
has(one, 'at://did:plc:alice/app.bsky.graph.list/r1', 'and the list uri is shown, since #listRule references it')
has(listPage({ viewer, list: myLists[0], members: [] }), 'nobody on this list yet', 'an empty list explains how to fill it')

// The payoff: #listRule threadgates become AUTHORABLE, not just honoured.
const withLists = timelinePage({ result: { posts: [] }, viewer, canWrite: true, composerLists: [{ uri: 'at://did:plc:alice/app.bsky.graph.list/r1', name: 'boats' }] })
has(withLists, 'value="at://did:plc:alice/app.bsky.graph.list/r1">only: boats', 'the composer can gate replies to a list')
not(timelinePage({ result: { posts: [] }, viewer, canWrite: true }), 'only: ', 'and offers none when you have no lists — no dead control')

// Add-to-list from a profile, only when you actually have lists.
const withPicker = profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: { canFollow: true, isSelf: false, following: 0, followers: 0, viewerLists: [{ uri: 'at://l', name: 'boats', member: false }] } })
has(withPicker, 'action="/api/list-member"', 'a profile offers add-to-list')
not(profilePage({ did: 'did:plc:bob', result: { posts: [] }, viewer, graph: { canFollow: true, isSelf: false, following: 0, followers: 0, viewerLists: [] } }), 'action="/api/list-member"', 'and not when you have no lists')


// (block indexing is exercised by test/appview-blocks.test.mjs — it needs a real store, so it runs under bare)


// ── composer layout ──────────────────────────────────────────────────────────────────────────────
// The control row was ONE non-wrapping flex line. Fine with a single select; once scope/lang/quotes/
// replies were all there it overflowed and pushed the image picker and post button outside the box.
const ctl = timelinePage({ result: { posts: [] }, viewer, canWrite: true })
has(ctl, '.controls{flex-wrap:wrap', 'the control row wraps instead of overflowing')
// 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 rather than wrapping.
// The selector gained `.controls button` when buttons were themed for dark mode — before that only
// selects were, so a button kept the UA's light chrome. The property under test is unchanged.
has(ctl, '.controls select,.controls button{min-width:0', 'and selects are allowed to shrink')
has(ctl, '#post-btn{margin-left:auto', 'the post button ends the row wherever it lands')
// A <select> is as wide as its LONGEST option, so no CSS can save a 28-character label.
not(ctl, 'syndicates to bsky</option>', 'the long option labels are gone')
has(ctl, 'class="composerhint"', 'and the meaning they carried moved to a hint under the row')
has(ctl, '@media(max-width:640px)', 'with a phone layout for the row too')

// ── empty states ────────────────────────────────────────────────────────────────────────────────
// A grey italic line made "nothing here" and "the index is broken" render identically.
has(emptyState('no posts'), 'class="emptyst"', 'empty state is a block, not a bare line')
// .empty is ALSO carrying settings helper prose in eight places — restyling it would have turned that
// prose into dashed empty-boxes, so the empty state deliberately uses its own class.
not(emptyState('no posts'), 'class="empty"', 'and does NOT reuse .empty, which settings prose still needs')
has(emptyState('no posts', 'try following someone'), 'try following someone', 'the hint renders when there is one')
not(emptyState('no posts'), 'empty-h', 'and is omitted entirely when there is not — no invented advice')
has(emptyState('<b>x</b>'), '&lt;b&gt;', 'title is escaped')
// Back-compat: every existing caller passes a plain string and must keep working untouched.
has(renderPosts([], 'nothing yet'), 'nothing yet', 'renderPosts still accepts a string (no caller changes)')
has(renderPosts([], { title: 'T', hint: 'H' }), 'T', 'renderPosts also accepts { title, hint }')

// ── follower / following list ───────────────────────────────────────────────────────────────────
const flEmpty = followListPage({ did: 'did:plc:abc', kind: 'followers', accounts: [] })
has(flEmpty, 'no followers here yet', 'empty follower list says so')
has(flEmpty, 'class="subtabs"', 'and still offers the followers/following switch')
has(flEmpty, 'aria-current="page"', 'with the current tab marked for assistive tech')

const fl = followListPage({
  did: 'did:plc:abc', kind: 'following', subjectName: 'tabertron', cursor: '50',
  accounts: [
    { did: 'did:plc:xyz', displayName: 'Someone', handle: 'someone.example', verified: true, source: null },
    { did: 'did:plc:fed', displayName: 'Fedi Person', handle: null, verified: false, source: 'fediverse' }
  ]
})
const flBody = fl.slice(fl.indexOf('<ul class="folist">'))
ok((flBody.match(/<li>/g) || []).length === 2, 'a row per account')
has(fl, 'cursor=50', 'and pages with a cursor')
// THE POINT OF THE PAGE: follows from different sources are LABELLED, never silently summed. A signed
// graph.follow we hold, a lens-reported follow, and (once ap-bridge state is readable) a fediverse
// follower proven only by an HTTP Signature are different epistemic things. Same choice the profile
// already makes showing local and network-wide counts on separate lines.
ok((flBody.match(/class="fo-src"/g) || []).length === 1, 'only the labelled source gets a badge')
has(flBody, '>fediverse<', 'and the badge names it')
not(flBody.split('<li>')[1], 'fo-src', 'an unlabelled row stays silent — that default IS the information')
// A handle is a CLAIM until verified; showing an unverified one would make impersonation a feature.
has(flBody, '@someone.example', 'a verified handle is shown')
not(flBody, '@null', 'an unverified one falls back to the DID, never a claimed handle')
has(followListPage({ did: 'did:plc:x', accounts: [{ did: 'did:plc:y', displayName: '<script>bad</script>' }] }),
  '&lt;script&gt;', 'display names are escaped')

// ── reach badges ────────────────────────────────────────────────────────────────────────────────
// Every public post already reaches bsky AND the fediverse from one record; the client never said so.
// The load-bearing property is that OBSERVED and ELIGIBLE do not render alike.
{
  const mk = (t, rkey) => ({ did: 'did:plc:a', rkey, createdAt: '2026-08-05T00:00:00Z', record: { $type: t, text: 'x' } })
  const ps = [mk('app.bsky.feed.post', 'r1'), mk('app.bsky.feed.post', 'r2'), mk('net.favela.local.post', 'r3')]
  withReach(ps, new Set(['r1']))
  ok(ps[0].reach.length === 2, 'a public post in the outbox reaches bsky AND the fediverse')
  ok(ps[1].reach.length === 1, 'a public post NOT in the outbox claims only bsky')
  ok(ps[2].reach.length === 0, 'a local-only post claims neither')

  const h = ps.map(renderPost).join('')
  ok((h.match(/badge reach/g) || []).length === 3, 'three reach badges across the three posts')
  // bsky is ELIGIBLE (we know the lexicon is indexed; we did NOT check that it landed), fediverse is
  // OBSERVED (the outbox names it). Rendering them identically would claim something unverified.
  ok((h.match(/badge reach weak/g) || []).length === 2, 'the two ELIGIBLE bsky badges render weaker')
  not(h, '>favela<', 'favela is never badged — true of every post, so it carries no information')

  // NULL is unknown, not "no". A failed HTTP call must not render as an absence.
  const u = [mk('app.bsky.feed.post', 'r9')]
  withReach(u, null)
  ok(u[0].reach.length === 1 && u[0].reach[0].tier === 'bsky',
    'an unreachable AP side yields NO fediverse claim either way — silence, not a denial')
  has(renderReach({ reach: [{ tier: 'fediverse', state: 'observed' }] }), 'outbox', 'the title says WHY we claim it')
  t_is_empty(renderReach({}), 'a post with no reach renders nothing')
}
function t_is_empty (s, m) { ok(s === '', m) }

// ── the composer must name the identity it will post AS ───────────────────────────────────────────
//
// ⛔ THIS COST A POST GOING OUT AS THE WRONG IDENTITY (2026-08-18). A favela node hosts MANY DIDs — your
// own plus every co-owned community — and the composer named none of them: its only context was the
// placeholder "post (relays to your hpp, which signs)". The page header carries `whoami`, but that is a
// different question ("who is signed in") asked in a different corner of the page, and on a handset with
// no handle resolved it rendered 32 characters of raw DID. Someone posted believing they were writing as
// a community and it went out as their personal identity, to bsky, irreversibly.
//
// A control that writes to one of N identities must say which one, next to the control.
{
  const withHandle = { did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: 'loser.club' }
  const c1 = timelinePage({ result: { posts: [] }, viewer: withHandle, canWrite: true })
  has(c1, 'posting as', 'the composer says who it will post as')
  has(c1, 'loser.club', 'and names the identity by handle when there is one')

  // A handle is frequently null (the phone logs handle:null on every boot), so the fallback is the one
  // that actually ships. It must be READABLE — a raw did:plc is 32 characters of base32 and reads as
  // decoration, which is exactly how it got ignored.
  const noHandle = { did: 'did:plc:wj62ebbusln6gxo5azo6ogzo' }
  const c2 = timelinePage({ result: { posts: [] }, viewer: noHandle, canWrite: true })
  has(c2, 'posting as', 'still says it with no handle')
  has(c2, shortDid('did:plc:wj62ebbusln6gxo5azo6ogzo'), 'falling back to the SHORT did, not the full string')
  not(c2, 'did:plc:wj62ebbusln6gxo5azo6ogzo<', 'never the raw 32-char did as the label')

  // The identity line belongs to the composer, not the page: no composer, no claim about posting.
  not(timelinePage({ result: { posts: [] }, viewer: withHandle, canWrite: false }), 'posting as',
    'a reader who cannot write is told nothing about posting')
}

// ── a reader must be told when the view may be out of date ────────────────────────────────────────
//
// ⛔ THE STATE NOBODY RENDERS. This node is designed to be INTERMITTENTLY ONLINE — a phone that Android
// suspends, a laptop that closes — and every page renders identically whether the index advanced a
// second ago or froze 42 hours ago. On 2026-08-18 the handset's node was dead for 68 minutes and the UI
// said nothing: a timeline that had simply stopped moving, which reads as "quiet day".
//
// frontsDiag has computed the answer since the ashatron freeze (onHighestRev / behindBy / live per
// front). It was only ever rendered on /status, which a reader never opens. This puts the one sentence a
// reader needs on the page they are actually on, and links to /status for the why.
//
// ⛔ IT CAN ONLY EVER SAY "MAY BE BEHIND". Currency is not provable — the freshest front we can SEE may
// not be the freshest that exists — so there is no "up to date" branch, and no news renders nothing.
{
  const front = (over = {}) => ({ firehoseKey: 'aa'.repeat(16), short: 'aaaaaaaaaaaa', live: true, verified: true, rev: '3mt', firehoseLength: 10, chosen: true, ...over })
  const did = (over = {}) => ({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', frontCount: 1, chosen: 'aaaaaaaaaaaa', onHighestRev: true, behindBy: 0, fronts: [front()], ...over })

  t_is_empty(freshnessNote(null), 'no diagnostic at all → no claim either way')
  t_is_empty(freshnessNote({ dids: [] }), 'nothing hosted → nothing to say')
  t_is_empty(freshnessNote({ dids: [did()] }), 'on the best line we can see → SILENCE, never "up to date"')

  // Nobody serving: the exact case the handset hit — the DID is tracked, no peer answered.
  const dead = freshnessNote({ dids: [did({ fronts: [front({ live: false })] })] })
  has(dead, 'out of date', 'no live peer → the reader is told the view may be stale')
  has(dead, '/status', 'and pointed at where the detail already lives')

  // Behind on the line we are on: the freeze signature, in a reader's words.
  const behind = freshnessNote({ dids: [did({ onHighestRev: false, behindBy: 7 })] })
  has(behind, '7', 'behind by N is stated as a number, not "syncing"')
  has(behind, 'behind', 'and named as being behind')

  // Mixed: one healthy, one dead — must not be silenced by the healthy one.
  const mixed = freshnessNote({ dids: [did(), did({ did: 'did:plc:other', fronts: [front({ live: false })] })] })
  has(mixed, 'out of date', 'one unreachable identity is enough to warn')

  // And it reaches the actual page, above the fold, only when there is something to say.
  const p = timelinePage({ result: { posts: [] }, freshness: { dids: [did({ fronts: [front({ live: false })] })] } })
  has(p, 'class="stale"', 'the banner renders on the timeline page')
  not(timelinePage({ result: { posts: [] } }), 'class="stale"', 'and never when no freshness was supplied')
}

// ── app updates (handset only) ───────────────────────────────────────────────────────────────────
// The controls exist because staging and applying are separate: an update discovered at boot N can only
// run at boot N+1, and with a 6h poll and no restart button the phone can only become current by
// accident. Measured 2026-08-24: a handset ran a bundle whose replacement had been staged for half an
// hour with no user-reachable way to apply it.
{
  const H = 'eaa75a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'
  const N = 'ffff5a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'

  // ⛔ The shell (an APK) and the worklet bundle (OTA) version independently, so a new bundle can find
  // itself inside an old shell with no message handler. Buttons that silently do nothing are worse than
  // no buttons — hence a capability claim, not a guess.
  ok(appUpdateSection(null) === '', 'no shell => no section at all (this is also every server)')
  ok(appUpdateSection({ ota: false, running: H }) === '', 'a shell that cannot service the buttons gets none')

  const staged = appUpdateSection({ ota: true, running: H, staged: N, checkedAt: '2026-08-24T21:41:31.000Z' })
  has(staged, 'An update is staged', 'a genuinely newer bundle is announced')
  has(staged, 'data-ota="restart"', 'and the restart that applies it is right there')
  has(staged, 'data-ota="check"', 'checking is not gated on anything')

  // ⛔ THE BUG THIS PAGE MUST NOT REPEAT. The OTA log announced "staged … — active on next start" naming
  // the bundle already running, because currentTreeHash was never passed. Rendering that as a pending
  // update would put the same lie on a screen.
  const same = appUpdateSection({ ota: true, running: H, staged: H })
  has(same, 'No update staged', 'staged === running is not an update')
  not(same, 'An update is staged', 'and is never announced as one')

  const apk = appUpdateSection({ ota: true, running: null, staged: null })
  has(apk, 'the bundle shipped in the app', 'no OTA bundle yet reads as the APK’s own, not as unknown')
  not(appUpdateSection({ ota: true, running: H }), 'last checked', 'no check yet => no invented timestamp')

  // It reaches the real page, signed in AND signed out — a restart is what repairs a session this page
  // has forgotten, so gating it on being signed in would hide it exactly when it is needed.
  const shell = { ota: true, running: H, staged: N }
  has(settingsPage({ viewer: null, shell }), 'App updates', 'the section renders signed out')
  has(settingsPage({ viewer: { did: 'did:plc:alice' }, shell }), 'App updates', 'and signed in')
  not(settingsPage({ viewer: null }), 'App updates', 'and never without a shell — this is a phone-only control')

  // ── pairing, which used to be a floating button over the bottom nav ────────────────────────────
  // ⛔ ITS OWN CAPABILITY, not shell.ota. The page arrives by OTA and the shell by APK, so a bundle can
  // land on a shell that services update buttons and has never heard of a pair op.
  ok(pairSection(null) === '', 'no shell => no pairing control (this is also every server)')
  ok(pairSection({ ota: true }) === '', 'an update-capable shell is not assumed to be pair-capable')
  const ps = pairSection({ ota: true, pair: true })
  has(ps, 'Connect this phone', 'a pair-capable shell gets the control')
  has(ps, 'data-ota="pair"', 'and it posts the pair op through the bridge')
  // The ceremony hands over the DID's signing key; the page must say so before opening the screen that
  // says it in full. Copy that softens this is a security regression, not a wording preference.
  has(ps, 'key that signs your posts', 'the page states what the ceremony grants')
  has(settingsPage({ viewer: null, shell: { ota: true, pair: true } }), 'Connect this phone', 'reachable signed out — a fresh phone is not signed in yet')
  not(settingsPage({ viewer: { did: 'did:plc:alice' }, shell: { ota: true } }), 'Connect this phone', 'and absent when the shell cannot service it')
}

console.log(`\nRENDER OK: ${pass} assertions`)


// ── the lens setting, which used to be reachable only by editing apps.json ────────────────────────
// A node-level trust decision that needs shell access is a deployment detail, not a setting — and on a
// handset there is no apps.json at all. The visible consequence was a bsky account rendering a real name
// and avatar with "no posts from this author" (hyper-social #53).
{
  const on = { enabled: true, lenses: [{ name: 'bsky', base: 'https://pds.7rnx.net' }] }

  // ⛔ Repointing where a node sends the DIDs it looks up is an OPERATOR act. On a public instance a
  // signed-in guest must never see this, and the page must not offer a control /api/lens would refuse.
  ok(lensControl({ lens: on, isOperator: false }) === '', 'a viewer who is not this node\'s identity sees nothing')
  ok(lensControl({ lens: on }) === '', 'and absent by default — not shown unless proven operator')

  const shown = lensControl({ lens: on, isOperator: true })
  has(shown, 'Remote reading', 'the operator gets the control')
  has(shown, 'https://pds.7rnx.net', 'with the current bridge filled in, so saving does not blank it')
  has(shown, 'action="/api/lens"', 'posting to the route that applies it')
  has(shown, 'method="POST"', 'a plain form — works with scripting off')

  const off = lensControl({ lens: { enabled: false, lenses: [] }, isOperator: true })
  has(off, 'Off.', 'the off state says so')
  has(off, 'empty profile', 'and names the consequence rather than leaving it implied')

  // It reaches the real page, and only for the operator.
  has(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: true }), 'Remote reading', 'rendered on the settings page')
  not(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: false }), 'Remote reading', 'and never for a guest')
}