OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 3bb33ff

0.0.52: a namespace list you can act on, and an update notice you cannot miss
TWO THINGS THE PAGE PROMISED AND COULD NOT DELIVER. ⛔ The join form has always said "the one you are in now stays on disk — you can come back to it, handles and all". True, and unreachable: nothing listed what was on disk, so coming back meant having written down 64 hex characters. The namespaces this node holds are now listed, with switch and forget. Forget drops a LIST ENTRY, never a log — the daemon refuses the current one and refuses a founded one, and the page says the claims stay put, because "forget" reads like deletion and is not. ⛔ And an update could be downloaded and waiting with nothing anywhere saying so unless you happened to open settings. `withUpdateBanner` is applied at SEND time exactly like `withTheme`, so no page function knows it exists and none can forget it — a notice that appears only where someone remembered to put it is a notice nobody sees on the page they were actually on. ⛔ STAGED-AND-DIFFERENT ONLY. Staged and identical means the node re-verified the bundle it is already running, which is the ordinary case; announcing that as an update trains people to ignore the banner, and then the real one does nothing. Pinned by tests, both directions. ⚠ It links to settings rather than restarting from the banner. Restarting is something the SHELL does and only the shell knows whether it can — a banner offering a button the shell cannot honour is the defect `parseShell` keeps separate `ota`/`pair` flags to avoid. ⚠ Read per request rather than cached: a node that stages an update while someone is reading should say so on their next navigation. ⛔ Also fixed: my own edit dropped a `+` from the join form's concatenation, which silently discarded the rest of the form. `test/pages.test.js` caught it by asserting the page says "stays on disk". 181/181 tests, 590/590 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3bb33ff54af17834d47a6d40ccccad97cc5cddde
by plankatron <fleeky@gmail.com> · 2026-09-10T15:20:57.000Z

Changed files (4) +107 -8

