constitution · epochs · watch · epoch 3

comparison

c_c6f5afec1d36 (tommy-mor) vs c_48edc893c5b0 (tommy-mor)

download prompt · raw event · cmp_ee36f953d1f33d

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

Side B introduces a substantial new architectural mechanism—a generic form-template/RPC system (form_template.rs, ui_action.rs, ui_html.rs) with tests, unifying disparate POST handlers into a single /ui endpoint—which is broader infrastructure with lasting reuse value. Side A adds a genuinely useful but narrower feature (pool-scoped voting) that is well-integrated but smaller in scope and reuses B's own ui_html.rs machinery (built atop it), making B foundational and A incremental.

~x-ai/grok-latest · winner B · 2:1 · permalink

B introduces lasting UI architecture: the form_template hole-filler, typed HtmlUiAction + unified POST /ui dispatch, and shared web_post runners—infrastructure reused by later features. A is a solid, scoped product feature (pool-constrained pair picking and nav) but builds on that stack rather than setting a comparable foundation.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B introduces a reusable browser UI command infrastructure: a new `/ui` endpoint, `HtmlUiAction` parsing with JSON template hole substitution, shared `run_*` handlers for existing web actions, and routing/module integration. Side A delivers a useful feature by adding pool-scoped voting (`pool` query handling, next-pair navigation, and UI links), but it is a targeted capability layered on existing code, whereas Side B establishes broader architectural support that can be reused by future browser interactions.

sides

A — c_c6f5afec1d36 (tommy-mor)

message

[5350388a] Add pool-scoped voting: /vote?pool=<parent> picks pairs from children.

- /vote now accepts an optional `pool` param (parent item path). When
  provided without left/right, it picks the first unvoted pair from the
  pool's children. When provided alongside left/right, it constrains
  "next pair" navigation to siblings within the pool.
- "vote on children" button appears on item pages with ≥2 children,
  linking to /vote?pool=<item>.
- Pool is threaded through VoteComparePost → success JS so in-page
  morph after voting keeps the pool context for next-pair navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff preview

diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 5faa642451d69555cb391974beb0ad22c9355c8c..b79efdb4d52bd445a67f38cbfd61d3507d2b3014 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -193,6 +193,7 @@ async fn dispatch_ui_action(
             ratio_right,
             explanation,
             next,
+            pool,
             form_action,
         } => {
             if form_action != "/ui" {
@@ -239,6 +240,9 @@ async fn dispatch_ui_action(
                         .into_response();
                 }
             };
+            let pool_id = pool.as_deref().and_then(|p| {
+                crate::path_types::ItemId::parse(p.trim()).map(|i| i.normalized_storage())
+            });
             let mut rl = ratio_left.trim().parse::<i32>().unwrap_or(0).max(0);
             let mut rr = ratio_right.trim().parse::<i32>().unwrap_or(0).max(0);
             if rl == 0 && rr == 0 {
@@ -294,6 +298,7 @@ async fn dispatch_ui_action(
                         &thread_tag,
                         &left_id,
                         &right_id,
+                        pool_id.as_ref(),
                         pid.as_str(),
                         post_index,
                     )
diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs
index 5865cfdbc06aeb6555c97380e25591fae6b3d125..1820d7fe7ee167ecbf5ad5124f23b2ab92ffb01f 100644
--- a/server/src/html/garden/pin.rs
+++ b/server/src/html/garden/pin.rs
@@ -79,7 +79,7 @@ pub(super) fn ont_pin_vote_controls(
                         }
                     }
                 } @else {
-                    a class="ont-vote-compare-btn" href=(vote_compare_href(nav, pi, &current, None)) title="Compare and vote" {
+                    a class="ont-vote-compare-btn" href=(vote_compare_href(nav, pi, &current, None, None)) title="Compare and vote" {
                         span class="ont-vote-glyph" aria-hidden="true" { "⚖" }
                         span { "vote" }
                     }
@@ -121,7 +121,7 @@ pub(super) fn child_row_pin_or_vote(
                         if nv == 1 { "" } else { "s" },
                     );
                     @let aria = format!("Vote; {} pairwise {}", nv, if nv == 1 { "vote" } else { "votes" });
-                    a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title=(tip) aria-label=(aria) {
+                    a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None, None)) title=(tip) aria-label=(aria) {
                         span class="ont-garden-vote-glyph" aria-hidden="true" { "⚖" }
                         span class="ont-garden-vote-count" { (format!("{}", nv)) }
                     }
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index 5c3986ca36902f6c9019a4b5590f0b3b9d2cb4ff..bd8cbce059ee789de6e8dc9b6f69f54beee2f994 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -29,6 +29,7 @@ use super::{
     item::{child_depth_from_uri, item_code_label, item_display_path, item_href},
     item_page::{build_item_page_view_model, sibling_nav_markup},
     pin::{child_row_pin_or_vote, ont_pin_vote_controls, pinned_item_from_jar},
+    vote::vote_pool_href,
 };
 
 pub(super) async fn render_scope_view(
@@ -186,12 +187,21 @@ pub(super) async fn render_scope_view(
             }
 
             section class="ont-tab-panel ont-tab-panel-children" {
+                @let total_children = model.child_rankings.component_rankings
+                    .iter().map(|c| c.ranked.len()).sum::<usize>()
+                    + model.child_rankings.unranked_items.len();
                 h3 {
                     "ranked child groups"
                     @if model.child_depth > 1 {
                         " "
                         span class="muted" { (format!("(depth {})", model.child_depth)) }
                     }
+                    @if total_children >= 2 {
+                        " "
+                        a class="ont-vote-children-btn" href=(vote_pool_href(&nav, &model.item)) {
+                            "vote on children"
+                        }
+                    }
                 }
                 @if model.child_rankings.component_rankings.is_empty() {
                     p class="muted" { "no voted pairs yet in this scope" }
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index 6900a6795bcde866a176722149d6378e5127b4c3..c2036fca7752aef63d260e36cfe9649f63b5c550 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -105,7 +105,7 @@ fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() {
     let content = content_for_garden_view(&reduced, &ScopeId::Public);
     let a = ItemId::parse("~/topic/a").unwrap().normalized_storage();
     let b = ItemId::parse("~/topic/b").unwrap().normalized_storage();
-    let next = suggest_next_vote_pair(content, &a, &b).expect("next sibling pair");
+    let next = suggest_next_vote_pair(content, &a, &b, None).expect("next sibling pair");
     assert_ne!(
         canonical_edge_items(&next.0, &next.1),
         canonical_edge_items(&a, &b)
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index 1d7fc8aa7436bfa9c1186dfd940a8d751ab7e088..2682cfcbd2f834b56459a02831aa225cffe67c58 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -211,14 +211,15 @@ pub(crate) async fn vote_compare_post_success_js(
     _thread_tag: &str,
     left: &ItemId,
     right: &ItemId,
+    pool: Option<&ItemId>,
     _post_id: &str,
     _post_idx: Option<usize>,
 ) -> String {
     let reduced = state.reduced.read().await;
     let content = content_for_garden_view(&reduced, &nav.scope());
     let edge_history = vote_edge_history_markup(content, left, right);
-    let next_pair = suggest_next_vote_pair(content, left, right);
-    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref());
+    let next_pair = suggest_next_vote_pair(content, left, right, pool);
+    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);
     drop(reduced);
     JsBuilder::new()
         .morph_inner_selector("#vote-edge-history-region", edge_history)
@@ -231,27 +232,39 @@ pub(super) fn vote_compare_href(
     left: &ItemId,
     right: &ItemId,
     thread_override: Option<&str>,
+    pool: Option<&ItemId>,
 ) -> String {
     let left_q = urlencoding::encode(left.as_str());
     let right_q = urlencoding::encode(right.as_str());
-    let base = format!(
+    let mut base = format!(
         "{}/vote?left={}&right={}",
         nav.room_path_prefix_for_vote_compare(),
         left_q,
         right_q
     );
     if let Some(t) = thread_override.filter(|s| !s.is_empty()) {
-        format!("{}&thread={}", base, urlencoding::encode(t))
-    } else {
-        base
+        base = format!("{}&thread={}", base, urlencoding::encode(t));
+    }
+    if let Some(p) = pool {
+        base = format!("{}&pool={}", base, urlencoding::encode(p.as_str()));
     }
+    base
+}
+
+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
+    format!(
+        "{}/vote?pool={}",
+        nav.room_path_prefix_for_vote_compare(),
+        urlencoding::encode(pool_item_str)
+    )
 }
 
 fn vote_compare_nav_markup(
     nav: &ThreadNav,
     next_pair: Option<&(ItemId, ItemId)>,
+    pool: Option<&ItemId>,
 ) -> maud::Markup {
-    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));
+    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
     html! {
         div class="vote-compare-nav" {
             @if let Some(href) = &next_pair_href {
@@ -267,8 +280,15 @@ pub(super) fn suggest_next_vote_pair(
     content: &ContentState,
     current_left: &ItemId,
     current_right: &ItemId,
+    pool_parent: Option<&ItemId>,
 ) -> Option<(ItemId, ItemId)> {
-    let pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
+    let pool: Vec<ItemId> = if let Some(parent) = pool_parent {
+        content
+            .item_children
+            .get(parent)
+            .map(|s| s.iter().cloned().collect())
+            .unwrap_or_default()
+    } else if current_left.parent().as_ref().map(|p| p.as_str())
         == current_right.parent().as_ref().map(|p| p.as_str())
     {
         current_left
@@ -322,10 +342,14 @@ pub(super) fn vote_compare_item_card(
 }
 #[derive(Debug, Deserialize)]
 pub struct VoteCompareQuery {
-    pub left: String,
-    pub right: String,
+    #[serde(default)]
+    pub left: Option<String>,
+    #[serde(default)]
+    pub right: Option<String>,
     #[serde(default)]
     pub thread: Option<String>,
+    #[serde(default)]
+    pub pool: Option<String>,
 }
 
 /// Public pairwise vote UI — `/vote?left=&right=&thread=`.
@@ -376,17 +400,53 @@ async fn vote_compare_inner(
     jar: CookieJar,
     uri: Uri,
 ) -> axum::response::Response {
-    let left = match ItemId::parse(q.left.trim()) {
-        Some(i) => i.normalized_storage(),
-        None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
+    let pool_id: Option<ItemId> = match q.pool.as_deref() {
+        Some(p) => match ItemId::parse(p.trim()) {
+            Some(i) => Some(i.normalized_storage()),
+            None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(),
+        },
+        None => None,
     };
-    let right = match ItemId::parse(q.right.trim()) {
-        Some(i) => i.normalized_storage(),
-        None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
+
+    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {
+        (Some(l), Some(r)) => {
+            let left = match ItemId::parse(l.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
+            };
+            let right = match ItemId::parse(r.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
+            };
+            if left == right {
+                return (StatusCode::BAD_REQUEST, "items must differ").into_response();
+            }
+            (left, right)
+        }
+        (None, None) => {
+            let Some(pool) = pool_id.as_ref() else {
+                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
+            };
+            let reduced = state.reduced.read().await;
+            let content = content_for_garden_view(&reduced, &nav.scope());
+            let children: Vec<ItemId> = content
+                .item_children
+                .get(pool)
+                .map(|s| s.iter().cloned().collect())
+                .unwrap_or_default();
+            if children.len() < 2 {
+                drop(reduced);
+                return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response();
+            }
+            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);
+            drop(reduced);
+            match pair {
+                Some(p) => p,
+                None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
+            }
+        }
+        _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(),
     };
-    if left == right {
-        return (StatusCode::BAD_REQUEST, "items must differ").into_response();
-    }
 
     let reduced = state.reduced.read().await;
     let content = content_for_garden_view(&reduced, &nav.scope());
@@ -409,7 +469,7 @@ async fn vote_compare_inner(
     let left_body = content.item_bodies.get(&left).cloned();
     let right_body = content.item_bodies.get(&right).cloned();
     let item_bodies_for_cards = content.item_bodies.

… preview truncated; 1,501 characters omitted

download full diff A

B — c_48edc893c5b0 (tommy-mor)

message

[3f35edab] progress

diff preview

diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index a10ce662105cff8fad949c6b83f7035ce79bed18..a986f706ea4b261cbaf004c02b4cf84184b41371 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -3,6 +3,7 @@ mod helpers;
 mod rpc;
 mod stream;
 mod validate;
+mod ui_html;
 mod web_post;
 
 pub use auth::{
@@ -33,6 +34,7 @@ pub use stream::{get_html_stream, get_stream};
 
 pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};
 
+pub use ui_html::post_ui_html;
 pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};
 
 #[cfg(test)]
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b40a72059981d558768f73d189b991f3448c257
--- /dev/null
+++ b/server/src/api/ui_html.rs
@@ -0,0 +1,139 @@
+//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).
+
+use axum::{
+    body::Body,
+    extract::State,
+    http::{header, HeaderMap, StatusCode},
+    response::{IntoResponse, Response},
+    Form,
+};
+use axum_extra::extract::cookie::CookieJar;
+use std::collections::HashMap;
+
+use crate::{
+    api::{
+        auth::optional_principal,
+        web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},
+    },
+    html::{
+        fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
+        parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,
+        ThreadNav,
+    },
+    state::AppState,
+};
+
+pub async fn post_ui_html(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    jar: CookieJar,
+    Form(form): Form<HashMap<String, String>>,
+) -> impl IntoResponse {
+    let action = match parse_html_ui_from_form(&form) {
+        Ok(a) => a,
+        Err(e) => return ui_js_warn(&e.to_string()).into_response(),
+    };
+
+    match action {
+        HtmlUiAction::PostIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_post_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::CheckIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_check_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::RedactPost { post_id } => {
+            run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await
+        }
+        HtmlUiAction::ExpandPublicNewThreadForm => {
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            drop(reduced);
+            let markup = if user.is_some() {
+                fragment_public_new_thread_form(true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#public-new-thread-ui-slot", markup)
+                .into_response()
+        }
+        HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {
+            let room_wire = room_wire.trim().to_string();
+            if room_wire.is_empty() {
+                return ui_js_warn("missing room").into_response();
+            }
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            if !reduced.rooms.contains(&room_wire) {
+                drop(reduced);
+                return ui_js_warn("room not found").into_response();
+            }
+            if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {
+                drop(reduced);
+                return ui_js_warn("forbidden").into_response();
+            }
+            let can_post = user
+                .as_ref()
+                .map(|u| user_can_post_room(&reduced, &room_wire, u))
+                .unwrap_or(false);
+            drop(reduced);
+            let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
+                return ui_js_warn("bad room").into_response();
+            };
+            let markup = if can_post {
+                fragment_room_new_thread_form(&nav, true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#room-new-thread-ui-slot", markup)
+                .into_response()
+        }
+    }
+}
+
+fn ui_js_warn(msg: &str) -> Response {
+    use crate::html::js_string_literal;
+    let js = format!("console.warn({});", js_string_literal(msg));
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+        .body(Body::from(js))
+        .unwrap()
+}
diff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs
index a64010e382d3039821c836a5529adad0fe67cce5..265025f41ff1548d05b2d2d5d84202245388053f 100644
--- a/server/src/api/web_post.rs
+++ b/server/src/api/web_post.rs
@@ -222,8 +222,18 @@ pub async fn post_web_redact(
     jar: CookieJar,
     Form(form): Form<WebRedactForm>,
 ) -> impl IntoResponse {
+    run_post_web_redact(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`].
+pub(crate) async fn run_post_web_redact(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebRedactForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -239,8 +249,8 @@ pub async fn post_web_redact(
         return js_redirect("/login").into_response();
     };
 
-    match rpc_post_redact(&state, &headers, form.post_id).await {
-        Ok(RpcResult::RedactPostOk {}) => redact_success_response(&state).await.into_response(),
+    match rpc_post_redact(state, headers, form.post_id).await {
+        Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),
         Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(),
         Err((msg, hint)) => {
             let detail = hint.as_deref().unwrap_or("");
@@ -255,8 +265,18 @@ pub async fn post_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_post_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_post_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -282,8 +302,8 @@ pub async fn post_web_ingest(
             .into_response();
     }
 
-    match rpc_post_with_bearer(&state, &bearer, room.clone(), thread_tag.clone(), text).await {
-        Ok(RpcResult::PostOk { .. }) => post_success_response(&state, &form, &headers, &jar)
+    match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {
+        Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)
             .await
             .into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Post did not return PostOk.").into_response(),
@@ -297,8 +317,18 @@ pub async fn check_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_check_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_check_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -324,7 +354,7 @@ pub async fn check_web_ingest(
         return js_clear_errors(&form_error_target(&form)).into_response();
     }
 
-    match rpc_check_with_bearer(&state, &bearer, room, form.text.clone()).await {
+    match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {
         Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Check did not return CheckOk.").into_response(),
         Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or("")).into_response(),
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3709c2c09a859da006e4af173413d5d235bc19be
--- /dev/null
+++ b/server/src/form_template.rs
@@ -0,0 +1,142 @@
+//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from
+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**
+//! deserializing into a typed struct.
+//!
+//! # Wire format
+//!
+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty
+//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string
+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not
+//! bespoke encodings.
+//!
+//! # Power vs flat hidden fields
+//!
+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),
+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,
+//! arrays, and optional fields without inventing a new naming scheme each time.
+//!
+//! # Security
+//!
+//! Substitution runs **before** `serde` into your command type. It does not fix
+//! authorization: if the client can replace the hidden `__rpc__` value, they can
+//! change the command shape unless you validate (signed blob, server-side session
+//! context, or treat the blob as hints only). Same threat model as any hidden field.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String> {
+    serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+    match val {
+        Value::Object(map) => {
+            if map.len() == 1 {
+                if let Some(Value::String(field_name)) = map.get("$form") {
+                    let submitted = form_data
+                        .get(field_name.as_str())
+                        .map(|s| s.as_str())
+                        .unwrap_or("");
+   

… preview truncated; 13,273 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.