Add shared navigation to every page

The pages were only reachable by typing URLs, and the digest had no navigation
at all. A single _nav.html partial now appears on all four, marking the current
page, so Triage / Digest / Tune / Config are one click apart.

Parameterised test asserts every page links to the others and marks itself
active, so a page added later cannot ship stranded.
This commit is contained in:
connor committed 2026-09-08 17:29:27 -04:00
1 parent 5f4bc8d890
commit 327d6a69ae
7 files changed
+69 -15

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{#- Shared navigation. `current` marks the active page. -#}
<nav class="sitenav">
<a href="/" class="{{ 'here' if current == 'triage' }}">Triage</a>
<a href="/digest" class="{{ 'here' if current == 'digest' }}">Digest</a>
<a href="/tune" class="{{ 'here' if current == 'tune' }}">Tune</a>
<a href="/config" class="{{ 'here' if current == 'config' }}">Config</a>
</nav>
+9 -2
View File
@@ -47,14 +47,21 @@
.nearmiss { border-left:2px solid var(--warn); padding-left:.7rem; margin-top:.6rem; } .nearmiss { border-left:2px solid var(--warn); padding-left:.7rem; margin-top:.6rem; }
.ok { color:var(--pos); } .err { color:var(--neg); } .ok { color:var(--pos); } .err { color:var(--neg); }
.gate { color:var(--warn); } .gate { color:var(--warn); }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
<header> <header>
<h1>Config</h1> <h1>Config</h1>
<div class="note"><a href="/">triage</a> · <a href="/tune">tune</a> · {% include "_nav.html" %}
<a href="/digest">digest</a> · {{ clusters }} clusters scored</div>
</header> </header>
<p class="note" style="margin:-.5rem 0 1rem">{{ clusters }} clusters scored</p>
<h2>Try a filter</h2> <h2>Try a filter</h2>
<div class="probe"> <div class="probe">
<p class="note" style="margin-top:0"> <p class="note" style="margin-top:0">
+14 -3
View File
@@ -13,7 +13,9 @@
BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-text-size-adjust:100%; } -webkit-text-size-adjust:100%; }
main { max-width:38rem; margin:0 auto; padding:1.5rem 1.15rem 4rem; } main { max-width:38rem; margin:0 auto; padding:1.5rem 1.15rem 4rem; }
header { border-bottom:1px solid var(--rule); padding-bottom:.75rem; margin-bottom:1.5rem; } header { border-bottom:1px solid var(--rule); padding-bottom:.75rem; margin-bottom:1.5rem;
display:flex; gap:1rem; flex-wrap:wrap; align-items:baseline;
justify-content:space-between; }
h1 { font-size:1.3rem; margin:0 0 .15rem; letter-spacing:-.01em; } h1 { font-size:1.3rem; margin:0 0 .15rem; letter-spacing:-.01em; }
.date { color:var(--muted); font-size:.85rem; } .date { color:var(--muted); font-size:.85rem; }
.briefing p { margin:0 0 1rem; } .briefing p { margin:0 0 1rem; }
@@ -30,11 +32,20 @@
nav { display:flex; justify-content:space-between; margin-top:2rem; font-size:.9rem; } nav { display:flex; justify-content:space-between; margin-top:2rem; font-size:.9rem; }
nav a { color:var(--accent); text-decoration:none; } nav a { color:var(--accent); text-decoration:none; }
.empty { color:var(--muted); font-style:italic; } .empty { color:var(--muted); font-style:italic; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
<header> <header>
<h1>Daily briefing</h1> <div>
<div class="date">{{ day.strftime('%A, %-d %B %Y') }}</div> <h1>Daily briefing</h1>
<div class="date">{{ day.strftime('%A, %-d %B %Y') }}</div>
</div>
{% include "_nav.html" %}
</header> </header>
{% if briefing %} {% if briefing %}
+15 -5
View File
@@ -21,6 +21,8 @@
h1 { font-size:1.15rem; margin:0; letter-spacing:-.01em; } h1 { font-size:1.15rem; margin:0; letter-spacing:-.01em; }
.stats, .filters { color:var(--muted); font-size:.83rem; } .stats, .filters { color:var(--muted); font-size:.83rem; }
.filters a { color:var(--accent); text-decoration:none; margin-left:.6rem; } .filters a { color:var(--accent); text-decoration:none; margin-left:.6rem; }
.subbar { display:flex; gap:1rem; flex-wrap:wrap; justify-content:space-between;
margin:-.4rem 0 1rem; }
.filters a.on { text-decoration:underline; } .filters a.on { text-decoration:underline; }
article { background:var(--card); border:1px solid var(--rule); border-radius:9px; article { background:var(--card); border:1px solid var(--rule); border-radius:9px;
@@ -54,18 +56,26 @@
border-top:1px solid var(--rule); padding:.45rem 1rem; font-size:.78rem; border-top:1px solid var(--rule); padding:.45rem 1rem; font-size:.78rem;
color:var(--muted); display:flex; gap:1rem; justify-content:center; } color:var(--muted); display:flex; gap:1rem; justify-content:center; }
.empty { color:var(--muted); font-style:italic; padding:2rem 0; } .empty { color:var(--muted); font-style:italic; padding:2rem 0; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
@media (max-width:34rem) { .help { display:none; } } @media (max-width:34rem) { .help { display:none; } }
</style> </style>
<main> <main>
<header> <header>
<h1>Triage</h1> <h1>Triage</h1>
<div class="stats">{{ rows|length }} shown · {{ labelled }} labelled · {{ total }} clusters</div> {% include "_nav.html" %}
<div class="filters">show </header>
<div class="subbar">
<span class="stats">{{ rows|length }} shown · {{ labelled }} labelled · {{ total }} clusters</span>
<span class="filters">show
<a class="{{ 'on' if show == 'unlabelled' }}" href="?show=unlabelled">unlabelled</a> <a class="{{ 'on' if show == 'unlabelled' }}" href="?show=unlabelled">unlabelled</a>
<a class="{{ 'on' if show == 'all' }}" href="?show=all">all</a> <a class="{{ 'on' if show == 'all' }}" href="?show=all">all</a>
· <a href="/digest">digest</a> </span>
</div> </div>
</header>
{% for r in rows %} {% for r in rows %}
<article id="c{{ loop.index0 }}" data-id="{{ r.id }}" <article id="c{{ loop.index0 }}" data-id="{{ r.id }}"
+7 -1
View File
@@ -39,11 +39,17 @@
letter-spacing:.04em; min-width:5.5rem; } letter-spacing:.04em; min-width:5.5rem; }
.lane.imp { color:var(--neg); } .lane.imp { color:var(--neg); }
.note { color:var(--muted); font-size:.82rem; } .note { color:var(--muted); font-size:.82rem; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
<header> <header>
<h1>Tune</h1> <h1>Tune</h1>
<div class="note"><a href="/">triage</a> · <a href="/digest">digest</a></div> {% include "_nav.html" %}
</header> </header>
<form method="get"> <form method="get">
+6 -4
View File
@@ -136,7 +136,7 @@ async def triage(request):
return TEMPLATES.TemplateResponse( return TEMPLATES.TemplateResponse(
request, "triage.html", request, "triage.html",
{"rows": rows, "show": show, "total": len(scores), "labelled": len(labelled), {"rows": rows, "show": show, "total": len(scores), "labelled": len(labelled),
"signed": bool(secret)}) "signed": bool(secret), "current": "triage"})
async def tune(request): async def tune(request):
@@ -196,7 +196,7 @@ async def tune(request):
request, "tune.html", request, "tune.html",
{"routing": routing, "counts": counts, "alerts": alerts, "digest": digest, {"routing": routing, "counts": counts, "alerts": alerts, "digest": digest,
"per_day": (counts[IMPORTANT] + counts[INTEREST]) / days, "days": days, "per_day": (counts[IMPORTANT] + counts[INTEREST]) / days, "days": days,
"defaults": cfg.routing}) "defaults": cfg.routing, "current": "tune"})
def _centroids(con): def _centroids(con):
@@ -254,7 +254,8 @@ async def config_page(request):
{"interests": summarise("interest", "___", 0.60), {"interests": summarise("interest", "___", 0.60),
"vetoes": summarise("veto", "threshold", 0.60), "vetoes": summarise("veto", "threshold", 0.60),
"demotes": summarise("demote", "___", 0.55), "demotes": summarise("demote", "___", 0.55),
"clusters": len(rows), "scoring": cfg.scoring, "routing": cfg.routing}) "clusters": len(rows), "scoring": cfg.scoring, "routing": cfg.routing,
"current": "config"})
async def config_preview(request): async def config_preview(request):
@@ -383,7 +384,8 @@ async def digest_page(request):
request, "digest.html", request, "digest.html",
{"day": when, "stories": stories, {"day": when, "stories": stories,
"briefing": prose["briefing"] if prose else None, "briefing": prose["briefing"] if prose else None,
"prev": when - timedelta(days=1), "next": when + timedelta(days=1)}, "prev": when - timedelta(days=1), "next": when + timedelta(days=1),
"current": "digest"},
) )
+11
View File
@@ -232,3 +232,14 @@ def test_labelling_is_disabled_when_no_secret_is_configured(tmp_path, monkeypatc
from news import web from news import web
web._VECTORS = None web._VECTORS = None
assert "labelling is disabled" in TestClient(web.app).get("/").text assert "labelling is disabled" in TestClient(web.app).get("/").text
@pytest.mark.parametrize("path,active", [
("/", "Triage"), ("/tune", "Tune"), ("/config", "Config"), ("/digest", "Digest")])
def test_every_page_carries_the_nav(client, path, active):
"""Navigation must not require knowing the URLs."""
c, _ = client
html = c.get(path).text
for target in ("/tune", "/config", "/digest"):
assert f'href="{target}"' in html, f"{path} is missing a link to {target}"
assert f'class="here">{active}<' in html, f"{path} does not mark itself active"