Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.
The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.
Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bd12dd4e99
commit
74bba2e9b1
@@ -70,7 +70,10 @@ stream.
|
|||||||
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
||||||
parent(s), so a visitor's path through the questions is a verifiable
|
parent(s), so a visitor's path through the questions is a verifiable
|
||||||
lineage; `?chain=` links carry it, and self-service transitions
|
lineage; `?chain=` links carry it, and self-service transitions
|
||||||
(unsubscribe) authorize by holding one.
|
(unsubscribe) authorize by holding one. The question nav reads it
|
||||||
|
too: a `followup` page is offered only when it is on that path (the
|
||||||
|
page the chain verifiably answered) or further down from the current
|
||||||
|
page over `action` edges - one followup path never unlocks another.
|
||||||
- **Live resources** (`src/resource.rs`): content can pull a KV
|
- **Live resources** (`src/resource.rs`): content can pull a KV
|
||||||
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
||||||
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
||||||
|
|||||||
+38
-19
@@ -213,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|
|||||||
|(path, chain)| get_question(path, chain),
|
|(path, chain)| get_question(path, chain),
|
||||||
);
|
);
|
||||||
let user = Resource::new(|| (), |_| current_user());
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
|
let nav = Resource::new(
|
||||||
|
move || (path.get(), chain.get()),
|
||||||
|
|(path, chain)| list_qualifying_questions(path, chain),
|
||||||
|
);
|
||||||
let announcements = Resource::new(|| (), |_| list_announcements());
|
let announcements = Resource::new(|| (), |_| list_announcements());
|
||||||
provide_context(QuestionRes(question));
|
provide_context(QuestionRes(question));
|
||||||
provide_context(UserRes(user));
|
provide_context(UserRes(user));
|
||||||
@@ -289,7 +292,6 @@ fn QuestionView(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let question_id = question.id.clone();
|
let question_id = question.id.clone();
|
||||||
let has_chain = parent_hash.is_some();
|
|
||||||
|
|
||||||
// The provenance counterpart to the qualifies gate below: a
|
// The provenance counterpart to the qualifies gate below: a
|
||||||
// requires_chain page whose visitor holds no verifiable lineage to
|
// requires_chain page whose visitor holds no verifiable lineage to
|
||||||
@@ -318,7 +320,7 @@ fn QuestionView(
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
// A gate is never a dead end: the same nav as every page.
|
// A gate is never a dead end: the same nav as every page.
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -355,7 +357,7 @@ fn QuestionView(
|
|||||||
}}
|
}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -418,7 +420,7 @@ fn QuestionView(
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
view! { <ResponsibleNote responsible=r/> }
|
view! { <ResponsibleNote responsible=r/> }
|
||||||
})}
|
})}
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
@@ -427,8 +429,7 @@ fn QuestionView(
|
|||||||
/// 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).
|
||||||
#[component]
|
#[component]
|
||||||
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||||
let _ = has_chain; // keyed into the shared resource by PortalShell
|
|
||||||
let NavRes(nav) = expect_context();
|
let NavRes(nav) = expect_context();
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
@@ -2029,16 +2030,21 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
|||||||
Ok(state.site.load().as_ref().clone())
|
Ok(state.site.load().as_ref().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every question the current visitor qualifies for, as (id, name) -
|
/// Every question the visitor may be offered from `current`, as (id,
|
||||||
/// the site nav's data. Context-dependent: an owner session sees the
|
/// name) - the question nav's data. Front-door pages the visitor
|
||||||
/// gated pages too, and `followup` pages only appear once the visitor
|
/// qualifies for are always in (an owner session sees the gated desks
|
||||||
/// carries an answer chain.
|
/// too); `followup` pages and ended events only when they are on the
|
||||||
|
/// visitor's path - the page their verified `?chain=` answered - or
|
||||||
|
/// further down from `current` (see `content::outcomes_reachable_from`).
|
||||||
|
/// A chain unlocks the followup it came from, never every followup on
|
||||||
|
/// the site; an unverifiable hash unlocks nothing.
|
||||||
#[server(endpoint = "list_qualifying_questions")]
|
#[server(endpoint = "list_qualifying_questions")]
|
||||||
pub async fn list_qualifying_questions(
|
pub async fn list_qualifying_questions(
|
||||||
has_chain: bool,
|
current: String,
|
||||||
|
chain: Option<String>,
|
||||||
) -> Result<Vec<(String, String)>, ServerFnError> {
|
) -> Result<Vec<(String, String)>, ServerFnError> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
use crate::content::is_qualified;
|
use crate::content::{is_qualified, outcomes_reachable_from, Question};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
@@ -2049,14 +2055,27 @@ pub async fn list_qualifying_questions(
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let mut out: Vec<(String, String)> = state
|
let questions = state.questions.load();
|
||||||
.questions
|
// The one page the chain verifiably answered - the same lookup a
|
||||||
.load()
|
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||||
|
// to nothing relevant.
|
||||||
|
let answered: Option<String> = match &chain {
|
||||||
|
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
|
||||||
|
.await
|
||||||
|
.map(|node| node.question_id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
// An ended event page is a followup from then on.
|
||||||
|
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
|
||||||
|
let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome);
|
||||||
|
let mut out: Vec<(String, String)> = questions
|
||||||
.values()
|
.values()
|
||||||
.filter(|q| is_qualified(user.as_ref(), q))
|
.filter(|q| is_qualified(user.as_ref(), q))
|
||||||
// An ended event page is a followup from then on - only a
|
.filter(|q| {
|
||||||
// visitor carrying a chain (they answered it) still sees it.
|
!is_outcome(q)
|
||||||
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
|
|| answered.as_deref() == Some(q.id.as_str())
|
||||||
|
|| reach.contains(&q.id)
|
||||||
|
})
|
||||||
// A live event is announced in the header instead.
|
// A live event is announced in the header instead.
|
||||||
.filter(|q| !q.event_live_at(now_ms))
|
.filter(|q| !q.event_live_at(now_ms))
|
||||||
// A dynamic page has no URL of its own to link to.
|
// A dynamic page has no URL of its own to link to.
|
||||||
|
|||||||
+115
-2
@@ -42,8 +42,11 @@ pub struct Question {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub responsible: Option<Responsible>,
|
pub responsible: Option<Responsible>,
|
||||||
/// A page that only makes sense after answering something (the
|
/// A page that only makes sense after answering something (the
|
||||||
/// post-submission pages) - kept out of the question nav unless the
|
/// post-submission pages) - kept out of the question nav unless it is
|
||||||
/// visitor's context carries an answer chain. Unset means inferred
|
/// on the visitor's path (the page their verified `?chain=` answered)
|
||||||
|
/// or further down from the page they are on, over `action` edges
|
||||||
|
/// (see `outcomes_reachable_from`). One followup path never unlocks
|
||||||
|
/// another. Unset means inferred
|
||||||
/// from the file's place in the tree: files nested in a
|
/// from the file's place in the tree: files nested in a
|
||||||
/// subdirectory are followups unless they're the directory's
|
/// subdirectory are followups unless they're the directory's
|
||||||
/// `index.yaml`; top-level files keep the historical default
|
/// `index.yaml`; top-level files keep the historical default
|
||||||
@@ -922,6 +925,74 @@ pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>>
|
|||||||
Some(captures)
|
Some(captures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The question an alternative's `action` lands on, as a question id:
|
||||||
|
/// a plain path resolves directly, a templated one
|
||||||
|
/// (`/shape/{curve.key}`) to the dynamic page whose pattern has the
|
||||||
|
/// same shape (`/shape/[key]`). `None` when no page declares it.
|
||||||
|
pub fn action_target_id(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
action: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
if questions.contains_key(action) {
|
||||||
|
return Some(action.to_string());
|
||||||
|
}
|
||||||
|
let want: Vec<&str> = action.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
questions
|
||||||
|
.values()
|
||||||
|
.filter(|q| q.is_dynamic())
|
||||||
|
.map(|q| &q.id)
|
||||||
|
.find(|id| {
|
||||||
|
let pat: Vec<&str> = id.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
pat.len() == want.len()
|
||||||
|
&& pat
|
||||||
|
.iter()
|
||||||
|
.zip(&want)
|
||||||
|
.all(|(p, w)| p == w || (p.starts_with('[') && w.starts_with('{')))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome pages "further down" from `from`: every `is_outcome`
|
||||||
|
/// question reachable from it over `alternatives[].action` edges,
|
||||||
|
/// walking only *through* outcome pages. A front-door page (not an
|
||||||
|
/// outcome) is where the walk ends, so a "back to the start" action
|
||||||
|
/// never fans out into the whole site. `from` itself is never in the
|
||||||
|
/// result. This, plus the one page a visitor's verified chain answered,
|
||||||
|
/// decides which followups the question nav offers - one followup path
|
||||||
|
/// never unlocks another (an email-link page like redoal's `/watching`,
|
||||||
|
/// which nothing links to, stays out of every nav).
|
||||||
|
pub fn outcomes_reachable_from(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
from: &str,
|
||||||
|
is_outcome: &dyn Fn(&Question) -> bool,
|
||||||
|
) -> std::collections::HashSet<String> {
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut queue: VecDeque<Question> = VecDeque::new();
|
||||||
|
if let Some(q) = resolve_question(questions, from) {
|
||||||
|
queue.push_back(q);
|
||||||
|
}
|
||||||
|
while let Some(q) = queue.pop_front() {
|
||||||
|
for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) {
|
||||||
|
let Some(id) = action_target_id(questions, action) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`)
|
||||||
|
// is itself, not somewhere further down.
|
||||||
|
if id == from || path_matches(&id, from).is_some() || seen.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = &questions[&id];
|
||||||
|
if !is_outcome(target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(id.clone());
|
||||||
|
queue.push_back(target.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a URL path to its question: an exact id first, else the
|
/// Resolves a URL path to its question: an exact id first, else the
|
||||||
/// one dynamic page whose pattern matches, with each captured segment
|
/// one dynamic page whose pattern matches, with each captured segment
|
||||||
/// substituted into `{name}` placeholders in the clone's resource keys
|
/// substituted into `{name}` placeholders in the clone's resource keys
|
||||||
@@ -1961,6 +2032,48 @@ alternatives:
|
|||||||
assert!(!questions.contains_key("/review/_draft"));
|
assert!(!questions.contains_key("/review/_draft"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn followups_surface_by_reach_not_by_carrying_any_chain() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let files = vec![
|
||||||
|
// Front door with three outcomes, one of them templated onto
|
||||||
|
// a dynamic page.
|
||||||
|
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()),
|
||||||
|
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||||||
|
("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Nested -> followup by inference; leads on to /subscribed.
|
||||||
|
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Reached only by an out-of-band link: nothing points here.
|
||||||
|
("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()),
|
||||||
|
// A second, unrelated path with its own outcome.
|
||||||
|
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()),
|
||||||
|
("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
];
|
||||||
|
let questions = build_questions(&files).unwrap();
|
||||||
|
let is_outcome = |q: &Question| q.is_followup();
|
||||||
|
let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome);
|
||||||
|
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
// Templated actions resolve to the dynamic page's pattern.
|
||||||
|
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
|
||||||
|
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
||||||
|
|
||||||
|
// From the front door: its own outcomes, and what they lead on
|
||||||
|
// to - but not the other path's outcome, nor the orphan.
|
||||||
|
assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"]));
|
||||||
|
// The other path only sees its own outcome. Its "back to /"
|
||||||
|
// edge ends the walk at the front door instead of fanning out.
|
||||||
|
assert_eq!(reach("/kontakt"), set(&["/takk"]));
|
||||||
|
// Standing on an outcome: nothing further down through outcomes.
|
||||||
|
assert_eq!(reach("/subscribed"), set(&[]));
|
||||||
|
assert_eq!(reach("/takk"), set(&[]));
|
||||||
|
// The email-link page reaches nothing and nothing reaches it.
|
||||||
|
assert_eq!(reach("/watching"), set(&[]));
|
||||||
|
// A concrete dynamic path resolves to its pattern and walks on;
|
||||||
|
// the pattern itself is not "further down" from its own page.
|
||||||
|
assert_eq!(reach("/shape/abc"), set(&["/subscribed"]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn declared_id_wins_over_path() {
|
fn declared_id_wins_over_path() {
|
||||||
let files = vec![(
|
let files = vec![(
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ a {
|
|||||||
main.loading,
|
main.loading,
|
||||||
main.not-found {
|
main.not-found {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
|
/* svh so wrapper browsers that resize the webview as their toolbar
|
||||||
|
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
|
||||||
|
small viewport is a fixed value. Same ladder as .hero-piece. */
|
||||||
|
min-height: 60svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user