diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 3b7c3e9..ac9a5ed 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -92,6 +92,13 @@ pub const SCRIPT: &str = r#"
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
// lifecycle word, exactly like the 'ok' branch below it.
function seal() {
+ // The WHOLE page goes inert, not only the controls. A greyed-out
+ // button beside a full-colour table still reads as a live game with
+ // one broken control; the session has ended and everything on screen
+ // is now a record of it.
+ if (document.body && document.body.classList) {
+ document.body.classList.add('sealed-page');
+ }
var all = document.querySelectorAll('[data-drop]');
for (var i = 0; i < all.length; i++) {
all[i].classList.add('sealed');
@@ -264,6 +271,11 @@ 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)}
+/* The session has ended. Everything on screen is a record of a game that
+ is over, so the whole page says so -- not one greyed button beside a
+ live-looking table. */
+.sealed-page{filter:grayscale(.9);opacity:.5;pointer-events:none;transition:opacity .2s}
+.sealed-page #cb-status{filter:none;opacity:1;color:#fc9}
/* 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.
@@ -1341,7 +1353,9 @@ pub fn ending(
s.push_str("");
}
// Observations 6 and 7: the controls and the full log belong beside
- // the result, not under it.
+ // the result, not under it. And the controls go BELOW the log — you
+ // read what happened, then decide what to do next.
+ log_section(&mut s, log);
let _ = write!(
s,
"
\
@@ -1349,7 +1363,6 @@ pub fn ending(
end session \u{2014} stops the game server
\
",
);
- log_section(&mut s, log);
s.push_str("");
let _ = write!(
s,
diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs
index 82b9bcf..490c5e8 100644
--- a/crates/cb-render-html/src/jsrun.rs
+++ b/crates/cb-render-html/src/jsrun.rs
@@ -55,10 +55,18 @@ pub fn scripts(html: &str) -> Vec {
const DOM: &str = r#"
var __handlers = {};
var __status = { textContent: "" };
+// The body carries a real classList, because `seal` marks the whole page
+// inert on it (CB-WP-0028). A stub without one would make "the session
+// visibly ended" untestable -- and CB-WP-0016 established that a stub too
+// thin to express a failure is how the failure survives.
+var __bodyCls = {};
var __body = { children: [], appendChild: function (n) { this.children.push(n); n.parentNode = this; },
removeChild: function (n) { var i = this.children.indexOf(n);
if (i >= 0) { this.children.splice(i, 1); }
- n.parentNode = null; } };
+ n.parentNode = null; },
+ classList: { add: function (c) { __bodyCls[c] = true; },
+ remove: function (c) { delete __bodyCls[c]; },
+ contains: function (c) { return !!__bodyCls[c]; } } };
var __all = [];
// A DOM node with a real classList and real attributes. CB-WP-0016 found
@@ -169,6 +177,7 @@ function __liveKeys() {
return out.sort().join(',');
}
function __statusText() { return __status.textContent; }
+function __bodySealed() { return !!__bodyCls['sealed-page']; }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
@@ -221,6 +230,11 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String>
/// Returns `(live drop keys, status text)` — the keys still droppable
/// once the script has handled the server's answer.
///
+/// When the script has marked the whole page inert, the status carries a
+/// NUL-separated `sealed-page` marker. Ugly, and deliberate: widening the
+/// return type would touch every caller for one boolean, and the marker
+/// cannot occur in a server reply.
+///
/// **This path did not exist before CB-WP-0024 T01, and that is why the
/// defect it tests survived.** The fetch stub used to return a
/// `then`-chain that never called its callbacks, so nothing the script
@@ -259,6 +273,14 @@ pub fn gesture_with_reply(
let status: String = ctx
.eval_as("__statusText()")
.map_err(|e| format!("status: {e}"))?;
+ let sealed: bool = ctx
+ .eval_as("__bodySealed()")
+ .map_err(|e| format!("body sealed: {e}"))?;
+ let status = if sealed {
+ format!("{status}\u{0}sealed-page")
+ } else {
+ status
+ };
let keys = if keys.is_empty() {
Vec::new()
} else {
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 76db89f..5941d62 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -1326,6 +1326,28 @@ mod ending_page {
status.contains("session has ended"),
"the page must say what happened: {status:?}"
);
+ // The WHOLE page goes inert, not only the controls. A greyed
+ // button beside a full-colour table reads as a live game with one
+ // broken control.
+ assert!(
+ status.contains("sealed-page"),
+ "the page itself was not marked ended: {status:?}"
+ );
+ }
+
+ /// The controls sit **below** the log: you read what happened, then
+ /// decide what to do next.
+ #[test]
+ fn the_controls_come_after_the_log() {
+ let html = page();
+ let log = html.find("log
").expect("a log section");
+ let again = html
+ .find("data-drop=\"again\"")
+ .expect("a play-again control");
+ assert!(
+ again > log,
+ "the controls are above the log; the reader decides before reading"
+ );
}
/// **A thing you click must not look like a thing you drag.**
@@ -1470,6 +1492,12 @@ mod ending_page {
live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
"an 'ok' reply must not seal the page: {live:?}"
);
+ let (_, status) =
+ jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run");
+ assert!(
+ !status.contains("sealed-page"),
+ "a dealing reply greyed out a page that is about to be reused"
+ );
}
}
diff --git a/trials/2026-08-06-2230.md b/trials/2026-08-06-2230.md
new file mode 100644
index 0000000..834def4
--- /dev/null
+++ b/trials/2026-08-06-2230.md
@@ -0,0 +1,17 @@
+# Trial log
+
+What the player said while playing, bound to the position they said it at
+(CB-WP-0027, ADR-0014). The recording beside this file is the position;
+`state_hash` is what reaches it.
+
+**These are raw notes and they stay here.** Nothing in this file travels to
+`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a
+register finding, by a human, with the wording chosen then.
+
+
+
+| n | round | step | state_hash | comment |
+|---|---|---|---|---|
+| 1 | 5 | Reveal | bb9de8f0bfe5 | RespondToSupport is unclear as i cant see whos support i accept. |
+
+
diff --git a/trials/2026-08-06-2230.yaml b/trials/2026-08-06-2230.yaml
new file mode 100644
index 0000000..195eeda
--- /dev/null
+++ b/trials/2026-08-06-2230.yaml
@@ -0,0 +1,141 @@
+scenario: ground/cb-play-session
+description: recorded by cb-play (CB-WP-0008 T02)
+covers: []
+provisional: false
+provisional_owner: ''
+provisional_raised: ''
+ruled: ''
+ruled_by: ''
+ruled_note: ''
+encodes_u_item: ''
+seed: 2
+setup:
+ players: 3
+ preset: standard-3p
+ patch: {}
+commands:
+- actor: P1
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 1
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 1
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 1
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: P3
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 3
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 2
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: P3
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 3
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 4
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 4
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+expect:
+ events: []
+ state: {}
+ rejects: []
+ state_hash: 12e812982e92dde5e91147dfd67befb8552831c30e3053cfc5cdc5133d025d57