OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 26b8182

theme: an explicit light/dark preference, which is also the Android dark-mode fix
Reported: dark mode does not work on Android, and there is no toggle in settings. Those are one bug. The theme was decided ENTIRELY by prefers-color-scheme. 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 — its manifest sets Theme.AppCompat.DayNight for the native shell and nothing configures the WebView. So the page has always been told "light" whatever the handset is set to. It never worked; it did not stop working. An explicit preference does not consult the query at all, so the toggle and the Android fix are the same change. THREE states — system (no attribute), light, dark — where "system" is the ABSENCE of a stored value, so a reader who never touches this is in exactly the state everyone was in before. :root light tokens @media(dark) :root:not([data-theme=light]) system dark, unless overridden :root[data-theme=dark] explicit dark, wins over a light system Verified all six cells of the matrix (three settings x two system preferences); the two that matter are explicit-dark under a light system and explicit-light under a dark one, both correct. Written SERVER-SIDE from a cookie, so the first paint is already right — a class flipped by script would flash the wrong theme on every navigation and would never run with scripting off. Applied in the html() response helper rather than threaded through a dozen page functions, since it is one attribute and every HTML response already funnels through there; the value is stamped on `res`, which is per-request. Two placement decisions: /api/theme is in OPEN_POST. Not because it establishes a session, but because it writes nothing server-side and must work on a READ-ONLY LENS INSTANCE — the first thing the write gate does is 403 those, which would have made the public site the one place you could not choose a theme. The control renders SIGNED OUT too. A theme is a property of the reader's eyes, not of an account, and the public instance serves mostly anonymous readers. Only the token block is duplicated per theme: the four rules that used to sit beside it now read from --bg-input and --on-accent. Same reason the hand-written dark patch was deleted earlier — one place per theme, not one per component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26b8182250f01a1a391a1fade1f5028ebf1c5815
by plankatron <fleeky@gmail.com> · 2026-08-24T21:24:08.000Z

Changed files (3) +146 -21

