OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 8e08e32

0.3.81: seat the bottom bar, and take pairing off the overlay
Both reported from a handset today, and they are the same corner of the screen. 1. THE BAR FLOATED ABOVE THE BOTTOM The meta viewport had no `viewport-fit=cover`, and safe-area insets are INERT without it — they compute to 0. `.bottomnav` already had `padding-bottom:env(safe-area-inset-bottom,0)` and body already reserved `calc(64px + env(safe-area-inset-bottom,0))`, so both rules were written expecting insets that never arrived. The WebView paints edge-to-edge while the layout viewport stops at the safe area, so the bar anchored above the gesture strip with page content sliding underneath it. The rules were right; this is the one token that switches them on. 2. PAIRING IS A SETTINGS ROW, NOT A FLOATING BUTTON favela-android put a small `pair` pill at left:10/bottom:14 — which is exactly where this bar later put its Home item. Tapping Home opened the pairing screen, which reads as "the app wants me to connect this phone" and was reported as exactly that. favela-android's own note said the button "goes away when pairing moves into favela-web", and the bridge shipped yesterday makes that possible: pairSection renders a Connect-this-phone control that posts a `pair` op, and the native screen — which still owns the ceremony, because its wording is part of the security model — opens from there. ⛔ GATED ON `shell.pair`, 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. One flag for both would render a dead button on precisely that pairing. Same reasoning that made `ota` a capability claim rather than an assumption. The control renders signed OUT as well: a phone that has not paired yet has no session, and putting the pairing control behind sign-in would hide it exactly when it is needed. Suite green (413 render assertions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8e08e3213099fc0bd0dddc5c2c4b1a1a07203745
by plankatron <fleeky@gmail.com> · 2026-08-25T16:43:17.000Z

Changed files (3) +65 -4