lib/pages.js +57 -4
……@@ -23,6 +23,30 @@ export function withTheme (html, theme) {
2323 return html.replace('<html lang="en">', '<html lang="en" data-theme="' + theme + '">')
2424}
2525
26/**
27 * A banner on EVERY page when an update is downloaded and waiting.
28 *
29 * ⛔ APPLIED AT SEND TIME, exactly like `withTheme`, so no page function has to know this exists and
30 * none can forget to render it. A notice that only appears where someone remembered to put it is a
31 * notice nobody sees on the page they were actually on.
32 *
33 * ⛔ STAGED-AND-DIFFERENT ONLY. Staged and identical means the node re-verified the bundle it is
34 * already running, which is the ordinary case — announcing that as an update would train people to
35 * ignore the banner, and then the real one does nothing.
36 *
37 * ⚠ It links to settings rather than restarting from here. Restarting is a thing the SHELL does and
38 * only the shell knows whether it can; a banner that offered a button the shell cannot honour would
39 * be the same defect `parseShell` keeps separate flags to avoid.
40 */
41export function withUpdateBanner (html, update) {
42 const u = update || null
43 if (!u || !u.staged || !u.running || u.staged === u.running) return html
44 const bar = '<div class="update-bar"><a href="/settings">An update is ready — restart to apply</a></div>'
45 // After the opening <body>, so it is the first thing in the document and cannot be pushed below
46 // the fold by whatever the page happens to render first.
47 return html.includes('<body>') ? html.replace('<body>', '<body>' + bar) : bar + html
48}
49
2650export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
2751
2852const short = (k) => esc(String(k || '').slice(0, 8))
……@@ -291,6 +315,12 @@ input[type=file]::file-selector-button:hover{border-color:var(--dim);background:
291315 background:var(--bg-soft);touch-action:none;cursor:grab;overflow:hidden;position:relative}
292316/* Block-level, or the canvas sits on a text baseline and overflows the box it was sized to fit. */
293317.scene canvas{display:block}
318/* The update notice. Deliberately loud — it is the one thing on the page that expires. */
319.update-bar{background:var(--warn-bg,#8a5a00);text-align:center;padding:.5rem}
320.update-bar a{color:#fff;text-decoration:none;font-weight:600}
321.ns-list{list-style:none;padding:0}
322.ns-list li{margin:.35rem 0}
323form.inline{display:inline}
294324.explorer{display:flex;flex-direction:column;gap:8px}
295325/* ⚠ Fullscreen paints its own black behind the element, so the ground has to be declared again or
296326 a light-theme page turns into a light panel floating on black. */
……@@ -1291,7 +1321,7 @@ export function handleSettings (handle, namespace) {
12911321 * through a redirect: a secret in a query string lands in browser history and in any log that keeps
12921322 * request lines.
12931323 */
1294export function namespaceSettings (namespace, token = null) {
1324export function namespaceSettings (namespace, token = null, known = []) {
12951325 const ns = namespace || {}
12961326 if (!ns.key) return ''
12971327
……@@ -1312,7 +1342,30 @@ export function namespaceSettings (namespace, token = null) {
13121342 ? '<p class="who">Nobody else has joined yet. Share the key above.</p>'
13131343 : ''
13141344
1315 const join = '<form method="post" action="/namespace/join">' +
1345 // ⛔ THE LIST IS THE DOOR. The join form below promises the namespace you leave stays on disk and
1346 // you can come back to it; without this, coming back meant having written down 64 hex characters.
1347 // A promise nobody can act on is indistinguishable from losing the thing.
1348 const others = (known || []).filter((n) => !n.current)
1349 const held = (known || []).length > 1
1350 ? '<h3>Namespaces this node holds</h3><ul class="ns-list">' +
1351 (known || []).map((n) => {
1352 const label = '<code>' + esc(String(n.key).slice(0, 16)) + '…</code>'
1353 if (n.current) return '<li>' + label + ' <span class="who">— you are here</span></li>'
1354 return '<li>' + label +
1355 ' <form method="post" action="/namespace/join" class="inline">' +
1356 '<input type="hidden" name="key" value="' + esc(n.key) + '"><button>switch</button></form>' +
1357 // ⚠ Says what it does and what it does NOT do. "Forget" reads like deletion and is not:
1358 // the log and every claim in it stay on disk.
1359 ' <form method="post" action="/namespace/forget" class="inline">' +
1360 '<input type="hidden" name="key" value="' + esc(n.key) + '">' +
1361 '<button title="removes it from this list; the log and its claims stay on disk">forget</button></form>' +
1362 '</li>'
1363 }).join('') + '</ul>' +
1364 (others.length ? '<p class="who">Forgetting one removes it from this list only — its log and ' +
1365 'every handle in it stay on disk, and joining by key brings it back.</p>' : '')
1366 : ''
1367
1368 const join = held + '<form method="post" action="/namespace/join">' +
13161369 '<p><label>Join another namespace<br>' +
13171370 '<input type="text" name="key" placeholder="64 hex characters"></label></p>' +
13181371 // ⛔ Said plainly, because it is the one thing a person would fear: joining is not a migration
……@@ -1478,7 +1531,7 @@ const restart = document.getElementById('app-restart')
14781531if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') })
14791532`
14801533
1481export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null) {
1534export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = []) {
14821535 const p = profile || {}
14831536 const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
14841537 const shown = p.avatar
……@@ -1512,7 +1565,7 @@ export function settings (profile, message, handle = null, namespace = null, tok
15121565 '<button>save</button></form>' +
15131566 handleSettings(handle, namespace) +
15141567 themeSettings(theme) +
1515 namespaceSettings(namespace, token))
1568 namespaceSettings(namespace, token, known))
15161569}
15171570
15181571export function knocks (rows) {
lib/router.js +24 -2
……@@ -42,10 +42,13 @@ for (const f of (() => { try { return fs.readdirSync(STATIC_DIR).filter((x) => x
4242 * A WeakMap keyed by `res` is scoped to exactly one exchange and needs no cleanup.
4343 */
4444const THEME = new WeakMap()
45// ⛔ The same per-response shape as THEME, for the same reason: applied once at send time so no page
46// has to know the banner exists and none can forget it.
47const UPDATE = new WeakMap()
4548
4649const send = (res, html, status = 200) => {
4750 res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
48 res.end(pages.withTheme(html, THEME.get(res) || null))
51 res.end(pages.withUpdateBanner(pages.withTheme(html, THEME.get(res) || null), UPDATE.get(res) || null))
4952}
5053
5154// The reader's theme, or null for system. A plain cookie — no JS, and the server needs to know it
……@@ -85,6 +88,9 @@ async function settingsPage (client, message, token = null, theme = null, appUpd
8588 const me = await client.call('whoami')
8689 const mine = (await client.call('profiles'))[me.feed] || {}
8790 const ns = await client.call('namespace')
91 // ⚠ Tolerated missing, like `reach`: an older daemon has no `namespaces` command and settings must
92 // still render. An empty list makes the section absent rather than claiming this node holds none.
93 const known = (await client.call('namespaces').catch(() => ({ namespaces: [] }))).namespaces || []
8894 // Invites are a separate call: only an admin has any, and the namespace summary is fetched on
8995 // every settings load by anyone.
9096 if (ns && ns.carrying) {
……@@ -101,7 +107,7 @@ async function settingsPage (client, message, token = null, theme = null, appUpd
101107 // render at all rather than being fabricated from what this surface can see.
102108 let update = null
103109 try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ }
104 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update)
110 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known)
105111}
106112
107113/**
……@@ -184,6 +190,10 @@ export async function route (req, res, ctx) {
184190 // Read once, before anything renders — the server has to know the theme before it writes the
185191 // page, or the first paint is the wrong colour and then corrects itself.
186192 THEME.set(res, themeFrom(req))
193 // ⚠ Per request, because the state changes underneath a long-lived page: a node that stages an
194 // update while someone is reading should tell them on their next navigation, not on their next
195 // restart. It is a small local file, and absent everywhere there is no shell.
196 try { if (ctx.appUpdate) UPDATE.set(res, await ctx.appUpdate()) } catch { /* no shell */ }
187197 const { client, sessions } = ctx
188198 const url = new URL(req.url, 'http://localhost')
189199 const path = url.pathname
……@@ -722,6 +732,18 @@ export async function route (req, res, ctx) {
722732 return res.end()
723733 }
724734
735 // ⛔ REMOVES A LIST ENTRY, NEVER A LOG. The daemon refuses the current namespace and refuses a
736 // founded one, and says why in both cases — the message is passed through verbatim because it is
737 // the half that knows.
738 if (req.method === 'POST' && path === '/namespace/forget') {
739 const form = await body(req)
740 try {
741 await client.call('namespace-forget', { key: (form.key || '').trim() })
742 } catch (err) {
743 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
744 }
745 return seeOther(res, '/settings')
746 }
725747 if (req.method === 'POST' && path === '/namespace/join') {
726748 const form = await body(req)
727749 try {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.51",
3 "version": "0.0.52",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/pages.test.js +25 -1
……@@ -2,7 +2,7 @@
22
33import test from 'brittle'
44import fs from '#node:fs'
5import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread } from '../lib/pages.js'
5import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread, withUpdateBanner } from '../lib/pages.js'
66
77const row = (over = {}) => ({
88 author: 'ab'.repeat(32), collection: 'post', name: 'p1',
……@@ -577,3 +577,27 @@ test('⛔ the page declares its own background, in both directions', (t) => {
577577 t.ok(css.includes('[data-theme=dark]'), 'an explicit dark choice applies')
578578 t.ok(css.includes(':root:not([data-theme=light])'), 'and a light choice beats the device default')
579579})
580
581// ── the update banner ────────────────────────────────────────────────────────────────────────────
582//
583// ⛔ Applied at SEND time like `withTheme`, so no page can forget it. These pin the one distinction
584// that decides whether the notice means anything.
585test('★ the banner appears only when a DIFFERENT bundle is staged', (t) => {
586 const page = '<html lang="en"><body><h1>x</h1></body></html>'
587 const on = withUpdateBanner(page, { running: 'aaaa', staged: 'bbbb' })
588 t.ok(on.includes('update-bar'), 'a staged bundle that differs is an update')
589 t.ok(on.includes('href="/settings"'), 'and it points at the page that can restart')
590
591 // ⛔ The ordinary case: the node re-verified what it is already running. Announcing that as an
592 // update trains people to ignore the banner, and then the real one does nothing.
593 t.absent(withUpdateBanner(page, { running: 'aaaa', staged: 'aaaa' }).includes('update-bar'),
594 'staged and identical is NOT an update')
595 t.absent(withUpdateBanner(page, null).includes('update-bar'), 'no shell, no banner')
596 t.absent(withUpdateBanner(page, { running: 'aaaa', staged: null }).includes('update-bar'),
597 'nothing staged is not an update')
598})
599
600test('the banner sits at the top of the document, not wherever a page happens to put it', (t) => {
601 const out = withUpdateBanner('<html lang="en"><body><h1>x</h1></body></html>', { running: 'a', staged: 'b' })
602 t.ok(out.indexOf('update-bar') < out.indexOf('<h1>'), 'it cannot be pushed below the fold')
603})