diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 533521e..4e3de65 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -79,6 +79,29 @@ pub const SCRIPT: &str = r#"
function key(n) { return n ? n.getAttribute('data-drop') : null; }
function status(t) { document.getElementById('cb-status').textContent = t; }
+ // The session has ended server-side, so nothing on this page can work
+ // any more -- and nothing on it may look like it can (CB-WP-0024 T01).
+ //
+ // The old page kept a full table and a live `play again` control after
+ // the server had stopped listening, because the script acted only on
+ // 'ok'. A control that cannot work must stop being a control, so the
+ // `data-drop` attribute is REMOVED rather than styled: that is the
+ // attribute the script's own walk reads, so the element becomes
+ // undroppable by the same rule that made it droppable.
+ //
+ // No game vocabulary here (ADR-0007 D5). 'closed' is a server
+ // lifecycle word, exactly like the 'ok' branch below it.
+ function seal() {
+ var all = document.querySelectorAll('[data-drop]');
+ for (var i = 0; i < all.length; i++) {
+ all[i].classList.add('sealed');
+ // removeAttribute, NOT setAttribute(_, null): the latter writes the
+ // literal string "null" in a real browser, which is truthy, so the
+ // control would stay droppable while the test stub said otherwise.
+ all[i].removeAttribute('data-drop');
+ }
+ }
+
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
// wrote. This matches on them. It does not compute, infer, filter or
// default one -- a script that pattern-matched ids to guess what is
@@ -175,6 +198,7 @@ pub const SCRIPT: &str = r#"
}).then(function (r) { return r.text(); }).then(function (t) {
status(t);
if (t.indexOf('ok') === 0) { window.location.reload(); }
+ else if (t.indexOf('closed') === 0) { seal(); }
});
});
})();
@@ -225,6 +249,12 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
#cb-ghost .why{color:#9cf;margin-left:.4rem}
/* What you picked up, left visibly behind so there are not two cards. */
.held{cursor:grabbing;opacity:.35;border-style:dashed}
+/* CB-WP-0024 T01: a control the server can no longer serve. The script
+ removes its `data-drop` so it is genuinely inert; this is only how that
+ reads. `pointer-events:none` is belt and braces, not the mechanism --
+ 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)}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
@@ -297,6 +327,112 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
/// The relationship graph — the one element every Rust 2D toolkit would
/// have left us to hand-roll, and the reason SVG earns its place here
/// rather than merely fitting the budget (CB-RES-0006 §5).
+/// One pile, drawn as a small stack of offset cards with its count on top.
+///
+/// `depth` is how many card-backs to suggest, not the count — a pile of 17
+/// is not seventeen rectangles. The **number** is the truth; the stack is
+/// how you tell at a glance that there is a pile there at all.
+fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) {
+ let depth = match count {
+ 0 => 0,
+ 1..=3 => 1,
+ 4..=9 => 2,
+ _ => 3,
+ };
+ // The title carries the count in words. It is what a screen reader
+ // announces, and it is the stable thing a coverage probe can match --
+ // the on-canvas number is a bare numeral that could be anything.
+ let _ = write!(
+ out,
+ "{} pile: {count} remaining",
+ esc(label),
+ );
+ if depth == 0 {
+ // An EMPTY pile is drawn, not omitted. A missing slot reads as
+ // "this game has no discard", which is a different statement from
+ // "the discard is empty" (CB-WP-0024 T02).
+ let _ = write!(
+ out,
+ "",
+ );
+ }
+ for d in (0..depth).rev() {
+ let dx = x + (d as i32) * 3;
+ let dy = 14 - (d as i32) * 3;
+ let _ = write!(
+ out,
+ "",
+ );
+ }
+ let _ = write!(
+ out,
+ "{count}\
+ {label}\
+ {note}",
+ tx = x + 38,
+ count = count,
+ label = esc(label),
+ note = esc(note),
+ );
+}
+
+/// The draw stack and the discard stack, as objects on the table.
+///
+/// Both numbers come from the projection — `solution_deck_len` and
+/// `solution_discard` — and are never recomputed here.
+///
+/// **The reshuffle is real and is the U4 default**, confirmed by
+/// ground-game on 2026-08-03: when the deck runs out, the discard is
+/// reshuffled into it deterministically; if both are empty the draw is
+/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows
+/// the state in which the *next* draw will trigger it. It does not claim a
+/// reshuffle has happened — the view carries no such flag, and the event
+/// already reads out in the log.
+fn piles_svg(out: &mut String, view: &GroundView) {
+ let deck = view.solution_deck_len;
+ let discard = view.solution_discard.len();
+ let will_reshuffle = deck == 0 && discard > 0;
+
+ out.push_str("");
+
+ // The discard is public — it has been played (GR-S04 hides only the
+ // deck) — so its contents stay readable as text beside the picture.
+ let _ = write!(
+ out,
+ "
");
+ piles_svg(s, view);
if let Some(o) = &view.outcome {
let personal = o
@@ -822,7 +953,7 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L
s,
"
\
play again
\
-
close \u{2014} I have read this
\
+
end session \u{2014} stops the game server
\
\
the game is over
\
",
diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs
index 40d46ac..82b9bcf 100644
--- a/crates/cb-render-html/src/jsrun.rs
+++ b/crates/cb-render-html/src/jsrun.rs
@@ -79,6 +79,11 @@ function __mk(key, targets, text, descs) {
'data-descs': descs || null },
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
setAttribute: function (a, v) { this._attr[a] = v; },
+ // A real browser's removeAttribute. The stub must have it so the
+ // script can use it instead of `setAttribute(a, null)`, which writes
+ // the truthy string "null" in a browser but a real null here -- a
+ // difference that would make a broken seal pass its own test.
+ removeAttribute: function (a) { delete this._attr[a]; },
classList: {
add: function (c) { n._cls[c] = true; },
remove: function (c) { delete n._cls[c]; },
@@ -97,10 +102,25 @@ var document = {
querySelectorAll: function (_sel) { return __all; }
};
var window = { location: { reload: function () { __reloaded(); } } };
+
+// The server's REPLY, delivered synchronously through the then-chain.
+//
+// CB-WP-0024 T01: the old stub returned `{then: function(){return this}}`,
+// which never invoked the callbacks -- so every line of the script that
+// reacts to what the server said was unreachable in every test. That is
+// why "the page still looks live after the session ends" survived: the
+// branch that would have fixed it could not be executed here.
+//
+// `__reply` is set per run. Chained `.then(fn)` passes fn's return value
+// on, matching the promise semantics the script relies on (`r.text()`
+// then the text).
+var __reply = "";
function fetch(url, opts) {
__post(url, (opts && opts.body) || "");
- var chainable = { then: function () { return chainable; } };
- return chainable;
+ function chain(v) {
+ return { then: function (fn) { return chain(fn ? fn(v) : v); } };
+ }
+ return chain({ text: function () { return __reply; } });
}
// Gestures are driven against REGISTERED nodes, so the script's walk, its
@@ -136,6 +156,19 @@ function __heldKeys() {
return out.sort().join(',');
}
function __ghosts() { return __body.children.length; }
+
+// What the page still offers AFTER the reply was handled. A control the
+// script has sealed must stop being droppable, not merely look different
+// -- so this reads the same attribute the script's own walk reads.
+function __liveKeys() {
+ var out = [];
+ for (var i = 0; i < __all.length; i++) {
+ var k = __all[i].getAttribute('data-drop');
+ if (k) { out.push(k); }
+ }
+ return out.sort().join(',');
+}
+function __statusText() { return __status.textContent; }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
@@ -183,6 +216,57 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String>
Ok(out)
}
+/// What the page LOOKS LIKE after a gesture whose reply was `reply`.
+///
+/// Returns `(live drop keys, status text)` — the keys still droppable
+/// once the script has handled the server's answer.
+///
+/// **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
+/// does in response to the server was reachable from any test. A page
+/// that ignores what it was told looked identical to one that acts on it.
+pub fn gesture_with_reply(
+ html: &str,
+ down: &str,
+ up: &str,
+ reply: &str,
+) -> Result<(Vec, String), String> {
+ let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
+ ctx.add_callback("__post", |_url: String, _body: String| 0i32)
+ .map_err(|e| format!("register __post: {e}"))?;
+ let reloaded = Arc::new(Mutex::new(false));
+ let flag = reloaded.clone();
+ ctx.add_callback("__reloaded", move || {
+ *flag.lock().expect("reloaded") = true;
+ 0i32
+ })
+ .map_err(|e| format!("register __reloaded: {e}"))?;
+
+ prepare(&ctx, html)?;
+ ctx.eval(&format!("__reply = {};", json_lit(reply)))
+ .map_err(|e| format!("set reply: {e}"))?;
+ ctx.eval(&format!(
+ "__down({}); __up({});",
+ json_lit(down),
+ json_lit(up)
+ ))
+ .map_err(|e| format!("gesture: {e}"))?;
+
+ let keys: String = ctx
+ .eval_as("__liveKeys()")
+ .map_err(|e| format!("live keys: {e}"))?;
+ let status: String = ctx
+ .eval_as("__statusText()")
+ .map_err(|e| format!("status: {e}"))?;
+ let keys = if keys.is_empty() {
+ Vec::new()
+ } else {
+ keys.split(',').map(str::to_string).collect()
+ };
+ Ok((keys, status))
+}
+
/// Every droppable in the document, as `(data-drop, data-targets)`.
///
/// The stub's node set is built from the **real emitted page**, not from a
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 03b74e7..7dd0f36 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -128,7 +128,7 @@ mod coverage {
("step", "step Select"),
("mode", "scoring BondedCoalitions"),
("viewer", "viewing as P1"),
- ("solution_deck_len", "17 remaining"),
+ ("solution_deck_len", "draw pile: 17 remaining"),
("solution_discard.*.suit", "discard Repair Change"),
("players.*.stress", "stress 5"),
("players.*.protection", "protect 2"),
@@ -630,5 +630,156 @@ mod gamelog {
}
}
+/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table.
+///
+/// The maintainer asked for the piles to be visible and for the discard
+/// to show a shuffle when the draw runs out. **The reshuffle is real** —
+/// `games/ground/src/lib.rs::draw_solution` implements the U4 default
+/// (deterministic reshuffle of the discard; skip the draw if both are
+/// empty), which ground-game confirmed on 2026-08-03. So this renders the
+/// state in which the next draw triggers it, rather than inventing a rule.
+#[cfg(test)]
+mod piles {
+ use cb_kernel::PlayerId;
+ use games_ground::view::GroundView;
+
+ use crate::doc::document;
+
+ fn view() -> GroundView {
+ crate::testfix::view(Some(PlayerId(0)))
+ }
+
+ fn html(v: &GroundView) -> String {
+ document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
+ }
+
+ /// The counts must come from the projection, never be recomputed.
+ #[test]
+ fn both_counts_are_the_views_own_numbers() {
+ let mut v = view();
+ v.solution_deck_len = 5;
+ let doc = crate::text_of(&html(&v));
+ assert!(
+ doc.contains("draw pile: 5 remaining"),
+ "the drawn deck count is not the view's: {doc}"
+ );
+ let n = v.solution_discard.len();
+ assert!(
+ doc.contains(&format!("discard pile: {n} remaining")),
+ "the drawn discard count is not the view's ({n}): {doc}"
+ );
+ }
+
+ /// An empty discard is an EMPTY pile, not a missing one. A absent slot
+ /// reads as "this game has no discard", which is a different claim.
+ #[test]
+ fn an_empty_pile_is_drawn_rather_than_omitted() {
+ let mut v = view();
+ v.solution_discard.clear();
+ let doc = crate::text_of(&html(&v));
+ assert!(
+ doc.contains("discard pile: 0 remaining"),
+ "an empty discard vanished instead of rendering as empty: {doc}"
+ );
+ }
+
+ /// The U4 state: deck empty, discard holding cards. The next draw
+ /// reshuffles, and the table should say so.
+ #[test]
+ fn an_exhausted_deck_says_the_discard_shuffles_back_in() {
+ let mut v = view();
+ v.solution_deck_len = 0;
+ assert!(!v.solution_discard.is_empty(), "fixture needs a discard");
+ let doc = crate::text_of(&html(&v));
+ assert!(
+ doc.contains("shuffles in on next draw"),
+ "an exhausted deck did not announce the U4 reshuffle: {doc}"
+ );
+
+ // And the negative half: with cards left, no shuffle is promised.
+ let mut full = view();
+ full.solution_deck_len = 12;
+ assert!(
+ !crate::text_of(&html(&full)).contains("shuffles in on next draw"),
+ "a stocked deck claimed a reshuffle was coming"
+ );
+ }
+}
+
+/// CB-WP-0024 T01 — the ending page's one control.
+///
+/// The maintainer reported it as *"the button says 'I need to read this'
+/// — why? the UI is not closing."* Two defects wearing one button: the
+/// label described a reading while the control stopped a server, and
+/// acknowledging it changed nothing on screen, leaving a live-looking
+/// table and a `play again` pointing at a closed port.
+#[cfg(test)]
+mod ending_page {
+ use crate::{doc, jsrun};
+
+ fn page() -> String {
+ doc::ending(None, "the game ended", "/command?t=x", &[])
+ }
+
+ /// The label must say what the control DOES. Asserted on the rendered
+ /// text so reverting the wording turns this red — a comment would not.
+ #[test]
+ fn the_control_is_labelled_by_its_effect_not_by_a_reading() {
+ let text = crate::text_of(&page());
+ assert!(
+ text.contains("end session") && text.contains("stops the game server"),
+ "the ending control must name its effect: {text}"
+ );
+ assert!(
+ !text.contains("I have read this"),
+ "the label claimed the player had read something; it stops a server"
+ );
+ }
+
+ /// The defect the maintainer actually saw. After the server says the
+ /// session is closed, `play again` must stop being offered — it now
+ /// points at a port nobody is listening on.
+ #[test]
+ fn acknowledging_the_end_stops_the_page_offering_anything() {
+ let html = page();
+ let before = doc::drop_keys(&html);
+ assert!(
+ before.contains("again") && before.contains("done"),
+ "fixture must start with both controls: {before:?}"
+ );
+
+ let (live, status) =
+ jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended")
+ .expect("run the page");
+
+ assert!(
+ !live.contains(&"again".to_string()),
+ "`play again` survived the session ending and would post to a closed port: {live:?}"
+ );
+ assert!(
+ live.is_empty(),
+ "every control must be sealed once the server stops, not only `again`: {live:?}"
+ );
+ assert!(
+ status.contains("session has ended"),
+ "the page must say what happened: {status:?}"
+ );
+ }
+
+ /// The negative control. If `seal` fired on any reply, this test would
+ /// pass for a page that tears itself down whenever it is touched —
+ /// which would break `play again` in the ordinary case.
+ #[test]
+ fn a_dealing_reply_leaves_the_controls_alone() {
+ let html = page();
+ let (live, _) =
+ jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run the page");
+ assert!(
+ live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
+ "an 'ok' reply must not seal the page: {live:?}"
+ );
+ }
+}
+
#[cfg(test)]
mod testfix;
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index b856215..1385e96 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -249,7 +249,17 @@ impl Server {
&mut stream,
200,
"text/plain",
- if again { "ok: dealing" } else { "closed" },
+ // CB-WP-0024 T01: the reply is what the player
+ // reads. "closed" alone left them looking at a
+ // live table wondering why nothing happened.
+ // The `closed` prefix is what the page's script
+ // matches on to seal itself.
+ if again {
+ "ok: dealing"
+ } else {
+ "closed \u{2014} the session has ended and the server has stopped. \
+ You can close this tab."
+ },
);
break Ok(if again {
EndChoice::Again
diff --git a/workplans/CB-WP-0024-the-table-you-can-watch.md b/workplans/CB-WP-0024-the-table-you-can-watch.md
index 6d65391..54208b8 100644
--- a/workplans/CB-WP-0024-the-table-you-can-watch.md
+++ b/workplans/CB-WP-0024-the-table-you-can-watch.md
@@ -2,7 +2,7 @@
id: CB-WP-0024
kind: product
title: "The table you can watch: the piles, the other seats' moves, the score that carries"
-status: ready
+status: active
state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2"
---
@@ -47,7 +47,7 @@ except where task T04 says so explicitly.
```task
id: CB-WP-0024-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc"
```
@@ -75,11 +75,36 @@ because a control that can no longer work must not look like it can.
- the label change must be mutation-visible: assert the rendered text, so
reverting the wording turns a test red.
+**Done 2026-08-05.** Label is now `end session — stops the game server`;
+the reply is `closed — the session has ended and the server has stopped.
+You can close this tab.`; and the script **seals** the page on a `closed`
+reply — `removeAttribute('data-drop')` on every control, so they stop
+being droppable by the same rule that made them droppable. `sealed` CSS is
+how that reads, not the mechanism.
+
+**`removeAttribute`, not `setAttribute(_, null)`** — the latter writes the
+literal string `"null"` in a browser, which is truthy, so the control
+would stay live while the stub reported it sealed.
+
+**The reply path had never been executable in a test.** `jsrun`'s `fetch`
+stub returned `{then: function(){return this}}`, which never invoked its
+callbacks — so every line of the script that reacts to what the server
+said was unreachable from every test in the project. **That is why this
+defect survived**: a page that ignores the server looked identical to one
+that acts on it. The stub now delivers a real then-chain, and
+`gesture_with_reply` reports the page's surviving controls.
+
+Three tests, and the seal is mutation-proven: deleting the `closed` branch
+turns exactly one red. The negative control (`ok: dealing` must NOT seal)
+exists because a seal that fired on every reply would pass the first test
+and break `play again`. `cb-play`: 22 passed, including
+`play_again_deals_a_second_game`.
+
## Task: the piles are objects on the table
```task
id: CB-WP-0024-T02
-status: todo
+status: done
priority: high
state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e"
```
@@ -107,6 +132,33 @@ stop.
- the shuffle question is answered in the task record with the line number
that settles it, either way.
+**Done 2026-08-05.** Both piles are drawn as offset stacks with their
+counts, replacing the text line. Depth suggests *a pile exists*; the
+number is the truth — 17 cards is not seventeen rectangles.
+
+**The shuffle question is settled and the answer is that it already
+works.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`) implements
+the **U4 default**: deck empty → deterministic reshuffle of the discard
+via a `DeckReshuffled` event seeded from `seed ^ round`; both empty → skip
+the draw. ground-game **confirmed U4 on 2026-08-03**, so this is a ruled
+rule, not an invented one. Nothing to raise.
+
+The event already reads out in the log (`hotseat.rs:374`). What the piles
+add is the *state before it fires*: deck 0 with a non-empty discard draws
+an arrow and says **"shuffles in on next draw"**. That is derivable from
+the view; a claim that a reshuffle *has happened* would not be, and is not
+made.
+
+**The coverage gate caught the probe going stale** — removing the
+`17 remaining` text broke `solution_deck_len`'s classification. Fixed by
+putting the count in the pile's `` (`draw pile: 17 remaining`),
+which is both a stable probe and what a screen reader announces; the
+on-canvas numeral alone could be any number on the page.
+
+Three tests: counts come from the view, an empty discard renders as an
+empty pile rather than vanishing, and the reshuffle notice appears only
+when the deck is actually out.
+
## Task: what the other seats played, on the table
```task