OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 934f9ed

render: bottom navigation on phones, five destinations
Read off bluesky-social/social-app (BottomBarWeb.tsx + BottomBarStyles.tsx) rather than recalled — I asserted this bar existed from memory earlier, withdrew it, and then went and looked. It does: fixed to the bottom, full width, hairline top rule, items at flex:1 with ~13px above and 4px below, five destinations, icon over label. Home · Search · Following · Alerts · You Signed out it degrades to two rather than linking to pages that cannot have content without a follow graph or an identity. Inline SVG icons, not Unicode glyphs. A nav drawn in ⌂ ⌕ ☰ is the most reliable way to look unfinished; these inherit currentColor so they theme for free. Phones only. bsky uses a left sidebar at desktop widths and our column is 640px — a phone chrome pinned under it would just be wrong. Verified: at 390px the bar is flex and the four destinations it shares with the top row are hidden from that row, so each place appears once; at 1280px the bar is display:none and the top row is whole. saved / lists / settings / RSS stay in the top nav, which is why that row is not simply hidden on mobile. Two things caught by measuring rather than looking: The top nav's active-item underline was reaching the bar, because .bottomnav is also a <nav>. Those rules are now scoped to `header nav`; the bar marks its own active item. Body clearance was wrong first time — measured 32px where 64 was intended. A fixed bar sits on top of the page, so the last post needs room or it is unreachable behind it, and my padding-bottom was losing to a later `padding` shorthand in the same media query. It now lives in that shorthand, plus env(safe-area-inset-bottom) for handsets with a home indicator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
934f9ed3cfecd41e7dd6796d9923e08ac914f6d5
by plankatron <fleeky@gmail.com> · 2026-08-24T21:44:44.000Z

Changed files (2) +80 -9

