summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 13:57:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 13:57:34 +0200
commit4b74ff3fc54f35fbc41d9c0f7d2b4ec89475154a (patch)
tree6a0ab44c40602415015cbacda30e5615e0863d8f /packages
parentcab9ba28cfba9d90d0a6c4c00dbba852825f66c2 (diff)
downloadmeshbay-4b74ff3fc54f35fbc41d9c0f7d2b4ec89475154a.tar.gz
feat(hub): legal information link at the foot of the sidebar
A small link to the hub's legal pages, pinned to the bottom of the sidebar once signed in. It opens in a new tab: the desktop application refuses to navigate away from its interface and hands a new window to the system browser, and in a browser it keeps the session on screen. The address comes from hubBase(), so it is the legal pages of the hub in use. The sidebar now sticks under the navigation bar at the window's height; otherwise, on a long file list, the link would sit at the bottom of the page. The music bar, pinned to the bottom of the window as well, publishes its height through useStickyBand as --music-bar-h and the sidebar stops above it. On a phone the slide-out panel does the same. test_sidebar_legal_measured.py measures the real stylesheet: the link at the bottom of the window on a 3000px page, and above the music bar, at phone and desktop widths. Checked signed in on a local hub in Chrome and Firefox 155. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css24
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_legal_measured.py101
14 files changed, 148 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index dfd0aeb..ba24822 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -474,10 +474,20 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha
})
}
</div>
+ <a class="sidebar-legal" href="${legalUrl()}" target="_blank" rel="noopener">
+ ${t('sidebar.legal')}</a>
</aside>
`;
}
+// The legal pages of the hub in use: the page's own origin in a browser, the
+// configured hub in the application. A new tab either way: the application
+// refuses to navigate away from its interface and hands a new window to the
+// system browser instead, and in a browser it keeps the session on screen.
+function legalUrl() {
+ return `${platform.hubBase().replace(/\/$/, '')}/legal/`;
+}
+
// ── Home Page ────────────────────────────────────────────────────────────────
function NotificationFeed({ notifications, onMarkRead, onPurge }) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 116bbe9..7eed742 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -23,6 +23,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Meine Gruppen',
'sidebar.no_groups': 'Noch keine Gruppen',
+ 'sidebar.legal': 'Rechtliche Hinweise',
'sidebar.discover': 'Entdecken',
'sidebar.public_groups': 'Öffentliche Gruppen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 80dd819..143f462 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -26,6 +26,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'My Groups',
'sidebar.no_groups': 'No groups yet',
+ 'sidebar.legal': 'Legal information',
'sidebar.discover': 'Discover',
'sidebar.public_groups': 'Public groups',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 9f8b60e..f35d05d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -22,6 +22,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Mis grupos',
'sidebar.no_groups': 'Aún no hay grupos',
+ 'sidebar.legal': 'Información legal',
'sidebar.discover': 'Descubrir',
'sidebar.public_groups': 'Grupos públicos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index d928f52..e8ac577 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -22,6 +22,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Mes groupes',
'sidebar.no_groups': 'Aucun groupe pour le moment',
+ 'sidebar.legal': 'Informations légales',
'sidebar.discover': 'Découvrir',
'sidebar.public_groups': 'Groupes publics',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 58e2389..76caa51 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -23,6 +23,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'I miei gruppi',
'sidebar.no_groups': 'Ancora nessun gruppo',
+ 'sidebar.legal': 'Note legali',
'sidebar.discover': 'Esplora',
'sidebar.public_groups': 'Gruppi pubblici',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 234b238..a6c39ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -23,6 +23,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'マイグループ',
'sidebar.no_groups': 'グループはまだありません',
+ 'sidebar.legal': '法的情報',
'sidebar.discover': '見つける',
'sidebar.public_groups': '公開グループ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 72511f5..e4a7914 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -23,6 +23,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Mijn groepen',
'sidebar.no_groups': 'Nog geen groepen',
+ 'sidebar.legal': 'Juridische informatie',
'sidebar.discover': 'Ontdekken',
'sidebar.public_groups': 'Openbare groepen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 12a31aa..d13e3ef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -26,6 +26,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Moje grupy',
'sidebar.no_groups': 'Nie ma jeszcze żadnych grup',
+ 'sidebar.legal': 'Informacje prawne',
'sidebar.discover': 'Odkrywaj',
'sidebar.public_groups': 'Grupy publiczne',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index c5f826b..1b517bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -24,6 +24,7 @@ export default {
// Sidebar
'sidebar.my_groups': 'Meus grupos',
'sidebar.no_groups': 'Nenhum grupo ainda',
+ 'sidebar.legal': 'Informações legais',
'sidebar.discover': 'Descobrir',
'sidebar.public_groups': 'Grupos públicos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index e06c5c6..9922a2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -23,6 +23,7 @@ export default {
// Sidebar
'sidebar.my_groups': '我的群组',
'sidebar.no_groups': '暂无群组',
+ 'sidebar.legal': '法律信息',
'sidebar.discover': '发现',
'sidebar.public_groups': '公开群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
index 2d3c002..a4a8c5c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -3,6 +3,7 @@ import {
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
+import { useStickyBand } from './sticky.js';
import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
/**
@@ -179,6 +180,9 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) {
}
function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
+ // Pinned to the bottom of the window, over the bottom of the sidebar; the
+ // sidebar subtracts this so its last entry is not underneath.
+ const barBand = useStickyBand('--music-bar-h');
const audioRef = useRef(null);
const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index }
const blobInsertRef = useRef(0);
@@ -480,7 +484,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
: repeat === 'all' ? t('music.player_repeat_all') : t('music.player_repeat_one');
return html`
- <div class="music-player-bar">
+ <div class="music-player-bar" ref=${barBand}>
<audio ref=${audioRef}
onTimeUpdate=${(e) => setCurrentTime(e.target.currentTime)}
onDurationChange=${(e) => setDuration(e.target.duration)}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ff6fe57..2b35088 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -71,6 +71,8 @@
--nav-h: 52px;
--chrome-h: 0px;
--toolbar-h: 0px;
+ /* Published by the music bar while it is on screen (sticky.js). */
+ --music-bar-h: 0px;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
@@ -387,15 +389,33 @@ a:hover { text-decoration: underline; }
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
+/* Pinned under the navigation bar at the window's height, so the legal link at
+ its foot is at the bottom of the window whatever the page's length — on a
+ long file list it would otherwise be at the bottom of the page. It stops
+ above the music bar, which is pinned to the bottom of the window too. */
.sidebar {
+ position: sticky;
+ top: var(--nav-h);
+ align-self: flex-start;
+ display: flex;
+ flex-direction: column;
width: 240px;
+ height: calc(100vh - var(--nav-h) - var(--music-bar-h));
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
- padding: 16px 0;
+ padding: 16px 0 0;
flex-shrink: 0;
overflow-y: auto;
}
+.sidebar-legal {
+ margin-top: auto;
+ padding: 10px 20px 12px;
+ color: var(--text-dim);
+ font-size: 0.75em;
+}
+.sidebar-legal:hover { color: var(--accent); }
+
.sidebar-section { padding: 0 12px; margin-bottom: 24px; }
.sidebar-heading {
@@ -2465,7 +2485,7 @@ a.transfer-name {
position: fixed;
left: -240px;
top: 52px;
- height: calc(100vh - 52px);
+ height: calc(100vh - 52px - var(--music-bar-h));
z-index: 50;
transition: left 0.2s;
box-shadow: none;
diff --git a/packages/meshbay-hub/tests/test_sidebar_legal_measured.py b/packages/meshbay-hub/tests/test_sidebar_legal_measured.py
new file mode 100644
index 0000000..faaaec7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_sidebar_legal_measured.py
@@ -0,0 +1,101 @@
+"""
+The legal link at the foot of the sidebar, measured in a browser.
+
+It is pinned to the bottom of the window, not of the page: the sidebar sticks
+under the navigation bar at the window's height, so on a long file list the
+link is still on screen. And the music bar, pinned to the bottom of the window
+as well, must not cover it — the bar publishes its height as `--music-bar-h`
+(sticky.js, as the toolbars do), and the sidebar stops above it. That value
+is set by hand here; what is under test is the stylesheet's arithmetic.
+
+On a phone the sidebar is the slide-out panel, measured open.
+"""
+
+import json
+import shutil
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "layout_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+# The probe's frames are this tall.
+WINDOW_H = 740
+MUSIC_BAR_H = 64
+WIDTHS = [390, 1024, 1440]
+
+
+def _page(music: bool) -> str:
+ groups = "".join(
+ f'<a class="sidebar-item sidebar-group" href="#/"><span class="si-head">'
+ f'<span class="sidebar-item-name">Some group {i}</span></span></a>' for i in range(4))
+ bar = (f'<div class="music-player-bar" style="height:{MUSIC_BAR_H}px;'
+ f'box-sizing:border-box">a track</div>') if music else ""
+ style = f' style="--music-bar-h:{MUSIC_BAR_H}px"' if music else ""
+ return textwrap.dedent(f"""
+ <div id="app"{style}>
+ <nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div>
+ <div class="nav-right"><button class="nav-btn">someone</button></div></nav>
+ <div class="layout">
+ <aside class="sidebar open">
+ <div class="sidebar-section"><div class="sidebar-heading">My groups</div>{groups}</div>
+ <a class="sidebar-legal" href="/legal/">Legal information</a>
+ </aside>
+ <main class="main"><h2>A long list</h2><div style="height:3000px"></div></main>
+ </div>
+ {bar}
+ </div>
+ """)
+
+
+def _measure(tmp_path_factory, music: bool) -> dict:
+ fragment = tmp_path_factory.mktemp("sidebar") / "fragment.html"
+ fragment.write_text(_page(music), encoding="utf-8")
+ proc = subprocess.run(
+ ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
+ str(fragment), ".sidebar-legal", ".sidebar", ".music-player-bar"],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, f"no measurement: {out}"
+ return out
+
+
+@pytest.fixture(scope="module")
+def plain(tmp_path_factory):
+ return _measure(tmp_path_factory, music=False)
+
+
+@pytest.fixture(scope="module")
+def playing(tmp_path_factory):
+ return _measure(tmp_path_factory, music=True)
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_link_is_at_the_bottom_of_the_window(plain, width):
+ """Not at the bottom of a 3000px page, where nobody would find it."""
+ link = plain[str(width)]["boxes"][".sidebar-legal"]
+ assert link is not None, "no legal link"
+ bottom = link["top"] + link["height"]
+ assert WINDOW_H - 8 <= bottom <= WINDOW_H, (
+ f"at {width} px the link ends at {bottom}, the window at {WINDOW_H}")
+ assert link["offLeft"] == 0 and link["offRight"] == 0
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_music_bar_does_not_cover_it(playing, width):
+ link = playing[str(width)]["boxes"][".sidebar-legal"]
+ bar = playing[str(width)]["boxes"][".music-player-bar"]
+ assert link is not None and bar is not None
+ bottom = link["top"] + link["height"]
+ assert bottom <= bar["top"] + 1, (
+ f"at {width} px the link ends at {bottom} and the music bar starts at {bar['top']}")
+ assert bottom >= bar["top"] - 8, (
+ f"at {width} px the link floats {bar['top'] - bottom} px above the music bar")