bin/favela-web.js +55 -4
……@@ -1052,8 +1052,12 @@ const CLEAR_COOKIE = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
10521052// Write-gate tiers (see the gate itself, at the top of the request handler). Listed here so the
10531053// classification of a route is one grep away, not buried mid-file.
10541054//
1055// OPEN: these ESTABLISH a session — gating them on having one would be circular.
1056const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logout', '/api/auth/challenge'])
1055// OPEN: no session required. The auth routes ESTABLISH one, so gating them on having one would be
1056// circular. /api/theme is here for a different reason: it writes NOTHING server-side — its only
1057// effect is a cookie in the caller's own browser — and it must work on a read-only lens instance,
1058// which is the first thing the gate below refuses. A reader who is not signed in still gets to
1059// choose dark.
1060const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logout', '/api/auth/challenge', '/api/theme'])
10571061// IDENTITY: self-scoped or node-local. /api/devices/* act on the caller's OWN delegated sub-keys;
10581062// track/bookmark/mute mutate this node's local index and stores only — nothing is signed into a repo,
10591063// so repo ownership is not the relevant question, but anonymity still is.
……@@ -1064,7 +1068,30 @@ const IDENTITY_POST = new Set([
10641068// Everything else POSTed authors a record (or a blob) into the repo and falls through to the
10651069// repo-owner check. Deliberately NOT enumerated: an unlisted route must fail closed.
10661070
1067const html = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...(extra || {}) }); res.end(body) }
1071// Theme is applied HERE rather than threaded through a dozen page functions, because it affects
1072// exactly one attribute on <html> and every HTML response already funnels through this helper. The
1073// value is stamped on `res` by the request handler below — the response object is per-request, so
1074// there is no shared state to race.
1075//
1076// Server-side, so the first paint is correct. A script that flips a class after load would flash the
1077// wrong theme on every navigation and would never run at all with scripting off.
1078const html = (res, status, body, extra) => {
1079 const t = res._theme
1080 const themed = (t === 'dark' || t === 'light') && typeof body === 'string'
1081 ? body.replace('<html lang="en">', `<html lang="en" data-theme="${t}">`)
1082 : body
1083 res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...(extra || {}) })
1084 res.end(themed)
1085}
1086// system | light | dark. Absent or unrecognised means system, which is what everyone had before this
1087// existed, so an old cookie or a hand-edited one degrades to the previous behaviour rather than to a
1088// broken page.
1089const THEMES = new Set(['light', 'dark'])
1090function themeOf (req) {
1091 const raw = (req.headers && req.headers.cookie) || ''
1092 const m = raw.match(/(?:^|;\s*)favela_theme=([a-z]+)/)
1093 return m && THEMES.has(m[1]) ? m[1] : null
1094}
10681095const json = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'application/json', ...(extra || {}) }); res.end(body) }
10691096const xml = (res, status, body, type) => { res.writeHead(status, { 'content-type': type }); res.end(body) }
10701097const redirect = (res, to, extra) => { res.writeHead(303, { location: to, ...(extra || {}) }); res.end() }
……@@ -1073,6 +1100,7 @@ const server = http.createServer(async (req, res) => {
10731100 const url = new URL(req.url, 'http://localhost')
10741101 const path = url.pathname
10751102 const viewer = viewerOf(req) // { did, subKey } from the session cookie/Bearer, or null (anonymous)
1103 res._theme = themeOf(req) // read once per request; html() applies it to whatever page is rendered
10761104
10771105 // ── WRITE GATE — one choke point, default-deny ──────────────────────────────────────────────
10781106 //
……@@ -1303,7 +1331,7 @@ const server = http.createServer(async (req, res) => {
13031331 // (pair / revoke) are progressive enhancement, matching how the avatar section already works.
13041332 let devices = []
13051333 if (viewer) { try { devices = await writer.listDevices() } catch { devices = [] } }
1306 return html(res, 200, settingsPage({ viewer, profile, did, devices }))
1334 return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme }))
13071335 }
13081336 if (path === '/login') {
13091337 // Sign-in UI: the client script (/static/login.js) generates the browser's secp256k1 sub-key,
……@@ -2413,6 +2441,29 @@ const server = http.createServer(async (req, res) => {
24132441 // MUTE — private and NODE-LOCAL. Never a record: in atproto a mute is server-side state, so writing
24142442 // one to the repo would publish "alice muted bob" to the network permanently, in a signed record.
24152443 // Nothing here touches hpp or the repo at all.
2444 // Theme preference. Writes nothing but a cookie — no repo, no index, no session. A plain form POST
2445 // with a redirect so it works with scripting off, which is the whole point: the no-JS path is the
2446 // one that has to carry this, because a script-flipped class cannot fix the first paint.
2447 //
2448 // NOT HttpOnly, deliberately, unlike the session cookie: this is a display preference, and leaving
2449 // it readable lets a future client-side control stay in sync without a round trip. It carries no
2450 // authority, so there is nothing to steal.
2451 if (req.method === 'POST' && path === '/api/theme') {
2452 const isForm = !(req.headers['content-type'] || '').includes('application/json')
2453 const raw = (await readBody(req)) || ''
2454 let choice, back
2455 try {
2456 if (isForm) { const q = new URLSearchParams(raw); choice = q.get('theme'); back = q.get('return') }
2457 else { const b = JSON.parse(raw || '{}'); choice = b.theme; back = b.return }
2458 } catch { choice = null }
2459 const home = safeReturn(back) || '/settings'
2460 // 'system' clears the cookie rather than storing a third value, so "follow the system" is the
2461 // ABSENCE of a preference — the same state as a reader who never touched this.
2462 const cookie = THEMES.has(choice)
2463 ? `favela_theme=${choice}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 3600}`
2464 : 'favela_theme=; Path=/; SameSite=Lax; Max-Age=0'
2465 return redirect(res, home, { 'set-cookie': cookie })
2466 }
24162467 if (req.method === 'POST' && path === '/api/mute') {
24172468 const isForm = !(req.headers['content-type'] || '').includes('application/json')
24182469 const raw = (await readBody(req)) || ''
lib/render.js +90 -16
……@@ -810,6 +810,9 @@ a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,
810810 --fg:#111; --fg-dim:#5c6672; --fg-faint:#6b7480;
811811 --bg:#fff; --bg-soft:#f7f8fa; --line:#e6e8eb; --line-soft:#f0f1f3;
812812 --accent:#1a56db; --like:#e0245e; --repost:#17bf63;
813 /* Paired with --accent and with a text input's background, so the theme block only has to restate
814 tokens rather than restate rules. */
815 --bg-input:#fff; --on-accent:#fff;
813816 /* Semantic colours. These existed only as literals, which is why the dark patch below had to
814817 hand-repaint every element that used one. Measured on --bg #fff: --danger-text 6.47:1 and --lens
815818 5.67:1 pass AA for text. Two were corrected on the way in rather than tokenising a known-failing
……@@ -864,6 +867,19 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
864867/* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between
865868 them is padding INSIDE a target, not dead space between two small ones. */
866869.meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)}
870/* ── appearance control ────────────────────────────────────────────────────────────────────────────
871 A segmented row of submit buttons, one per state. Buttons rather than a select because the current
872 value should be visible without opening anything, and 44px because this is a touch control like any
873 other. */
874.themesec{margin:0 0 var(--s5)}
875.sech{font-size:var(--f2);margin:0 0 var(--s2)}
876.themeform{display:flex;gap:0;border:1px solid var(--line);border-radius:999px;overflow:hidden;width:fit-content}
877.themebtn{font:inherit;font-size:var(--f1);min-height:44px;padding:0 var(--s4);border:0;
878 background:var(--bg);color:var(--fg-dim);cursor:pointer}
879.themebtn+.themebtn{border-left:1px solid var(--line)}
880.themebtn:hover{background:var(--bg-soft);color:var(--fg)}
881.themebtn.on{background:var(--accent);color:var(--on-accent);font-weight:600}
882.themenote{font-size:var(--f0);color:var(--fg-faint);margin:var(--s2) 0 0;max-width:44ch}
867883/* ── composer ──────────────────────────────────────────────────────────────────────────────────────
868884 The composer was ~40% of the first screen on a phone, permanently expanded, with four gate selects
869885 competing with the timeline before a reader had seen a single post. The gates move behind a native
……@@ -935,20 +951,47 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
935951 .media.multi{gap:var(--s1)}
936952 .meta{gap:var(--s2)}
937953}
954/* ── theme ─────────────────────────────────────────────────────────────────────────────────────────
955 THREE states, not two: system (no attribute), explicit light, explicit dark.
956
957 It cannot be prefers-color-scheme alone. Android's WebView does not report that query truthfully to
958 page content unless the app opts in (setAlgorithmicDarkeningAllowed, or the deprecated
959 setForceDark), and favela-android does neither — so the phone has ALWAYS been told "light" whatever
960 the handset is set to. An explicit attribute does not consult the query at all, which is why the
961 settings toggle and the Android bug are one fix.
962
963 The attribute is written server-side from a cookie, so the first paint is already correct — a
964 class flipped by script would flash white on every navigation.
965
966 Only the token block is duplicated. The rules that used to live in here alongside it now read from
967 --bg-input and --on-accent, so there is one place per theme rather than one place per component —
968 the same reason the hand-written dark patch was deleted earlier. */
938969@media(prefers-color-scheme:dark){
939 :root{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
940 --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff;
941 /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1.
942 --danger keeps its light value because it is a fixed button background that white sits on
943 (5.46:1) rather than something read against the page. */
944 --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e;
945 --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f;
946 --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f}
947 .post .media img{border-color:var(--line)}
948 .composer textarea{background:#0e0f12;color:var(--fg)}
949 #post-btn{background:var(--accent);color:#0b0d10}
950 .more:hover{background:var(--bg-soft)}
970 /* System dark, unless the reader explicitly chose light. */
971 :root:not([data-theme="light"]){--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
972 --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff;
973 --bg-input:#0e0f12;--on-accent:#0b0d10;
974 /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1.
975 --danger keeps its light value because it is a fixed button background that white sits on
976 (5.46:1) rather than something read against the page. */
977 --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e;
978 --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f;
979 --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f}
951980}
981/* Explicit dark wins in BOTH directions: it must also override a light system preference. */
982:root[data-theme="dark"]{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f;
983 --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff;
984 --bg-input:#0e0f12;--on-accent:#0b0d10;
985 /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1.
986 --danger keeps its light value because it is a fixed button background that white sits on
987 (5.46:1) rather than something read against the page. */
988 --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e;
989 --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f;
990 --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f}
991.post .media img{border-color:var(--line)}
992.composer textarea{background:var(--bg-input);color:var(--fg)}
993#post-btn{background:var(--accent);color:var(--on-accent)}
994.more:hover{background:var(--bg-soft)}
952995
953996/* ── error surface ────────────────────────────────────────────────────────────────────────────────
954997 A failed write used to be a 1.2s flash plus a title attribute, which needs a hover — so on touch it did not
……@@ -1084,9 +1127,34 @@ ${lists.map((l) => `<option value="${esc(l.uri)}">only: ${esc(l.name)}</option>`
10841127// Settings: the avatar. This exists because a lost avatar is otherwise unfixable — the blob bytes are
10851128// gone from every node, so nothing can restore it except the owner re-uploading. Doing so also lands
10861129// the first blob in the content-addressed OverlayBlobStore, so it migrates as a side effect.
1087export function settingsPage ({ viewer = null, profile = null, did = null, devices = [] } = {}) {
1130// Appearance. Deliberately available SIGNED OUT as well: a theme is a property of the reader's eyes,
1131// not of an account, and the public instance serves mostly anonymous readers.
1132//
1133// Three states, and "system" is the ABSENCE of a preference rather than a third stored value — so a
1134// reader who never touches this is in exactly the state everyone was in before it existed.
1135//
1136// A plain form with a submit per option: no JS, one tap, and it works in the Android WebView, which is
1137// the case that prompted it. `return` sends you back where you were.
1138export function themeControl (theme = null, returnTo = '/settings') {
1139 const opts = [
1140 ['system', 'System', 'follow the device setting'],
1141 ['light', 'Light', ''],
1142 ['dark', 'Dark', '']
1143 ]
1144 const cur = (theme === 'light' || theme === 'dark') ? theme : 'system'
1145 const buttons = opts.map(([v, label, hint]) => {
1146 const on = v === cur
1147 return `<button class="themebtn${on ? ' on' : ''}" name="theme" value="${v}"${on ? ' aria-current="true"' : ''}${hint ? ` title="${esc(hint)}"` : ''}>${esc(label)}</button>`
1148 }).join('')
1149 return `<section class="themesec"><h2 class="sech">Appearance</h2>
1150<form class="themeform" method="POST" action="/api/theme"><input type="hidden" name="return" value="${esc(returnTo)}">${buttons}</form>
1151<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>`
1152}
1153
1154export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null } = {}) {
10881155 if (!viewer) {
1089 return page({ title: 'settings · favela', viewer, active: null, body: '<div class="viewhdr">Settings <a href="/">← back</a></div><p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
1156 // Appearance still renders here — it is not an account setting.
1157 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
10901158 }
10911159 const cid = profile && profile.avatar && blobCid(profile.avatar.ref)
10921160 const current = (cid && did && isSafeCid(cid))
……@@ -1107,7 +1175,9 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic
11071175 title: 'settings · favela',
11081176 viewer,
11091177 active: null,
1178 theme,
11101179 body: `<div class="viewhdr">Settings <a href="/">← back</a></div>
1180${themeControl(theme)}
11111181<section class="authbox">
11121182 <h3>Profile picture</h3>
11131183 <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p>
……@@ -1304,8 +1374,12 @@ const POSTCLICK = `<script>
13041374
13051375// Full HTML document. `client` (optional) is the progressive-enhancement <script> src/inline appended
13061376// before </body>; with it absent the page is fully functional server-rendered.
1307export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null } = {}) {
1308 return `<!doctype html><html lang="en"><head><meta charset="utf-8">
1377export 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 } = {}) {
1378 // Written server-side so the FIRST paint is already correct. A class flipped by script would flash
1379 // the wrong theme on every navigation, and with scripting off would never apply at all. Absent
1380 // attribute = follow the system, which is the default and the only state that existed before.
1381 const themeAttr = (theme === 'dark' || theme === 'light') ? ` data-theme="${theme}"` : ''
1382 return `<!doctype html><html lang="en"${themeAttr}><head><meta charset="utf-8">
13091383<meta name="viewport" content="width=device-width,initial-scale=1">
13101384<title>${esc(title)}</title>
13111385<link rel="alternate" type="application/atom+xml" title="favela timeline" href="/feed.xml">
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.74",
3 "version": "0.3.75",
44 "description": "favela-web \u2014 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",