fix: the controls sit below the log, and ending a session greys the page
Some checks failed
ci / check (push) Failing after 4s

Tier S (fixes inside a boundary; chaos d8=3, no override). Two
observations from play, both about what the page says has happened.

The controls moved BELOW the log: you read what happened, then decide what
to do next. They were above it, which asks for the decision first.

And sealing now marks the WHOLE PAGE inert, not just 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
a record of it. The status line stays legible on purpose -- it is the one
thing still worth reading.

THE STUB NEEDED A BODY classList TO MAKE THAT TESTABLE. Without it "the
session visibly ended" would have been a claim about CSS with nothing
checking it, which is precisely CB-WP-0016's finding: a stub too thin to
express a failure is how the failure survives. The harness reports the
sealed page through the status channel with a NUL-separated marker --
ugly, deliberate, and documented, because widening the return type would
touch every caller for one boolean.

Both directions asserted: a `closed` reply seals the page, an `ok:
dealing` reply does NOT -- otherwise the seal test would pass for a page
that greys itself whenever it is touched, breaking `play again`.

63 render tests, 26 cb-play, check and loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 22:38:32 +02:00
parent b12725566b
commit 51efe55d14
5 changed files with 224 additions and 3 deletions

View file

@ -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("</div>");
}
// 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,
"<div class=\"row\">\
@ -1349,7 +1363,6 @@ pub fn ending(
<div class=\"card btn tap\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
</div>",
);
log_section(&mut s, log);
s.push_str("</div></div>");
let _ = write!(
s,

View file

@ -55,10 +55,18 @@ pub fn scripts(html: &str) -> Vec<String> {
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<Vec<Posted>, 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 {

View file

@ -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("<h2>log</h2>").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"
);
}
}