OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 92f5c60

0.0.43: the nav stops being a row that has to fit
The CSS carried its own history: "Eight items on one line at this width", then "Nine items… it has wrapped twice now", then "Ten items… it has wrapped three times". Each fix was a smaller gap. There are eleven items and it wrapped again, orphaning "sign out" on a second row — 64px of navigation above every page. A fourth smaller gap fixes this instance and schedules the next one. A row that must fit N items breaks every time N grows, so the fix is a layout with no N: beside the column the nav stacks, and stacking cannot wrap however many pages get added. Both references do exactly this, and there was 250px of empty gutter either side of our 38rem column to put it in. ⚠ THE ROW IS STILL THE DEFAULT. A phone has no gutter for a sidebar, so the row is what works everywhere and the sidebar is what a wide viewport earns. 62rem is where 11rem + 38rem + the gap first fits without squeezing the reading column, which stays 38rem — the measured match for Bluesky's and Mastodon's 600px. `layout()` wraps content in <main>. Without it, body has the nav and every top-level element of the page as siblings, so a grid would put the nav in column one, the first paragraph in column two and the second back in column one. The wrapper is what makes a sidebar expressible at all. MEASURED at three widths: 1280px gives a 176x377 column at x=177 with content at x=381 and no overlap; 900px and 500px both fall back to the row. ⛔ Backticks in a CSS comment ended the template literal and the module stopped parsing — third time in this file this session. There is a test for it, but a parse error beats the test: the suite cannot import what will not parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92f5c603ba694c8a56de4ef3c772ef5925d12418
by plankatron <fleeky@gmail.com> · 2026-09-07T21:33:19.000Z

Changed files (3) +73 -8

