| … | … | @@ -848,9 +848,12 @@ h1 small{font-size:var(--f1)} |
| 848 | 848 | nav{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} |
| 849 | 849 | .navsearch{min-width:0} |
| 850 | 850 | .navsearch input{max-width:100%} |
| 851 | | nav a{color:var(--fg-dim);text-decoration:none;padding:var(--s1) 0;border-bottom:2px solid transparent} |
| 852 | | nav a:hover{color:var(--fg)} |
| 853 | | nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)} |
| 851 | header nav a{color:var(--fg-dim);text-decoration:none;padding:var(--s1) 0;border-bottom:2px solid transparent} |
| 852 | header 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. */ |
| 856 | header nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var(--fg)} |
| 854 | 857 | /* Posts read as rows in a stream, not as boxed cards — the hairline is the separator and the padding |
| 855 | 858 | gives each one room to breathe. */ |
| 856 | 859 | .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 |
| 867 | 870 | /* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between |
| 868 | 871 | them is padding INSIDE a target, not dead space between two small ones. */ |
| 869 | 872 | .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 | } |
| 870 | 898 | /* ── appearance control ──────────────────────────────────────────────────────────────────────────── |
| 871 | 899 | A segmented row of submit buttons, one per state. Buttons rather than a select because the current |
| 872 | 900 | 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 |
| 944 | 972 | was 15.2px and read small on a phone; now that the base IS 16px it would overshoot to 18px, which |
| 945 | 973 | is larger than any mainstream client sets body text. The padding reduction below is the part that |
| 946 | 974 | 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)} |
| 948 | 980 | h1{flex-wrap:wrap} |
| 949 | 981 | .who{margin-left:0;width:100%} |
| 950 | 982 | .post{padding:var(--s4) 0;border-radius:0} |
| … | … | @@ -1313,17 +1345,55 @@ function whoami (viewer) { |
| 1313 | 1345 | return '<span class="who"><a href="/login">sign in</a></span>' |
| 1314 | 1346 | } |
| 1315 | 1347 | |
| 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. |
| 1351 | const 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 | } |
| 1358 | const 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. |
| 1370 | function 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 | |
| 1316 | 1384 | function 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>` |
| 1318 | 1388 | // Following is only meaningful signed in — an anonymous viewer has no follow graph, so offering the |
| 1319 | 1389 | // 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) : '' |
| 1321 | 1391 | 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) : '' |
| 1323 | 1393 | const lists = viewer && viewer.did ? item('/lists', 'lists', 'lists') : '' |
| 1324 | 1394 | // A plain GET form: search works with scripting off and the result is a shareable URL. |
| 1325 | 1395 | 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>` |
| 1327 | 1397 | } |
| 1328 | 1398 | |
| 1329 | 1399 | // Progressive enhancement for the whole-entry link, and the one thing the pure-CSS version costs. |
| … | … | @@ -1395,6 +1465,7 @@ ${freshnessNote(freshness)} |
| 1395 | 1465 | ${composer(canWrite, quoting, composerLists, viewer)} |
| 1396 | 1466 | ${body} |
| 1397 | 1467 | </main> |
| 1468 | ${bottomNav(active, viewer)} |
| 1398 | 1469 | ${clientScript} |
| 1399 | 1470 | ${POSTCLICK} |
| 1400 | 1471 | <script type="module" src="/static/actions.js"></script> |