Side B implements a substantial, coherent feature (self vs children fetch, SSE-as-JS-eval streaming, subreddit listing import, ranking panel refactor into connected components/unranked lists) with corresponding test fixture and integration test updates. Side A is a trivial cosmetic tweak (breadcrumb text, CSS color/spacing, removed a heading) with no functional value beyond styling.
constitution · epochs · watch · epoch 3
c_2e0ba3ceee22 (tommy-mor) vs c_f7f1b6183703 (tommy-mor)
download prompt · raw event · cmp_06e9b4c91ebb64
council reasoning
Side A only tweaks presentation (breadcrumb label, remove ranking h2, accent color/font-size). Side B adds lasting product capability: separate self vs children Reddit fetch, listing import/link_child, multi-component ranking UI, unified SSE-as-JS morphs, OAuth base split, and test fixtures—real design and features versus cosmetic churn.
Side B introduces substantial functionality and architecture changes: it adds separate fetch modes for entities versus children, extends the Reddit import pipeline with FetchKind, child-list parsing and linking, updates SSE handling to stream executable UI morphs, and redesigns ranking to handle connected components and unranked items. Side A only makes presentation tweaks, such as changing the breadcrumb root label to "~", removing a ranking heading, and adjusting CSS typography and colors.
sides
A — c_2e0ba3ceee22 (tommy-mor)
message
[8fce2727] looks better
diff preview
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 0acba3ac745f23be473243e0a9b9cc6e57e8d86f..2864407ed6e8ec284a1dc663acf1805534566b62 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -138,7 +138,7 @@ fn segment_label(seg: &str) -> &str {
pub fn breadcrumb_path(item: &ItemId) -> Markup {
html! {
nav class="breadcrumbs" aria-label="Breadcrumb" {
- a href="/" { "Internet" }
+ a href="/" { "~" }
@for path in item.breadcrumb_paths() {
@let seg = path.segments().last().map_or("", |v| *v);
span class="separator" { " / " }
@@ -198,12 +198,6 @@ pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {
let (top, bottom) = top_bottom(group, 8);
html! {
section id="ranking-panel" class="demo-panel" {
- h2 {
- "Ranking"
- @if !item.is_root() {
- " — " span class="scope-name" { (item.as_str()) }
- }
- }
@if total == 0 {
p class="muted" {
@if item.is_root() {
diff --git a/server/static/sorter.css b/server/static/sorter.css
index a648e88a57290346b1069868134a212b180def52..36f68d415cea33c8562d5d02c0c9d4c5b225a782 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -104,7 +104,7 @@ code {
padding: 0.5rem;
border: 1px solid var(--border);
background: var(--bg);
- color: var(--fg);
+ color: var(--accent);
font-size: 1rem;
font-family: inherit;
resize: vertical;
@@ -120,7 +120,8 @@ code {
}
.breadcrumbs {
- font-size: 0.875rem;
+ font-size: 1rem;
+ margin-top: 1rem;
margin-bottom: 1rem;
color: var(--muted);
}
B — c_f7f1b6183703 (tommy-mor)
message
[902a4c5c] refactor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 7af6527d03c483f33f3469ce6766c01a554c5fe3..d1defd28242fd2ca3b886adc91a7070bef75e653 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -56,12 +56,9 @@ pub async fn post_ui_html(
return ui_js_warn(&e).into_response();
}
let tree = state.tree.read().await;
- let empty = crate::reducer::GroupState::new();
- let group = tree
- .get(&parent)
- .map(|n| &n.local_ranking)
- .unwrap_or(&empty);
- let panel = ranking_panel(&parent, group);
+ let empty = crate::reducer::NodeState::default();
+ let node = tree.get(&parent).unwrap_or(&empty);
+ let panel = ranking_panel(&parent, node);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
@@ -88,9 +85,9 @@ pub async fn post_ui_html(
.into_response()
}
},
- HtmlUiAction::FetchEntity { item } => {
+ HtmlUiAction::FetchEntity { item, kind } => {
let id = parse_item_param(&item);
- fetch::fetch_entity_stream(state, id).into_response()
+ fetch::fetch_entity_stream(state, id, kind).into_response()
}
}
}
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index 63634508496e224c38b9ec0308b7a6086462f925..9b6c2d0964640a157bca2a6a64cb0820238d8e4f 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -5,7 +5,7 @@ use maud::{html, Markup};
use crate::{
form_template::template_json_compact,
path_types::ItemId,
- reddit::is_fetchable,
+ reddit::{is_children_fetchable, is_fetchable},
reducer::NodeState,
ui_action::UI_RPC_FIELD,
};
@@ -26,25 +26,16 @@ fn entity_panel(node: &NodeState) -> Markup {
}
}
-/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.
-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
- if !is_fetchable(item) {
- return html! {};
- }
- let label = if fetching {
- "Fetching…"
- } else if has_data {
- "Fetch more"
- } else {
- "Fetch from Reddit"
- };
+/// One `fetch_entity` form/button targeting `kind` ("self" or "children").
+fn fetch_button(item: &ItemId, kind: &str, label: &str, fetching: bool) -> Markup {
let rpc = template_json_compact(&serde_json::json!({
"action": "fetch_entity",
"item": item.as_str(),
+ "kind": kind,
}))
.expect("fetch_entity rpc template");
html! {
- form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+ form method="post" action="/ui" class="fetch-entity-form" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
@if fetching {
button type="submit" class="btn-secondary" disabled { (label) }
@@ -55,6 +46,33 @@ pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Mark
}
}
+/// Reddit/API import controls — `POST /ui` with `fetch_entity` returns an SSE
+/// stream whose events are JS snippets to `eval`.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+ let self_ok = is_fetchable(item);
+ let children_ok = is_children_fetchable(item);
+ if !self_ok && !children_ok {
+ return html! {};
+ }
+ let self_label = if fetching {
+ "Fetching…"
+ } else if has_data {
+ "Refresh this"
+ } else {
+ "Fetch from Reddit"
+ };
+ html! {
+ div id="fetch-controls" class="fetch-controls" {
+ @if self_ok {
+ (fetch_button(item, "self", self_label, fetching))
+ }
+ @if children_ok {
+ (fetch_button(item, "children", if fetching { "Fetching…" } else { "Fetch posts" }, fetching))
+ }
+ }
+ }
+}
+
/// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).
pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
let has_data = node.data.is_some();
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
index 2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3..0177bb161cea1b72a100b52efdfc5e710271c9eb 100644
--- a/server/src/fetch/mod.rs
+++ b/server/src/fetch/mod.rs
@@ -1,4 +1,8 @@
//! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).
+//!
+//! Each SSE event's `data` is a JS snippet that the browser `eval`s — the same
+//! Idiomorph-morph snippets the non-streaming `/ui` responses use. There is no
+//! bespoke JSON envelope; the client just evals whatever each event carries.
pub mod html;
@@ -8,14 +12,15 @@ use std::time::Duration;
use async_stream::stream;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::Stream;
-use serde::Serialize;
use tokio::sync::oneshot;
use crate::{
+ html::{ranking_panel, JsBuilder},
path_types::ItemId,
- reddit::FetchJobResult,
+ reddit::{FetchJobResult, FetchKind},
reducer::NodeState,
state::AppState,
+ ui_action::FetchTarget,
};
pub fn now_ms() -> i64 {
@@ -25,55 +30,62 @@ pub fn now_ms() -> i64 {
t.as_millis() as i64
}
-#[derive(Serialize)]
-struct SseMorphPayload {
- selector: &'static str,
- html: String,
+fn js_event(js: String) -> Event {
+ Event::default().data(js)
}
-fn morph_complete_event(html: maud::Markup) -> Event {
- let payload = SseMorphPayload {
- selector: "#entity-section",
- html: html.into_string(),
- };
- let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
- Event::default().event("complete").data(data)
+/// JS that surfaces a transient message in the page's `#errors` region.
+fn error_js(message: &str) -> String {
+ JsBuilder::new()
+ .morph_selector(
+ "#errors",
+ maud::html! { div id="errors" { p class="muted" { (message) } } },
+ )
+ .build()
}
-/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].
+/// Stream Idiomorph-morph JS snippets for [`crate::ui_action::HtmlUiAction::FetchEntity`].
pub fn fetch_entity_stream(
state: AppState,
id: ItemId,
+ target: FetchTarget,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
- tracing::debug!(item = %id, "fetch entity stream opened");
+ let kind = match target {
+ FetchTarget::SelfEntity => FetchKind::SelfEntity,
+ FetchTarget::Children => FetchKind::Children,
+ };
+ tracing::debug!(item = %id, ?kind, "fetch entity stream opened");
let stream = stream! {
if id.is_root() {
- yield Ok(Event::default().event("error").data("{\"message\":\"nothing to fetch for the root\"}"));
+ yield Ok(js_event(error_js("Nothing to fetch for the root.")));
return;
}
- if !crate::reddit::is_fetchable(&id) {
- tracing::debug!(item = %id, "fetch stream: not fetchable");
- yield Ok(Event::default().event("error").data("{\"message\":\"this page cannot be fetched from Reddit\"}"));
+ let fetchable = match kind {
+ FetchKind::SelfEntity => crate::reddit::is_fetchable(&id),
+ FetchKind::Children => crate::reddit::is_children_fetchable(&id),
+ };
+ if !fetchable {
+ tracing::debug!(item = %id, ?kind, "fetch stream: not fetchable");
+ yield Ok(js_event(error_js("This page cannot be fetched from Reddit.")));
return;
}
- let fetching_html = {
+ // Optimistic "Fetching…" morph of the entity section.
+ let fetching_js = {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- html::entity_section(&id, node, true).into_string()
+ JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, true))
+ .build()
};
- let fetching_payload = serde_json::json!({
- "selector": "#entity-section",
- "html": fetching_html,
- });
- yield Ok(Event::default().event("fetching").data(fetching_payload.to_string()));
+ yield Ok(js_event(fetching_js));
let (tx, rx) = oneshot::channel();
- state.reddit.request_fetch(id.clone(), true, Some(tx));
- tracing::debug!(item = %id, "fetch stream: queued reddit job");
+ state.queue_entity_fetch(id.clone(), kind, Some(tx));
+ tracing::debug!(item = %id, ?kind, "fetch stream: queued reddit job");
let result = match rx.await {
Ok(r) => r,
@@ -82,31 +94,42 @@ pub fn fetch_entity_stream(
FetchJobResult::Failed("reddit worker stopped".into())
}
};
-
tracing::debug!(item = %id, ?result, "fetch stream: job finished");
match result {
- FetchJobResult::Imported | FetchJobResult::NotFound => {
+ FetchJobResult::Imported(_)
+ | FetchJobResult::NotFound
+ | FetchJobResult::SkippedCached
+ | FetchJobResult::SkippedDuplicate => {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+ let mut b = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false));
+ if kind == FetchKind::Children {
+ b = b.morph_selector("#ranking-panel", ranking_panel(&id, node));
+ }
+ yield Ok(js_event(b.build()));
}
- FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {
+ FetchJobResult::RateLimited { reset_secs } => {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
- }
- FetchJobResult::RateLimited { reset_secs } => {
- yield Ok(Event::default().event("error").data(
- serde_json::json!({"message": format!("Reddit rate limit — retry in {reset_secs}s")}).to_string(),
- ));
+ let js = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false))
+ .raw(&error_js(&format!("Reddit rate limit — retry in {reset_secs}s.")))
+ .build();
+ yield Ok(js_event(js));
}
FetchJobResult::Failed(msg) => {
- yield Ok(Event::default().event("error").data(
- serde_json::json!({"message": msg}).to_string(),
- ));
+ let tree = state.tree.read().await;
+ let empty = NodeState::default();
+ let node = tree.get(&id).unwrap_or(&empty);
+ let js = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false))
+ .raw(&error_js(&format!("Fetch failed: {msg}")))
+ .build();
+ yield Ok(js_event(js));
}
}
};
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9314a7556306ddab969b896dbf4126b542a46722..27ce9118c73ec5643e04363ab1a36cf5da6101bf 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -6,12 +6,16 @@ use axum::{
};
use maud::{html, Markup, DOCTYPE};
+use std::collections::HashSet;
… preview truncated; 29,086 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.