lib/pages.js +30 -7
……@@ -166,18 +166,41 @@ body{font:16px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;max-width:38rem
166166
167167/* Navigation reads as one row of peers, not eight loose links. No underline until you reach for it,
168168 and the page you are on is marked — otherwise the nav tells you nothing about where you are. */
169/* ⚠ Eight items on one line at this width: the gap shrinks rather than letting "sign out" orphan
170 itself on a second row, which reads as a mistake. */
171/* ⚠ Nine items on one line. It has wrapped twice now as pages were added, and one orphaned link on
172 a second row reads as a mistake rather than as a second row. */
173/* ⚠ Ten items. It has wrapped three times as pages were added, each time leaving one orphan on a
174 second row, which reads as a mistake rather than as a second row. */
169/* ⛔ THE ROW WAS FIXED THREE TIMES BY SHRINKING THE GAP, and wrapped again at eleven items.
170 "Eight items on one line at this width" became nine, then ten, each time leaving one orphaned
171 link on a second row. A row that must fit N items breaks every time N grows, so the fourth fix is
172 not a smaller gap: it is a layout that has no N. Beside the column the nav stacks, and stacking
173 cannot wrap however many pages are added.
174
175 ⚠ The ROW IS STILL THE DEFAULT. A phone has no gutter to put a sidebar in, so the row is what
176 works everywhere and the sidebar is what a wide viewport earns. */
175177nav{display:flex;flex-wrap:wrap;gap:.55rem;border-bottom:1px solid var(--line);
176178 padding-bottom:var(--s2);margin-bottom:var(--s3);font-size:13.5px}
177179nav a{margin:0;text-decoration:none;color:var(--dim)}
178180nav a:hover{color:var(--fg);text-decoration:underline}
179181nav a[aria-current]{color:var(--fg);font-weight:600}
180182
183/* ⛔ Beside the column, not above it.
184 The reading column stays 38rem — the measured match for Bluesky's and Mastodon's 600px — and the
185 nav takes 11rem of the gutter that was empty either side of it. It aligns to the start so a short
186 nav does not stretch down a long page, and sticks so it is still there after you scroll, which is
187 the behaviour both references have and a top row cannot.
188
189 ⛔ NO BACKTICKS IN THIS BLOCK. It lives in a JS template literal, so one backtick in a comment
190 ends the string and the module stops parsing. Third time in this file; a test asserts it, but a
191 parse error beats the test to it — the suite cannot import what will not parse.
192
193 ⚠ 62rem, because that is where 11rem + 38rem + the gap first fits without squeezing the column. */
194@media (min-width:62rem){
195 body{max-width:52rem;display:grid;grid-template-columns:11rem minmax(0,38rem);
196 gap:var(--s4);align-items:start}
197 nav{flex-direction:column;align-items:stretch;gap:.1rem;border-bottom:0;padding-bottom:0;
198 margin-bottom:0;position:sticky;top:var(--s3);font-size:14.5px}
199 nav a{padding:.3rem .6rem;border-radius:999px}
200 nav a:hover{background:var(--bg-soft);text-decoration:none;color:var(--fg)}
201 nav a[aria-current]{background:var(--bg-soft)}
202}
203
181204/* ⛔ The post is a GRID: a fixed avatar gutter and one content column, so every line of a post
182205 shares a left edge and the identity never shifts the text. */
183206/* Measured against mastodon/mastodon components.scss, which is the same structure:
……@@ -509,7 +532,7 @@ export function layout (title, body) {
509532 return '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
510533 '<meta name="viewport" content="width=device-width,initial-scale=1">' +
511534 '<title>' + esc(title) + '</title><style>' + CSS + '</style></head><body>' +
512 NAV(title) + body + '</body></html>'
535 NAV(title) + '<main>' + body + '</main></body></html>'
513536}
514537
515538/**
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.42",
3 "version": "0.0.43",
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/nav.test.js +42 -0
……@@ -0,0 +1,42 @@
1// ⛔ A ROW THAT MUST FIT N ITEMS BREAKS EVERY TIME N GROWS.
2//
3// The CSS carries its own history: "Eight items on one line at this width", then "Nine items… it has
4// wrapped twice now", then "Ten items… it has wrapped three times". Each time the fix was to shrink
5// the gap. There are eleven items now and it wraps again, leaving "sign out" orphaned on a second
6// row — 64px of navigation above every page.
7//
8// Shrinking the gap a fourth time fixes this instance and schedules the next one. Bluesky and
9// Mastodon both stack navigation vertically beside the column, which cannot wrap however many items
10// there are, and there is 250px of empty gutter either side of our 38rem column to put it in.
11
12import test from 'brittle'
13import { layout, timeline } from '../lib/pages.js'
14
15const css = (html) => html.slice(html.indexOf('<style>') + 7, html.indexOf('</style>'))
16
17test('★★ the content is one element, so it can be placed beside the nav', async (t) => {
18 const html = layout('timeline', '<p>hello</p>')
19 // ⛔ Without a wrapper, `body` has nav plus every top-level element of the page as siblings — so a
20 // grid on body would put the nav in column one, the first paragraph in column two, the second
21 // back in column one. The wrapper is what makes a sidebar expressible at all.
22 t.ok(html.includes('<main>'), 'a single content element')
23 t.ok(html.indexOf('<main>') > html.indexOf('</nav>'), 'after the nav, so source order is reading order')
24 t.ok(html.includes('</main></body>'))
25})
26
27test('★★ at a wide viewport the nav is a COLUMN, not a row', async (t) => {
28 const c = css(timeline([], { empty: { follows: 0 } }))
29 const wide = c.slice(c.indexOf('@media (min-width'))
30 t.ok(wide.length > 0, 'there is a wide-viewport rule at all')
31 t.ok(/flex-direction:column/.test(wide), 'the nav stacks')
32 t.ok(/grid-template-columns/.test(wide), 'and the content sits beside it')
33})
34
35test('⛔ the narrow layout is unchanged — it is the one that has to work everywhere', async (t) => {
36 const c = css(timeline([], { empty: { follows: 0 } }))
37 const base = c.slice(0, c.indexOf('@media (min-width'))
38 // A phone has no gutter to put a sidebar in. The row is the default and the sidebar is the
39 // enhancement, not the other way round.
40 t.ok(/nav\{[^}]*flex-wrap:wrap/.test(base), 'still wraps when it must')
41 t.ok(/body\{[^}]*max-width:38rem/.test(base), 'and the column is still the column')
42})