CB-WP-0027 T01-T04: the commentary track

The meta view beside the table, and a note channel that provably cannot
carry a move.

T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.

T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.

T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.

T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.

And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.

make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 10:37:46 +02:00
parent 5c6e322d5f
commit 4fb506fcd1
11 changed files with 1002 additions and 10 deletions

View file

@ -255,6 +255,25 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
styling alone would leave a dead control that still looks alive to
anything reading the DOM. */
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
/* CB-WP-0027 T02. `minmax(0,...)` on both tracks, because a grid child
defaults to min-content width and the SVG table would refuse to shrink,
pushing the meta column off-screen instead of narrowing.
The single-column fallback is deliberate rather than incidental: the
page was responsive by accident before this. */
.cb-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,24rem);
gap:1.2rem;align-items:start}
.cb-game{min-width:0}
.cb-meta{min-width:0;border-left:1px solid #2a3140;padding-left:1.1rem}
#cb-note{display:flex;flex-direction:column;gap:.5rem}
#cb-note textarea{width:100%;box-sizing:border-box;font:inherit;color:#dde;
background:#12141a;border:1px solid #445;border-radius:5px;padding:.5rem}
#cb-note button{align-self:flex-start;font:inherit;cursor:pointer;color:#dde;
background:#332a3a;border:1px solid #a7d;border-radius:5px;padding:.35rem .8rem}
.note{border-left:2px solid #a7d;padding-left:.6rem;margin:.4rem 0;white-space:pre-wrap}
@media (max-width:64rem){
.cb-cols{grid-template-columns:minmax(0,1fr)}
.cb-meta{border-left:none;border-top:1px solid #2a3140;padding-left:0;padding-top:1rem}
}
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
.played{display:block;margin:.3rem 0}
.k{color:#89a}
@ -679,7 +698,7 @@ pub fn document(
seat: Option<PlayerId>,
may_pass: bool,
) -> String {
document_with_log(view, legal, endpoint, seat, may_pass, &[])
document_with_log(view, legal, endpoint, seat, may_pass, &[], &[])
}
/// The table, plus the game log (CB-WP-0018 T02).
@ -690,6 +709,7 @@ pub fn document_with_log(
seat: Option<PlayerId>,
may_pass: bool,
log: &[LogLine],
meta: &[String],
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
@ -716,9 +736,16 @@ pub fn document_with_log(
},
);
// CB-WP-0027 T02: the table on the left, everything *about* the table
// on the right. The log moves right because it is commentary on the
// game rather than part of it.
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
body(&mut s, view);
move_section(&mut s, legal, seat, may_pass);
s.push_str("</div><div class=\"cb-meta\">");
meta_section(&mut s, meta);
log_section(&mut s, log);
s.push_str("</div></div>");
let _ = write!(
s,
"<div id=\"cb-status\">ready</div>\
@ -729,6 +756,40 @@ pub fn document_with_log(
s
}
/// The meta panel's own content: whatever the caller wants a player to see
/// *about* the session rather than about the position.
///
/// Empty is a legitimate state — a first game has no tally and may have no
/// notes — and renders as nothing rather than as an empty heading.
fn meta_section(s: &mut String, meta: &[String]) {
// CB-WP-0027 T03: the comment box. Always present — the panel's
// purpose is that a player can say something at any moment, and a box
// that appears only sometimes trains them not to look for it.
//
// A plain form POSTing to /note, so it works with the script disabled.
// The command channel needs JavaScript because a drag is not a form
// submission; a comment is, and making it depend on the script would
// add a failure mode for no gain.
s.push_str(
"<h2>what are you thinking?</h2>\
<form class=\"card\" method=\"post\" action=\"/note\" id=\"cb-note\">\
<textarea name=\"note\" rows=\"4\" placeholder=\"why this move, what is \
unclear, what is annoying \u{2014} bound to this position\"></textarea>\
<button type=\"submit\">note it</button></form>",
);
if meta.is_empty() {
return;
}
s.push_str("<h2>this session</h2><div class=\"card\">");
for (i, line) in meta.iter().enumerate() {
if i > 0 {
s.push_str("<br>");
}
s.push_str(&esc(line));
}
s.push_str("</div>");
}
/// The table itself: problems, relationships, seats, solutions, outcome.
///
/// Factored out of [`document`] so [`ending`] shows the SAME table rather

View file

@ -56,6 +56,91 @@ impl PointerFact {
}
}
/// What a test player wrote, and where they wrote it (CB-WP-0027,
/// ADR-0014 D1).
///
/// **A second channel that cannot carry a move.** ADR-0007 D5 governs the
/// *command* channel and is untouched: [`PointerFact`] still admits two
/// ids and refuses every other field. This type carries text, and there is
/// **no code path from it to a `GroundCommand`** — [`crate::resolve`]
/// takes a `PointerFact` and nothing else, so a note cannot become a move
/// by any route, including a future careless one.
///
/// If this struct ever grows a field the engine reads, that separation is
/// gone and ADR-0007 says to revisit the decision rather than widen the
/// control.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
/// The player's own words, verbatim. Never interpreted, only stored
/// and escaped on render.
pub text: String,
}
impl Note {
/// Parse the wire form `note=<text>`, percent-decoded.
///
/// Refuses unknown fields for the same reason `PointerFact` does: a
/// parser that ignores what it does not understand cannot tell a typo
/// from an attack.
pub fn parse(body: &str) -> Result<Self, String> {
let mut text = None;
for pair in body.split('&') {
match pair.split_once('=') {
Some(("note", v)) => text = Some(percent_decode(v)),
_ => return Err(format!("unrecognised field in note: {pair:?}")),
}
}
match text {
// An empty note is refused rather than stored: a blank line in
// the trial log is noise in the register (CB-WP-0027 T03).
Some(t) if !t.trim().is_empty() => Ok(Self {
text: t.trim().to_string(),
}),
_ => Err("an empty note is not recorded".to_string()),
}
}
}
/// `application/x-www-form-urlencoded` decoding, enough for one field.
///
/// Hand-rolled because this crate has **no third-party dependencies at
/// all** and ADR-0007 §D3 requires an argument before it acquires one —
/// which a form decoder does not merit.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(b) => {
out.push(b);
i += 3;
}
// A malformed escape is kept literally rather than
// dropped: losing characters silently is how a note
// stops meaning what its author wrote.
Err(_) => {
out.push(bytes[i]);
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn action_id(a: Action) -> &'static str {
match a {
Action::Investigate => "action-investigate",

View file

@ -48,7 +48,7 @@ pub mod jsrun;
pub mod serve;
pub use doc::{document, text_of};
pub use input::{resolve, PointerFact};
pub use input::{resolve, Note, PointerFact};
pub use serve::{Guard, Refusal, Request};
#[cfg(test)]
@ -599,6 +599,7 @@ mod gamelog {
Some(PlayerId(0)),
false,
log,
&[],
)
}
@ -706,6 +707,191 @@ mod piles {
}
}
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
#[cfg(test)]
mod notes {
use cb_kernel::PlayerId;
use crate::doc::{document_with_log, text_of};
use crate::input::{resolve, Note, PointerFact};
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
/// perfectly well-formed pointer fact must not become a move.
///
/// Structural, not vigilance: `Note::parse` returns a `Note`,
/// `resolve` takes a `PointerFact`, and nothing converts between
/// them. This asserts the behaviour anyway, because "the types don't
/// connect" is a claim about code layout until something checks it.
#[test]
fn a_note_that_looks_like_a_move_is_not_one() {
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
let note = Note::parse(hostile).expect("it parses as a note");
assert_eq!(
note.text, "down=action-solve&up=problem-1",
"the text is stored verbatim, uninterpreted"
);
// And the command parser refuses the same body outright — the two
// channels do not overlap even at the wire level.
assert!(
PointerFact::parse(hostile).is_err(),
"the command channel accepted a note body"
);
// The reverse, so this is not vacuous: a real pointer fact IS a
// command, and is NOT a note.
assert!(PointerFact::parse("down=a&up=b").is_ok());
assert!(
Note::parse("down=a&up=b").is_err(),
"the note channel accepted a pointer fact"
);
// Nothing in the crate turns a Note into a command. `resolve`'s
// signature is the proof; this pins it against a careless change.
let legal: Vec<games_ground::GroundCommand> = vec![];
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
}
/// An empty note is refused, not stored — a blank row is noise in the
/// register.
#[test]
fn an_empty_note_is_refused() {
assert!(Note::parse("note=").is_err());
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
assert_eq!(
Note::parse("note=%20hello%20").expect("real text").text,
"hello",
"surrounding whitespace is trimmed"
);
}
/// Percent and `+` decoding, since the form posts urlencoded.
#[test]
fn the_players_words_survive_the_wire() {
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
assert_eq!(n.text, "why is SOLVE doing nothing?");
}
/// **The first hostile input this renderer has handled.** Until now
/// `esc()` escaped suit names.
#[test]
fn a_note_containing_markup_renders_as_text() {
let hostile = "<script>alert(1)</script> & \"quoted\"";
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
&[hostile.to_string()],
);
assert!(
!html.contains("<script>alert(1)</script>"),
"a note's markup reached the document unescaped"
);
assert!(
html.contains("&lt;script&gt;"),
"the note should still be visible, escaped"
);
assert!(
text_of(&html).contains("alert(1)"),
"escaping must not eat the player's words — they still read what they wrote"
);
}
/// The comment box is always offered, and the page works without it
/// being used. A control that appears only sometimes trains a player
/// not to look for it.
#[test]
fn the_comment_box_is_always_there() {
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
&[],
);
assert!(html.contains("action=\"/note\""), "no comment box");
assert!(
html.contains("method=\"post\""),
"a plain form, so it works with the script disabled"
);
}
}
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
#[cfg(test)]
mod two_columns {
use cb_kernel::PlayerId;
use crate::doc::document_with_log;
fn page(meta: &[String]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
meta,
)
}
/// The columns exist, and the game is in the left one.
#[test]
fn the_table_is_in_the_game_column_and_the_log_is_not() {
let html = page(&[]);
let game = html
.split("class=\"cb-game\"")
.nth(1)
.and_then(|s| s.split("class=\"cb-meta\"").next())
.expect("a game column followed by a meta column");
assert!(
game.contains("<h2>problems</h2>"),
"the table must be in the game column"
);
assert!(
!game.contains("<h2>log</h2>"),
"the log belongs in the meta column — it is commentary on the game, not part of it"
);
}
/// **A player who writes nothing must not be worse off.** An empty
/// meta panel renders as nothing, not as an empty heading.
#[test]
fn an_empty_meta_panel_adds_no_furniture() {
assert!(
!page(&[]).contains("this session"),
"an empty session panel drew a heading with nothing under it"
);
assert!(
page(&["2 games this session".into()]).contains("this session"),
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
);
}
/// The single-column fallback is deliberate, not incidental. Asserted
/// on the stylesheet because there is no browser here to resize —
/// which is a weaker test than laying it out, and is said so rather
/// than dressed up.
#[test]
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
let html = page(&[]);
assert!(
html.contains("@media (max-width:64rem)"),
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
);
assert!(
html.contains("minmax(0,1fr)"),
"grid children default to min-content width; without minmax(0,…) the SVG table \
refuses to shrink and pushes the meta column off-screen"
);
}
}
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
///
/// The maintainer could follow the other players only by reading the log.