clay-borg/crates/cb-render-html/src/lib.rs

336 lines
12 KiB
Rust
Raw Normal View History

CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
//! `cb-render-html` — stage 1's renderer (ADR-0007).
//!
//! Emits HTML with inline SVG and one small piece of inline JavaScript;
//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no
//! third-party dependencies at all, and adding one requires an argument
//! against ADR-0007 §Decision 3.
//!
//! ## What this is not
//!
//! It is **not** `cb-render-api`, and there is no `cb-render-null`.
//! ADR-0007 Decision 2 withdrew the port: a capability port designed
//! against one implementation that emits whole documents acquires a
//! document's shape, and stage 2's `wgpu` renderer would find it
//! unimplementable. INTENT's rule is the one that binds —
//!
//! > *No concept becomes canonical merely because it looks general. It
//! > becomes canonical after surviving a second concrete use.*
//!
//! — so this renders against the existing [`cb_game_runtime::Project`]
//! trait, which is a real interface with real implementations, and the
//! port is declared at stage 2 when there are two.
//!
//! ## The controls, and why they exist
//!
//! CB-WP-0011 established that a renderer's defect class is **silent
//! omission**: every assertion a renderer test naturally makes is
//! satisfied by a renderer showing a third of the state. This crate moves
//! the interactive half of stage 1 into a language `cargo test`,
//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that
//! finding across the boundary:
//!
//! * [`input`] — **JavaScript may not construct commands.** The page
//! reports raw pointer facts; Rust decides what they mean, against the
//! legal list the aggregate already offered.
//! * the coverage gate below — asserts over the **parsed emitted
//! document**, not over the Rust that emits it. Asserting over the
//! emitting code would reproduce CB-WP-0011's original defect one layer
//! up.
//!
//! And [`serve`] carries the four that make a loopback listener safe to
//! have, of which the load-bearing one is a test that a token-less request
//! is refused.
pub mod doc;
pub mod input;
CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind ADR-0009: embed quick-js; node is refused. Measured marginal cost against the dev-toolchain graph, under the positive control: boa_engine 896,410 rquickjs 69,985 quick-js 11,434 node 0 <- and that zero is the problem ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97, which has no node, so the test would make our build fetch a JS runtime of tens of millions of unaudited lines while scoring zero on the only instrument that governs dependencies. A browser is exempt because a developer has one regardless of us; a CI-installed runtime is not. The loop is now closed: the real server serves the real page, QuickJS runs that page's own scripts, the gesture goes over a real socket, and the seat's Choice comes back. Before this, every link was tested and the chain was not — a page whose JavaScript sent something else entirely would have passed everything. Three controls, each red for its stated reason: the JS posting a command name instead of ids, the gesture not being delivered (EXPECT-VACUOUS), and the token stripped from the endpoint. A wrong assertion worth keeping: the first draft required the body not to contain "attack". It legitimately does — action-attack is the id of an element a finger landed on. An element may name an action; that is not the page deciding. The real test is the shape: exactly two fields, down and up, carrying two ids and nothing derived from them. AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's headroom; after landing AM-4b did not move at all. It measures games-ground --edges normal — one package, no dev edges. Measured, the workspace including dev edges is 725,258 lines against AM-4b's 317,021: 408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap, ciborium, quick-js). The decision stands on the acquisition rule; the affordability argument is withdrawn. Third defect in the AM-4 family. Also fixed structurally rather than by raising a limit: `make status` had grown past its 40-line readability gate as workplans accumulated. Closed workplans now collapse to one line, so the report is fixed-size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
#[cfg(any(test, feature = "js-harness"))]
pub mod jsrun;
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
pub mod serve;
pub use doc::{document, text_of};
pub use input::{resolve, PointerFact};
pub use serve::{Guard, Refusal, Request};
#[cfg(test)]
mod coverage {
//! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s
//! `every_view_field_is_classified`.
//!
//! Same shape as CB-WP-0011's gate, one layer further out: walk the
//! serialized `GroundView` for every leaf *path*, and require each to
//! be either rendered — with a token that must appear in the **parsed
//! document** — or omitted with a stated reason. A new field on
//! `GroundView` that is in neither list fails the build.
//!
//! Paths, not keys: `problem` occurs under a DARVO target, a GROUND
//! choice and a Selection, and a key-set walk would let one vouch for
//! the other two.
use cb_kernel::PlayerId;
use games_ground::view::GroundView;
use crate::doc::{document, text_of};
/// Every leaf path in the serialized view.
fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec<String>) {
match v {
serde_json::Value::Object(m) if !m.is_empty() => {
for (k, val) in m {
let p = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
paths(val, &p, out);
}
}
serde_json::Value::Array(a) if !a.is_empty() => {
for item in a {
paths(item, &format!("{prefix}.*"), out);
}
}
_ => out.push(prefix.to_string()),
}
}
/// Collapse map keys to `*` so the classification is over fields, not
/// over whichever seats and priorities the fixture happens to hold.
fn normalize(path: &str) -> String {
const MAPS: &[&str] = &[
"players",
"relations",
"problems",
"focus",
"selections",
"ground_modes",
"ground_choices",
"support_responses",
"darvo_targets",
"personal",
];
let mut parts: Vec<String> = Vec::new();
let mut prev_is_map = false;
for seg in path.split('.') {
if prev_is_map && seg != "*" {
parts.push("*".to_string());
} else {
parts.push(seg.to_string());
}
prev_is_map = MAPS.contains(&seg);
}
parts.join(".")
}
/// `(leaf path, a token the parsed document must contain)`.
const RENDERED: &[(&str, &str)] = &[
("round", "round 3"),
("lead", "lead P2"),
("step", "step Select"),
("mode", "scoring BondedCoalitions"),
("viewer", "viewing as P1"),
("solution_deck_len", "17 remaining"),
("solution_discard.*.suit", "discard Repair Change"),
("players.*.stress", "stress 5"),
("players.*.protection", "protect 2"),
("players.*.darvo", "darvo Reverse"),
("players.*.freedom_ready", "READY"),
("players.*.freedom_gate_lifted", "gate lifted"),
("players.*.blame_from.*", "blamed by P3"),
// The empty case is a leaf path of its own, and renders as an
// explicit absence rather than as nothing at all.
("players.*.blame_from", "blamed by none"),
("players.*.hand.*.suit", "hand Clarify Boundary"),
("players.*.hand_size", "cards)"),
("relations.*", "Rivalry"),
("problems.*.state", "face down"),
("problems.*.suit", "Change 6"),
("problems.*.value", "Change 6"),
("problems.*.denied", "denied"),
("problems.*.claimed_by", "claimed by P2"),
("problems.*.protected_this_round", "protected"),
("focus.*", "\u{2192}P3"),
("selections.*.state", "face down"),
("selections.*.action", "action: Attack"),
("selections.*.target", "target: Some(PlayerId(1))"),
("selections.*.problem", "problem: Some(7)"),
("ground_modes.*", "ground mode Gr"),
("ground_choices.*.choice", "ProtectProblem"),
("ground_choices.*.problem", "problem: 7 }"),
("support_responses.*", "support AcceptBond"),
("darvo_targets.*.problem", "problem: Some(1)"),
("darvo_targets.*.player", "player: Some(PlayerId(1))"),
("outcome.total", "total 9"),
("outcome.threshold", "of 12"),
("outcome.group_success", "failure"),
("outcome.personal.*", "P1 +3"),
("outcome.coalitions.*.members.*", "members: [PlayerId(1)"),
("outcome.coalitions.*.score", "score: 4"),
("outcome.mastery", "mastery +1"),
("outcome.winners.*", "winners P1"),
];
/// Deliberate omissions, each with a reason.
const OMITTED: &[(&str, &str)] = &[(
"players.*.hand",
"null for a non-viewer seat; the absence is rendered as a count",
)];
fn fixture() -> GroundView {
crate::testfix::view(Some(PlayerId(0)))
}
fn rendered_document(view: &GroundView) -> String {
text_of(&document(
view,
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
))
}
#[test]
fn every_view_field_is_classified_in_the_emitted_document() {
let view = fixture();
let json = serde_json::to_value(&view).expect("view serializes");
let mut raw = Vec::new();
paths(&json, "", &mut raw);
let all: std::collections::BTreeSet<String> = raw.iter().map(|p| normalize(p)).collect();
// EXPECT-VACUOUS floor. A coverage test over zero paths passes
// trivially, and that is precisely how this check would rot.
assert!(
all.len() >= 30,
"the walk found {} leaf path(s) — it is not walking the view",
all.len()
);
let claimed: std::collections::BTreeSet<&str> = RENDERED
.iter()
.map(|(p, _)| *p)
.chain(OMITTED.iter().map(|(p, _)| *p))
.collect();
let unclassified: Vec<&String> = all
.iter()
.filter(|p| !claimed.contains(p.as_str()))
.collect();
assert!(
unclassified.is_empty(),
"unclassified path(s) in GroundView: {unclassified:?} \
add each to RENDERED with a token, or to OMITTED with a reason"
);
let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect();
assert!(
stale.is_empty(),
"classified path(s) no longer exist in GroundView: {stale:?}"
);
// The half that makes it a rendering gate rather than a bookkeeping
// one: the token must be in the *parsed document*.
let text = rendered_document(&view);
let missing: Vec<&str> = RENDERED
.iter()
.filter(|(_, tok)| !text.contains(tok))
.map(|(p, _)| *p)
.collect();
assert!(
missing.is_empty(),
"claimed rendered, but absent from the emitted document: {missing:?}"
);
}
/// The parse must be a parse. If `text_of` returned the raw source, a
/// token hiding in a comment, a style rule or the script would count
/// as rendered — which is the loophole control 6 exists to close.
#[test]
fn the_parse_does_not_see_script_or_style_contents() {
let html = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
<script>var s='SCRIPTTOKEN';</script>";
let text = text_of(html);
assert!(text.contains("seen"));
assert!(text.contains("pid"), "element ids are addressable surface");
assert!(
!text.contains("STYLETOKEN"),
"style contents leaked into the text"
);
assert!(
!text.contains("SCRIPTTOKEN"),
"script contents leaked into the text"
);
}
/// Control 5, asserted at the document level: the emitted JavaScript
/// must contain no game vocabulary. If a rule ever needs to live in
/// the page, ADR-0007 says revisit the decision, not widen this.
#[test]
fn the_emitted_javascript_contains_no_game_vocabulary() {
let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false);
let script = doc
.split_once("<script>")
.and_then(|(_, r)| r.rsplit_once("</script>"))
.map(|(s, _)| s.to_string())
.expect("the document carries a script");
for word in [
"Investigate",
"Solve",
"Support",
"Attack",
"Ground",
"darvo",
"DARVO",
"stress",
"freedom",
"problem",
"coalition",
"reveal",
"resolve",
] {
assert!(
!script.contains(word),
"the emitted JavaScript mentions {word:?} — control 5 is breached"
);
}
// And it must still be doing its one job.
assert!(script.contains("pointerdown") && script.contains("pointerup"));
}
/// The renderer must not invent a hand it was never given.
///
/// Scoped deliberately: the *projection's* hiding rules are asserted
/// where they live, and re-asserting them here would be a duplicated
/// fact that drifts. What this checks is the renderer's own failure
/// mode — that `hand: None` renders as an absence, for every seat, and
/// that a spectator's document contains no open hand at all.
#[test]
fn the_renderer_never_invents_a_hand_it_was_not_given() {
for seat in [0u8, 1, 2] {
let view = crate::testfix::view(Some(PlayerId(seat)));
let text = rendered_document(&view);
let shown = text.matches("cards)").count();
assert_eq!(
shown,
1,
"P{} sees {shown} open hands in the document; it was given exactly one",
seat + 1
);
assert_eq!(
text.matches("card(s), hidden").count(),
2,
"the other two seats' hands must render as an absence with a count"
);
}
let text = rendered_document(&crate::testfix::view(None));
assert_eq!(
text.matches("cards)").count(),
0,
"a spectator document shows an open hand"
);
assert!(text.contains("a spectator (no hands)"));
}
}
#[cfg(test)]
mod testfix;