A way down the tree from every page
Test / test (pull_request) Successful in 53s

An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.

Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-29 07:05:39 +02:00
co-authored by Claude Opus 5.5
parent f0c43c5c0f
commit e2596bc3cf
4 changed files with 174 additions and 0 deletions
+4
View File
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file). content repos pin the tag they run (see the end of this file).
## Unreleased
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
## 0.5.2 (2026-09-28) ## 0.5.2 (2026-09-28)
- The rate limiter answered 500 to any request that arrived without `X-Forwarded-For`. It reads the caller from that header or `X-Real-IP` and falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed. - The rate limiter answered 500 to any request that arrived without `X-Forwarded-For`. It reads the caller from that header or `X-Real-IP` and falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
+103
View File
@@ -470,10 +470,83 @@ fn QuestionView(
/// Every other question the visitor currently qualifies for, as a /// Every other question the visitor currently qualifies for, as a
/// small nav - how a concern reaches the people it speaks to without /// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here). /// claiming front-page space (an owner also sees the gated desks here).
/// Where the arrow on the left takes a visitor: the nearest page below
/// the current one in the tree that they may open, found by taking the
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
/// and the front door when none of those is a page. `None` on the front
/// door itself, which has nowhere further down to go. It is always a
/// valid way on, so a visitor who went up a branch whose context turned
/// out not to apply is never left stranded.
pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> {
let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/');
if path.is_empty() {
return None;
}
let mut at = path;
while let Some(i) = at.rfind('/') {
at = &at[..i];
let candidate = if at.is_empty() { "/" } else { at };
if let Some(page) = pages.iter().find(|(id, _)| id == candidate) {
return Some(page.clone());
}
if at.is_empty() {
break;
}
}
Some(("/".to_string(), String::new()))
}
/// How the way down names where it goes: "the start" for the front
/// door, and otherwise the page's own question, quoted - a page's
/// name is the question it asks, which reads as a destination only in
/// quotation marks.
fn down_the_tree_label(lang: &str, href: &str, name: &str) -> String {
let to = if href == "/" || name.is_empty() {
t(lang, "the_start").to_string()
} else if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
format!("«{name}»")
} else {
format!("\u{201c}{name}\u{201d}")
};
format!("{}{to}", t(lang, "down_to"))
}
#[component] #[component]
fn QuestionNav(current_id: String) -> impl IntoView { fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context(); let NavRes(nav) = expect_context();
let location = leptos_router::hooks::use_location();
view! { view! {
// The way down the tree: fixed at the left edge, half-way up the
// screen, small enough never to cover what a visitor is reading.
// It replaces a "back" alternative on every page that had one.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<a class="down-the-tree" href=href aria-label=label.clone() title=label>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5 8 12l7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
}
})
}}
</Suspense>
// The same way down, at the foot of the page, in words: where a
// reader who has read to the end looks for where to go next.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<nav class="down-the-tree-foot">
<a href=href>{format!("← {label}")}</a>
</nav>
}
})
}}
</Suspense>
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
let current_id = current_id.clone(); let current_id = current_id.clone();
@@ -2578,3 +2651,33 @@ async fn upload_file(
.as_string() .as_string()
.ok_or_else(|| JsValue::from_str("upload response had no key")) .ok_or_else(|| JsValue::from_str("upload response had no key"))
} }
#[cfg(test)]
mod down_the_tree_tests {
use super::down_the_tree;
fn pages(ids: &[&str]) -> Vec<(String, String)> {
ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect()
}
#[test]
fn the_way_down_is_the_nearest_page_below_and_never_a_dead_end() {
let p = pages(&["/", "/melde-fra", "/review"]);
// One level down, to a page that exists.
assert_eq!(down_the_tree("/melde-fra", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/review/abc123", &p).unwrap().0, "/review");
// Segments that are no page of their own are skipped.
assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/");
// A trailing slash or a query does not count as a level.
assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review");
// Named: the start, or the page's question in quotation marks.
assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten");
assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»");
assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}");
// The front door has nowhere further down.
assert!(down_the_tree("/", &p).is_none());
// A visitor who may not open the front door still gets "/".
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
}
}
+6
View File
@@ -33,6 +33,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
"This part of the site is for organizational owners — sign in with that account to take a look." "This part of the site is for organizational owners — sign in with that account to take a look."
} }
("en", "announcements") => "Announcements", ("en", "announcements") => "Announcements",
("en", "down_to") => "Back to ",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre", ("no", "also_worth_asking") => "Også verdt å spørre",
("no", "asked_by") => "Stilt av ", ("no", "asked_by") => "Stilt av ",
@@ -51,6 +53,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt." "Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
} }
("no", "announcements") => "Kunngjøringer", ("no", "announcements") => "Kunngjøringer",
("no", "down_to") => "Tilbake til ",
("no", "the_start") => "starten",
_ => return None, _ => return None,
}) })
@@ -83,6 +87,8 @@ mod tests {
"follows_answer", "follows_answer",
"owners_only", "owners_only",
"announcements", "announcements",
"down_to",
"the_start",
] { ] {
assert!(lookup("en", key).is_some(), "en missing {key}"); assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}"); assert!(lookup("no", key).is_some(), "no missing {key}");
+61
View File
@@ -297,6 +297,67 @@ main.not-found {
} }
} }
/* The way down the tree: one level toward the front door, from any
page. Fixed at the left edge, half-way up, over the margin rather
than the text; small, and half-faded until touched, so it never
covers what a visitor is reading. */
.down-the-tree {
position: fixed;
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 2.6rem;
height: 2.6rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--card-bg-opaque);
color: var(--ink);
box-shadow: 0 0.2rem 0.8rem var(--card-shadow);
opacity: 0.7;
transition: opacity 120ms, transform 120ms;
}
.down-the-tree:hover,
.down-the-tree:focus-visible {
opacity: 1;
transform: translateY(-50%) translateX(-0.12rem);
}
.down-the-tree svg {
width: 1.3rem;
height: 1.3rem;
}
@media (max-width: 43.75rem) {
.down-the-tree {
left: 0.25rem;
width: 2.2rem;
height: 2.2rem;
opacity: 0.55;
}
}
.down-the-tree-foot {
position: relative;
z-index: 1;
text-align: center;
margin: 2.6rem auto 0;
font-size: 0.95rem;
}
.down-the-tree-foot a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.down-the-tree-foot a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.question-nav { .question-nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;