lib/render.js +49 -2
……@@ -1251,12 +1251,51 @@ document.querySelectorAll('[data-ota]').forEach(function (b) {
12511251</section>`
12521252}
12531253
1254// Pairing, on a handset, from the page instead of an overlay.
1255//
1256// ⛔ WHAT THIS OPENS IS NOT A LOGIN. The ceremony hands this phone the DID's SHARED SIGNING KEY, after
1257// which it authors as that identity on its own, offline — and removing a device later stops it being
1258// ADMITTED but cannot un-know a key it already holds. The full warning lives on the native screen this
1259// button opens (app/Pair.jsx, where the wording is explicitly part of the security model), so this says
1260// enough to set expectations and does not paraphrase it into something softer.
1261//
1262// It lives here because it always should have: favela-android's own note says "the old UI reached
1263// pairing from settings for exactly this reason", and it only became a floating button when the native
1264// settings screen went away with the move to a WebView. On the handset that overlay sat on top of the
1265// bottom nav's Home item, so tapping Home opened the pairing screen — reported 2026-08-25.
1266//
1267// Gated on `shell.pair`, a SEPARATE capability from `shell.ota`: the page arrives by OTA and the shell
1268// by APK, so a page that lands on a shell which cannot service this must render nothing at all rather
1269// than a button that silently does nothing.
1270export function pairSection (shell = null) {
1271 if (!shell || !shell.pair) return ''
1272 return `<section class="authbox pairsec">
1273 <h3>Connect this phone</h3>
1274 <p class="empty">Paste an invite minted by a device that already holds your identity — favela's
1275 <em>Generate a device invite</em>, above or on your desktop. Use this when setting the phone up, or
1276 when it holds your identity but posts are being refused because it is not on the room's member list.</p>
1277 <p class="empty"><strong>It receives the key that signs your posts.</strong> From then on this phone can
1278 post as you, by itself. The next screen says so in full before anything happens.</p>
1279 <p><button class="devbtn" data-ota="pair">open the pairing screen</button></p>
1280<script>
1281document.querySelectorAll('[data-ota="pair"]').forEach(function (b) {
1282 b.addEventListener('click', function () {
1283 var bridge = window.ReactNativeWebView
1284 if (!bridge) return
1285 b.disabled = true
1286 bridge.postMessage(JSON.stringify({ t: 'app-update', op: 'pair' }))
1287 })
1288})
1289</script>
1290</section>`
1291}
1292
12541293export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null } = {}) {
12551294 if (!viewer) {
12561295 // Appearance still renders here — it is not an account setting. So does App updates: a restart is
12571296 // what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when
12581297 // it is needed.
1259 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
1298 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) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
12601299 }
12611300 const cid = profile && profile.avatar && blobCid(profile.avatar.ref)
12621301 const current = (cid && did && isSafeCid(cid))
……@@ -1282,6 +1321,7 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic
12821321${themeControl(theme)}
12831322${versionNote()}
12841323${appUpdateSection(shell)}
1324${pairSection(shell)}
12851325<section class="authbox">
12861326 <h3>Profile picture</h3>
12871327 <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p>
……@@ -1522,7 +1562,14 @@ export function page ({ title = 'favela', heading, viewer = null, active = 'time
15221562 // attribute = follow the system, which is the default and the only state that existed before.
15231563 const themeAttr = (theme === 'dark' || theme === 'light') ? ` data-theme="${theme}"` : ''
15241564 return `<!doctype html><html lang="en"${themeAttr}><head><meta charset="utf-8">
1525<meta name="viewport" content="width=device-width,initial-scale=1">
1565<!-- viewport-fit=cover is what makes env(safe-area-inset-*) resolve to anything. Without it those
1566 insets are INERT — they compute to 0 — and .bottomnav's padding-bottom plus body's
1567 calc(64px + env(safe-area-inset-bottom)) were both written expecting them to work. On the handset
1568 the effect was a bottom bar floating ABOVE the gesture strip with page content sliding underneath
1569 it, because the WebView paints edge-to-edge while the layout viewport stops at the safe area.
1570 Observed on a moto g power, 2026-08-25. The rules were already correct; this is the token that
1571 switches them on. -->
1572<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
15261573<title>${esc(title)}</title>
15271574<link rel="alternate" type="application/atom+xml" title="favela timeline" href="/feed.xml">
15281575<meta name="favela-pds" content="${esc(pds)}">
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.80",
3 "version": "0.3.81",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
test/render.test.mjs +15 -1
……@@ -1,5 +1,5 @@
11// favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs
2import { 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'
2import { pairSection, 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'
33import { SCOPE_COLLECTION } from '../lib/scopes.js'
44
55let pass = 0
……@@ -954,6 +954,20 @@ function t_is_empty (s, m) { ok(s === '', m) }
954954 has(settingsPage({ viewer: null, shell }), 'App updates', 'the section renders signed out')
955955 has(settingsPage({ viewer: { did: 'did:plc:alice' }, shell }), 'App updates', 'and signed in')
956956 not(settingsPage({ viewer: null }), 'App updates', 'and never without a shell — this is a phone-only control')
957
958 // ── pairing, which used to be a floating button over the bottom nav ────────────────────────────
959 // ⛔ ITS OWN CAPABILITY, not shell.ota. The page arrives by OTA and the shell by APK, so a bundle can
960 // land on a shell that services update buttons and has never heard of a pair op.
961 ok(pairSection(null) === '', 'no shell => no pairing control (this is also every server)')
962 ok(pairSection({ ota: true }) === '', 'an update-capable shell is not assumed to be pair-capable')
963 const ps = pairSection({ ota: true, pair: true })
964 has(ps, 'Connect this phone', 'a pair-capable shell gets the control')
965 has(ps, 'data-ota="pair"', 'and it posts the pair op through the bridge')
966 // The ceremony hands over the DID's signing key; the page must say so before opening the screen that
967 // says it in full. Copy that softens this is a security regression, not a wording preference.
968 has(ps, 'key that signs your posts', 'the page states what the ceremony grants')
969 has(settingsPage({ viewer: null, shell: { ota: true, pair: true } }), 'Connect this phone', 'reachable signed out — a fresh phone is not signed in yet')
970 not(settingsPage({ viewer: { did: 'did:plc:alice' }, shell: { ota: true } }), 'Connect this phone', 'and absent when the shell cannot service it')
957971}
958972
959973console.log(`\nRENDER OK: ${pass} assertions`)