lib/render.js +79 -8
……@@ -848,9 +848,12 @@ h1 small{font-size:var(--f1)}
848848nav{gap:var(--s4);margin:0 0 var(--s5);padding-bottom:var(--s2);border-bottom:1px solid var(--line);font-size:var(--f1);flex-wrap:wrap}
849849.navsearch{min-width:0}
850850.navsearch input{max-width:100%}
851nav a{color:var(--fg-dim);text-decoration:none;padding:var(--s1) 0;border-bottom:2px solid transparent}
852nav a:hover{color:var(--fg)}
853nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)}
851header nav a{color:var(--fg-dim);text-decoration:none;padding:var(--s1) 0;border-bottom:2px solid transparent}
852header nav a:hover{color:var(--fg)}
853/* Scoped to the header: .bottomnav is also a <nav>, and this underline was reaching it by accident.
854 The bar marks its own active item (colour + weight) rather than inheriting a rule meant for a
855 horizontal text row. */
856header nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)}
854857/* Posts read as rows in a stream, not as boxed cards — the hairline is the separator and the padding
855858 gives each one room to breathe. */
856859.post{padding:var(--s4) var(--s2);border-color:var(--line);border-radius:var(--r2)}
……@@ -867,6 +870,31 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
867870/* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between
868871 them is padding INSIDE a target, not dead space between two small ones. */
869872.meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)}
873/* ── bottom navigation (phones) ────────────────────────────────────────────────────────────────────
874 Read off bluesky-social/social-app BottomBarWeb.tsx + BottomBarStyles.tsx: fixed to the bottom,
875 full width, hairline top rule, items at flex:1 with roughly 13px above and 4px below. Five
876 destinations, icon over label.
877
878 Phones only. bsky uses a left sidebar at desktop widths and our content column is 640px — a phone
879 chrome pinned under it would just be wrong. Above 640px this does not render as a bar at all.
880
881 The four destinations it shares with the top nav are hidden FROM that row under 640px, so a phone
882 shows each place once; saved / lists / settings / RSS stay up there, which is why the top row is
883 not simply hidden. */
884.bottomnav{display:none}
885@media(max-width:640px){
886 .bottomnav{display:flex;position:fixed;left:0;right:0;bottom:0;z-index:50;
887 background:var(--bg);border-top:1px solid var(--line);padding:0 var(--s2);
888 /* Clears the home indicator on phones that have one. */
889 padding-bottom:env(safe-area-inset-bottom,0)}
890 .bnitem{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
891 padding:13px 0 4px;min-height:44px;text-decoration:none;color:var(--fg-faint);font-size:11px}
892 .bnitem .nicon{display:block}
893 .bnitem.on{color:var(--accent);font-weight:600}
894 .bnitem:hover{color:var(--fg)}
895 /* Shown in the bar instead — one destination, one place. */
896 header nav a.navprimary{display:none}
897}
870898/* ── appearance control ────────────────────────────────────────────────────────────────────────────
871899 A segmented row of submit buttons, one per state. Buttons rather than a select because the current
872900 value should be visible without opening anything, and 44px because this is a touch control like any
……@@ -944,7 +972,11 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var
944972 was 15.2px and read small on a phone; now that the base IS 16px it would overshoot to 18px, which
945973 is larger than any mainstream client sets body text. The padding reduction below is the part that
946974 was actually buying the space. */
947 body{padding:var(--s4) var(--s3) var(--s6);font-size:var(--f2)}
975 /* The bottom bar is fixed and sits ON TOP of the page, so the last post needs room or it is
976 unreachable behind it. This lives in the shorthand rather than as a separate padding-bottom:
977 an earlier padding-bottom loses to this rule, which is exactly what happened first time —
978 measured 32px where 64 was intended. env() clears the home indicator where there is one. */
979 body{padding:var(--s4) var(--s3) calc(64px + env(safe-area-inset-bottom,0));font-size:var(--f2)}
948980 h1{flex-wrap:wrap}
949981 .who{margin-left:0;width:100%}
950982 .post{padding:var(--s4) 0;border-radius:0}
……@@ -1313,17 +1345,55 @@ function whoami (viewer) {
13131345 return '<span class="who"><a href="/login">sign in</a></span>'
13141346}
13151347
1348// Icons for the bottom bar. Inline SVG rather than Unicode glyphs: a nav drawn in ⌂ ⌕ ☰ is the single
1349// most reliable way to look unfinished, and these inherit currentColor so they theme for free. 24px to
1350// match the rest of the touch furniture. aria-hidden because each sits beside a real text label.
1351const ICON = {
1352 home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
1353 search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
1354 following: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M17 5.2a3.5 3.5 0 0 1 0 6.6"/><path d="M18.5 20a6.5 6.5 0 0 0-2.5-5.1"/>',
1355 bell: '<path d="M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6"/><path d="M10.5 20a2 2 0 0 0 3 0"/>',
1356 user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>'
1357}
1358const icon = (k) => `<svg class="nicon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[k]}</svg>`
1359
1360// The phone bar. Five destinations, matching bsky's shape (home / search / a people feed /
1361// notifications / you) — read from their BottomBarWeb.tsx rather than recalled.
1362//
1363// Signed out, `following`, `notifications` and a profile do not exist, so it degrades to three rather
1364// than offering links to permanently empty pages. Each item is flex:1, so a target is a fifth of the
1365// screen wide — the same "make it unmissable" instinct as bsky's hitSlop on the post controls.
1366//
1367// The items here are HIDDEN from the top nav under 640px (see .navprimary in STYLE), so a phone shows
1368// each destination once. Everything that is not here — saved, lists, settings, RSS — stays in the top
1369// nav, which is why that row is not simply hidden on mobile.
1370function bottomNav (active, viewer = null) {
1371 const signedIn = !!(viewer && viewer.did)
1372 const item = (href, label, key, ic) =>
1373 `<a href="${href}" class="bnitem${active === key ? ' on' : ''}"${active === key ? ' aria-current="page"' : ''}>${icon(ic)}<span>${label}</span></a>`
1374 const items = [
1375 item('/', 'Home', 'timeline', 'home'),
1376 item('/search', 'Search', 'search', 'search'),
1377 signedIn ? item('/following', 'Following', 'following', 'following') : '',
1378 signedIn ? item('/notifications', 'Alerts', 'notifications', 'bell') : '',
1379 signedIn ? item(`/profile/${encodeURIComponent(viewer.did)}`, 'You', 'profile', 'user') : ''
1380 ].join('')
1381 return `<nav class="bottomnav" aria-label="primary">${items}</nav>`
1382}
1383
13161384function nav (active, viewer = null) {
1317 const item = (href, label, key) => `<a href="${href}"${active === key ? ' aria-current="page"' : ''}>${label}</a>`
1385 // `primary` marks the destinations the bottom bar also carries, so they can be hidden from this row
1386 // on a phone without hiding the row itself — the overflow lives here and must stay reachable.
1387 const item = (href, label, key, primary = false) => `<a href="${href}"${primary ? ' class="navprimary"' : ''}${active === key ? ' aria-current="page"' : ''}>${label}</a>`
13181388 // Following is only meaningful signed in — an anonymous viewer has no follow graph, so offering the
13191389 // link would lead to a permanently empty page.
1320 const following = viewer && viewer.did ? item('/following', 'following', 'following') : ''
1390 const following = viewer && viewer.did ? item('/following', 'following', 'following', true) : ''
13211391 const bookmarks = viewer && viewer.did ? item('/bookmarks', 'saved', 'bookmarks') : ''
1322 const notifs = viewer && viewer.did ? item('/notifications', 'notifications', 'notifications') : ''
1392 const notifs = viewer && viewer.did ? item('/notifications', 'notifications', 'notifications', true) : ''
13231393 const lists = viewer && viewer.did ? item('/lists', 'lists', 'lists') : ''
13241394 // A plain GET form: search works with scripting off and the result is a shareable URL.
13251395 const search = `<form class="navsearch" method="GET" action="/search" role="search"><input type="search" name="q" placeholder="search" aria-label="search"></form>`
1326 return `<nav>${item('/', 'timeline', 'timeline')}${following}${notifs}${bookmarks}${lists}${item('/search', 'search', 'search')}${item('/settings', 'settings', 'settings')}${item('/feed.xml', 'RSS', 'rss')}${search}</nav>`
1396 return `<nav>${item('/', 'timeline', 'timeline', true)}${following}${notifs}${bookmarks}${lists}${item('/search', 'search', 'search', true)}${item('/settings', 'settings', 'settings')}${item('/feed.xml', 'RSS', 'rss')}${search}</nav>`
13271397}
13281398
13291399// Progressive enhancement for the whole-entry link, and the one thing the pure-CSS version costs.
……@@ -1395,6 +1465,7 @@ ${freshnessNote(freshness)}
13951465${composer(canWrite, quoting, composerLists, viewer)}
13961466${body}
13971467</main>
1468${bottomNav(active, viewer)}
13981469${clientScript}
13991470${POSTCLICK}
14001471<script type="module" src="/static/actions.js"></script>
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.75",
3 "version": "0.3.76",
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",