diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index fca3e47..fae1353 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -421,24 +421,10 @@ fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, not
);
}
-/// 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) {
+fn piles_body(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("");
pile_svg(
out,
10,
@@ -465,27 +451,34 @@ fn piles_svg(out: &mut String, view: &GroundView) {
",
);
}
- 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,
- "
discard {}
",
- esc(&cards(&view.solution_discard)),
- );
}
-fn relations_svg(view: &GroundView) -> String {
+/// The table, seen from above (CB-WP-0028 T03).
+///
+/// **One diagram, not three.** The seats were already placed on a circle
+/// for the relationship graph, while the Problems were a row above it and
+/// the piles a separate picture below — three views of one table, and the
+/// player had to assemble them. Now: seats around the edge, the Problems
+/// and the two stacks in the middle, each seat's played card in front of
+/// it, relations drawn between seats.
+///
+/// The existing `problem_svg` and `pile_svg` are reused rather than
+/// reimplemented — they carry the tokens the coverage gate probes for,
+/// and drawing the same thing twice is how the two drift.
+fn table_svg(view: &GroundView) -> String {
let n = view.players.len().max(1);
- let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64);
+ let (cx, cy) = (380.0f64, 300.0f64);
+ let seat_r = 240.0f64;
+
let pos: Vec<(PlayerId, f64, f64)> = view
.players
.keys()
.enumerate()
.map(|(i, p)| {
- let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2;
- (*p, cx + r * a.cos(), cy + r * a.sin())
+ // Start at the bottom, not the top: the viewer sits nearest
+ // the reader, which is where you sit at a real table.
+ let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2;
+ (*p, cx + seat_r * a.cos(), cy + seat_r * a.sin() * 0.72)
})
.collect();
let find = |p: PlayerId| {
@@ -495,8 +488,12 @@ fn relations_svg(view: &GroundView) -> String {
};
let mut s = String::from(
- "",
+ "\
+ ",
);
+
+ // Relations first, so seats draw over them.
for (pair, rel) in &view.relations {
if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) {
let colour = match rel {
@@ -507,32 +504,70 @@ fn relations_svg(view: &GroundView) -> String {
s,
"\
- {rel:?} ",
+ {rel:?} ",
mx = (x1 + x2) / 2.0,
- my = (y1 + y2) / 2.0 - 3.0,
+ my = (y1 + y2) / 2.0 - 4.0,
);
}
}
+
+ // The middle of the table: Problems, then the two stacks under them.
+ let span = (view.problems.len().max(1) as f64) * 130.0;
+ let _ = write!(
+ s,
+ "",
+ x = cx - span / 2.0,
+ );
+ for (i, (priority, p)) in view.problems.iter().enumerate() {
+ problem_svg(&mut s, *priority, p, (i as i32) * 130);
+ }
+ s.push_str(" ");
+ let _ = write!(s, "", x = cx - 150.0);
+ piles_body(&mut s, view);
+ s.push_str(" ");
+
+ // Seats, each with what it played in front of it.
for (p, x, y) in &pos {
let is_viewer = view.viewer == Some(*p);
let focus = view
.focus
.get(p)
- .map(|f| format!(" \u{2192}{}", seat_name(*f)));
+ .map(|f| format!(" \u{2192}{}", seat_name(*f)))
+ .unwrap_or_default();
+ let pv = &view.players[p];
+ // The played card sits between the seat and the centre.
+ let (dx, dy) = ((cx - x) * 0.30, (cy - y) * 0.30);
+ if let Some(sel) = view.selections.get(p) {
+ let _ = write!(
+ s,
+ "",
+ px = x + dx - 26.0,
+ py = y + dy - 17.0,
+ );
+ played_inner(&mut s, sel);
+ s.push_str(" ");
+ }
let _ = write!(
s,
- "\
- {name} \
- {focus} ",
+ "\
+ \
+ {name}{you} \
+ stress {stress}{focus} ",
raw = p.0,
+ // The viewer's own seat is thicker and blue: a table where you
+ // cannot find yourself is worse than a list.
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
- ty = y + 2.0,
- ty2 = y + 16.0,
+ sw = if is_viewer { 3 } else { 2 },
+ t1 = y - 2.0,
+ t2 = y + 14.0,
name = seat_name(*p),
- focus = esc(focus.as_deref().unwrap_or("")),
+ you = if is_viewer { " (you)" } else { "" },
+ stress = pv.stress,
+ focus = esc(&focus),
);
}
s.push_str(" ");
@@ -575,6 +610,11 @@ const CARD_BACK: &str = "\
";
+/// The played card without its own ``, for placing on the table.
+fn played_inner(out: &mut String, sel: &SelectionView) {
+ played_svg(out, sel);
+}
+
fn played_svg(out: &mut String, sel: &SelectionView) {
let s = match sel {
SelectionView::Hidden => {
@@ -845,21 +885,15 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) {
/// than a second rendering of it — two renderings of one state is how
/// they drift.
fn body(s: &mut String, view: &GroundView) {
- s.push_str(
- "problems ",
- );
+ // CB-WP-0028 T03: one overhead view — seats around the edge, the
+ // Problems and both stacks in the middle, each seat's played card in
+ // front of it. This replaces three separate diagrams the player had to
+ // assemble: a Problems row, a relationship circle, and a piles picture.
+ s.push_str("the table ");
if view.problems.is_empty() {
- s.push_str(
- "no problems in play ",
- );
+ s.push_str("no problems in play
");
}
- for (i, (priority, p)) in view.problems.iter().enumerate() {
- problem_svg(s, *priority, p, 10 + (i as i32) * 130);
- }
- s.push_str(" ");
-
- s.push_str("relationships ");
- s.push_str(&relations_svg(view));
+ s.push_str(&table_svg(view));
s.push_str("seats ");
for (id, p) in &view.players {
@@ -867,8 +901,13 @@ fn body(s: &mut String, view: &GroundView) {
}
s.push_str("
");
- s.push_str("solutions ");
- piles_svg(s, view);
+ // The discard's contents stay as text: it is public and readable,
+ // and the stack on the table shows only how many.
+ let _ = write!(
+ s,
+ "discard {}
",
+ esc(&cards(&view.solution_discard)),
+ );
if let Some(o) = &view.outcome {
let personal = o
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 6fbb79c..662d39d 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -714,6 +714,119 @@ mod piles {
}
}
+/// CB-WP-0028 T03 — one overhead table, not three diagrams.
+#[cfg(test)]
+mod overhead_table {
+ use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
+ use cb_kernel::PlayerId;
+ use games_ground::GroundState;
+
+ fn view_of(players: u8) -> games_ground::view::GroundView {
+ GroundState::setup(
+ &Setup {
+ players,
+ preset: format!("standard-{players}p"),
+ patch: Default::default(),
+ },
+ 7,
+ )
+ .expect("preset")
+ .project(Viewer::Player(PlayerId(0)))
+ }
+
+ /// **Every seat count lays out, and no two seats land on each other.**
+ /// Asserted per count rather than eyeballed at three, which is the
+ /// only count anyone ever looks at.
+ #[test]
+ fn two_through_six_seats_all_lay_out_without_overlap() {
+ for players in 2..=6u8 {
+ let v = view_of(players);
+ let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
+
+ let seats: Vec<(f64, f64)> = html
+ .match_indices(" 70.0,
+ "{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
+ they overlap"
+ );
+ }
+ }
+ }
+ }
+
+ /// A table where you cannot find yourself is worse than a list.
+ #[test]
+ fn the_viewers_own_seat_is_marked() {
+ let v = view_of(4);
+ let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
+ assert!(
+ crate::text_of(&html).contains("P1 (you)"),
+ "the viewer's seat is not identifiable"
+ );
+ assert!(
+ html.contains("stroke=\"#9cf\" stroke-width=\"3\""),
+ "the viewer's seat should also be visually distinct, not only labelled"
+ );
+ // A spectator has no seat to mark, and must not claim one.
+ let spec = GroundState::setup(
+ &Setup {
+ players: 4,
+ preset: "standard-4p".into(),
+ patch: Default::default(),
+ },
+ 7,
+ )
+ .expect("preset")
+ .project(Viewer::Spectator);
+ assert!(
+ !crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"),
+ "a spectator was given a seat"
+ );
+ }
+
+ /// The three diagrams became one: the relationship circle and the
+ /// piles picture are gone as separate views, and their content is on
+ /// the table.
+ #[test]
+ fn the_stacks_and_the_relations_are_on_the_table() {
+ let v = view_of(3);
+ let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
+ let table = html
+ .split("aria-label=\"the table, seen from above\"")
+ .nth(1)
+ .and_then(|s| s.split(" ").next())
+ .expect("one table svg");
+ assert!(
+ table.contains("draw pile:"),
+ "the draw stack is not on the table"
+ );
+ assert!(
+ table.contains("discard pile:"),
+ "the discard is not on the table"
+ );
+ assert!(
+ !html.contains("relationship graph"),
+ "the old separate relationship diagram is still being drawn"
+ );
+ }
+}
+
/// CB-WP-0028 T02 — the cards say what they do, in the edition's words.
#[cfg(test)]
mod card_words {
@@ -936,7 +1049,7 @@ mod two_columns {
.and_then(|s| s.split("class=\"cb-meta\"").next())
.expect("a game column followed by a meta column");
assert!(
- game.contains("problems "),
+ game.contains("the table "),
"the table must be in the game column"
);
assert!(
diff --git a/evidence/CB-EV-0026-the-table-you-sit-at.md b/evidence/CB-EV-0026-the-table-you-sit-at.md
new file mode 100644
index 0000000..6d5c496
--- /dev/null
+++ b/evidence/CB-EV-0026-the-table-you-sit-at.md
@@ -0,0 +1,163 @@
+# CB-EV-0026 — the table you sit at
+
+CB-WP-0028 T08. Tier M (structural M — imports more of an external
+dataset under AM-4's budgets; chaos d8=1 → no override). Declaration 11 of
+chaos window 2. Closed 2026-08-06.
+
+**Delivered:** [ADR-0015](../decisions/ADR-0015-the-cards-own-words.md),
+three vendored edition files, a generic reader, the cards' own words on
+the page, one overhead table replacing three diagrams, controls and log in
+the meta column, *"game solved"*, and rankings that cite their source.
+
+---
+
+## 1. Nine observations, and where they actually came from
+
+**Seven of nine were engine defects. One was a design finding. One was
+already true and nobody could tell.**
+
+| # | observation | what it turned out to be |
+|---|---|---|
+| 1 | no incentive to attack | a **design finding** (F17), and still a note |
+| 2 | *"I don't understand the GROUND card"* | **a data-import gap** — the card explains itself and we never read it |
+| 3 | overhead view | three diagrams the player had to assemble |
+| 4 | click the deck to draw | **not a legal move**; built nothing |
+| 5 | auto-draw option | **already automatic**, and always was |
+| 6–7 | controls and log on the right | layout |
+| 8 | *"Game Over"* on a win | wrong vocabulary for a co-operative game |
+| 9 | rankings | a place where the game defines *not to rank* |
+
+**Observations 4 and 5 are the most useful pair in the set**, because both
+dissolved. Clicking the deck is not a move — `GroundCommand` has no
+standalone draw, and the edition's INVESTIGATE text says *"…reveal it.
+**Then draw one Solution.**"* Drawing is a consequence. And auto-draw was
+asked for a thing that has **never been manual**.
+
+**Both had one cause: nothing on the page said how drawing works.** The
+INVESTIGATE card's own words were in a file we had not imported. So a
+player invented a mental model to fill the gap, and reported the gap as
+two feature requests.
+
+**No finding was raised.** An instinct differing from a *legible* rule is
+a comprehension gap; an instinct differing from an *invisible* one is our
+defect. Whether it recurs now that the card says it is a testable question
+and was not before.
+
+## 2. The import gap was worse than "one file of nineteen"
+
+Measured before deciding:
+
+| | |
+|---|---|
+| edition files upstream | 19 |
+| vendored | **1** |
+| columns in that one file | 13 |
+| columns the engine read | **5** |
+| scenarios in the file | 4 |
+| scenarios the engine deals | **1**, hardcoded |
+
+`title`, `problem_text`, `front_rules`, `reveal_effect` and
+`unresolved_effect` were vendored on 2026-08-04 and **discarded at parse
+time for eight days**. The page showed `Repair 2` for a card that reads
+*"Missed Deadline"*. Fixing that cost no new bytes, no dependency and no
+budget — it was the cheapest thing in the pass and the one nothing had
+found.
+
+**Rule coverage was 59/59 throughout.** The gate measures whether rules
+are exercised, and every rule was. **Nothing measures whether a player can
+read the game**, and nothing cheaply could — which is why the person
+playing it is the instrument.
+
+## 3. The dependency question was answered by measurement, not preference
+
+ADR-0011 refused the `csv` crate and named its own revisit condition:
+*"nested quoting, embedded newlines, multiple dialects."*
+
+Across every candidate file — Actions, Solutions, Modes, Scenarios —
+**zero doubled quotes and zero embedded newlines.** The hand reader's only
+job is comma-in-quoted-field, which it already did.
+
+So the argument was not re-run, **and the reason is a measurement rather
+than the inconvenience of re-running it.** `edition-check` now asserts
+that condition on every vendored file, so the day it stops being true the
+gate says so instead of a parser mangling a card.
+
+**One reader, four callers.** A per-file copy is how a parser acquires
+four subtly different bugs.
+
+## 4. Two gates were written for a smaller world
+
+**`edition-check` compared the first recorded digest against
+`Problems.csv` regardless of which file that digest described.** With one
+vendored file it was correct; with four it was comparing across files. It
+now checks both directions — a vendored file with no digest fails, and a
+digest naming an absent file fails.
+
+**A `cb-play` test asserted the literal string `"game over"`** and went
+red when a won game began saying *"solved"* — the feature working. Fixed
+by asserting the heading **against the outcome** rather than against a
+word, which also covers the no-outcome case the original never touched.
+
+**Both are the same shape:** an assertion that encoded a world with one
+file, or one ending. Neither was wrong when written.
+
+## 5. What T07 decided not to do
+
+`Modes.csv` gives `scoring_tiebreak` per mode. For SHARED GROUND it is
+**"Not applicable"** — the table succeeds or fails together.
+
+So the co-operative mode shows contributions and **refuses to order
+them**, and says why on the page. A leaderboard would have been easy,
+looked good, and invented scoring the game does not have — the same defect
+class as canonising a provisional default, which this project has already
+committed once.
+
+Where a mode *does* rank, the ordering is the game's own words. The single
+derived superlative is labelled **"clay-borg's reading, not a rule."**
+
+**This is the first time the project has had the game's own tiebreak to
+cite**, and it only had it because §2's import brought `Modes.csv` in. The
+alternative was inventing one.
+
+## 6. The overhead view cost no coverage probe
+
+All existing probes passed through a restructure that **deleted two
+renderers** (`relations_svg`, `piles_svg`) and merged three diagrams into
+one.
+
+**Second confirmation of CB-WP-0027's finding**: a probe that names a
+*fact* survives a reflow; one that names a *presentation* does not.
+CB-WP-0024's `17 remaining` probe broke on a rendering change;
+CB-WP-0027's and this pass's did not, and this reflow was far larger.
+
+The new control is per-seat-count: **2 through 6 seats all lay out with no
+two seat circles closer than 70px**, asserted rather than eyeballed at
+three — which is the only count anyone ever looks at.
+
+## 7. Chaos window 2 — closed
+
+**Declaration 11 of 12**, structural M, d8 = 1, no override.
+
+The window has now produced **zero overrides in eleven declarations at
+d8**. Its retirement condition — *retire if an override changes nothing
+twice running* — was never testable, and CB-EV-0024 and CB-EV-0025 both
+said so. This is the third and final statement of it.
+
+**The window's verdict should be that d8 bought rarity by spending
+evidence.** Window 1 at d4: two overrides, both changed the outcome.
+Window 2 at d8: none. **A mechanism that produces no data across a full
+window cannot be evaluated by that window**, which is a stronger
+conclusion than "the rate is too low" and belongs in whatever closes it.
+
+## Open after this pass
+
+- **F17 needs an artifact.** Counting ATTACK selections across the policy
+ panel against hand quality is cheap and nobody has done it.
+- **Three of four scenarios have never been dealt** (ADR-0015 D5). Now
+ visible, still true.
+- **`Relations`, `DARVO`, `Tokens`, `Glossary` are deferred, not refused.**
+ If a seat's DARVO stage needs its own words, that is the trigger.
+- **Nobody has written a trial note in anger** — carried forward from
+ CB-EV-0025, and the note channel only started working today.
+- **Whether the card text changed what the maintainer understood** is this
+ pass's real acceptance test and has a person attached to it.
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index 3513935..0dd359c 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -752,7 +752,19 @@ mod tests {
let replies = client.join().expect("client");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
- assert!(replies[0].contains("game over"), "the page did not say so");
+ // CB-WP-0028 T06: the heading follows the OUTCOME, so this asks
+ // the view rather than hardcoding a word. The old assertion said
+ // "game over" and went red when a won game started saying
+ // "solved" -- which was the feature working.
+ let want = match view.outcome.as_ref() {
+ Some(o) if o.group_success => "game solved",
+ Some(_) => "game over",
+ None => "the game stopped",
+ };
+ assert!(
+ replies[0].contains(want),
+ "the heading did not match the outcome; expected {want:?}"
+ );
assert!(
replies[0].contains("30 commands"),
"the result did not reach the page"
diff --git a/workplans/CB-WP-0028-the-table-you-sit-at.md b/workplans/CB-WP-0028-the-table-you-sit-at.md
index 9b34a8c..f0da28f 100644
--- a/workplans/CB-WP-0028-the-table-you-sit-at.md
+++ b/workplans/CB-WP-0028-the-table-you-sit-at.md
@@ -2,7 +2,7 @@
id: CB-WP-0028
kind: product
title: "The table you sit at: the cards' own words, an overhead view, and a game you solve rather than survive"
-status: active
+status: done
state_hub_workstream_id: "d37c8671-54e4-447f-af64-56f482483282"
---
@@ -39,29 +39,16 @@ split matters because they go to different places.
## Observation 2 is a data-import gap, and it reframes the pass
-*"I don't understand the GROUND card or why other cards should be played
-to the table"* reads as a design problem. It is not. **The card explains
-itself in the dataset and we never imported the explanation.**
+*"I don't understand the GROUND card"* reads as a design problem. It is
+not: `Actions.csv` carries that card's tagline (*"Regulate. Restore the
+frame. Decide."*) and its full rules text, and **we vendored one file of
+nineteen**. Everything else the engine knows is a hand-transcription into
+`GroundRules.md`'s 59 rules — enough to *play* the game, and nothing to
+*read*. The page shows `Clarify` where the card says *"Ask What Happened."*
-`ground-game/editions/*/Actions.csv`, the GROUND row:
-
-> **tagline:** *"Regulate. Restore the frame. Decide."*
-> **rules_text:** *"After all actions are revealed, choose one mode:
-> GR—Ground & Restate: −2 Stress, ready Freedom… OU—Observe & Uphold…"*
-
-**We vendored one file of nineteen.** `editions/ground-darvo-r0/` holds
-`Problems.csv` and nothing else; `Actions`, `Solutions`, `Modes`,
-`Scenarios`, `Relations`, `DARVO`, `Tokens`, `Glossary` and the rest live
-only in `ground-game`. Everything the engine knows about them is a
-**hand-transcription into `GroundRules.md`'s 59 numbered rules** — which
-is enough to *play* the game and gives a player nothing to *read*.
-
-So the page shows `Clarify` where the card says **"Ask What Happened —
-Invite a concrete account before judging."**
-
-**This is the most valuable thing in the pass**, and it was found by a
-player saying he did not understand something rather than by any gate.
-Rule coverage is 59/59 and has been for weeks.
+**Found by a player saying he did not understand something.** Rule
+coverage is 59/59 and has been for weeks. See F18 and
+[ADR-0015](../decisions/ADR-0015-the-cards-own-words.md).
## Task: decide what else to import, and what it costs
@@ -103,23 +90,17 @@ Decide:
[ADR-0015](../decisions/ADR-0015-the-cards-own-words.md), six decisions.
**The gap is bigger than "one file of nineteen", and the measurement is
-the decision.** Of the file we *did* vendor, the engine reads **5 of 13
-columns** — `title`, `problem_text`, `front_rules`, `reveal_effect` and
-`unresolved_effect` were discarded at parse time. **The cheapest part of
-this pass costs no new bytes** and was sitting in the repo for eight days.
-And `SCN_01` is hardcoded at `lib.rs:1824`: the edition ships **four**
-scenarios and the engine has never dealt three of them.
+the decision.** Of the file we *did* vendor the engine reads **5 of 13
+columns**; the discarded ones are player-facing text that had been in the
+repo for eight days. And `SCN_01` is hardcoded — the edition ships **four**
+scenarios and the engine has never dealt three.
-**ADR-0011's revisit condition is measurably absent**, so the dependency
-argument does not get re-run. Across Actions, Solutions, Modes and
-Scenarios: **zero doubled quotes, zero embedded newlines.** The hand
-reader's only job is comma-in-quoted-field, which it already did.
-
-Vendored `Actions`, `Solutions`, `Modes` — the text a player reads. Not
-the production artifacts. **Not `Extensions.csv`**, because it names
-content the designer placed *outside* the core, and importing it would
-break the as-printed claim — but it is now known to exist, which was the
-real risk.
+**ADR-0011's revisit condition is measurably absent** — zero doubled
+quotes, zero embedded newlines across every candidate file — so the
+dependency argument does not get re-run. Vendored `Actions`, `Solutions`,
+`Modes`. Not the production artifacts, and **not `Extensions.csv`**, which
+names deliberately non-core content; it is now known to exist, which was
+the real risk.
## Task: the cards say what they do
@@ -147,38 +128,26 @@ from the edition — not from a phrase we invented.
- **the page stays readable.** Five action cards with full rules text is a
wall; the tagline is the default and the rules text is on demand.
-**Done 2026-08-06.** One `Table` reader with four callers (a per-file copy
-is how a parser acquires four subtly different bugs), `CardText` for
-Actions/Solutions/Modes, and `ProblemText` for the columns already
-vendored.
-
-**The GROUND card now explains itself**: *"Regulate. Restore the frame.
-Decide."* as the tagline, its GR/OU/ND text behind a disclosure. Problems
-show their own titles where a priority number used to be.
+**Done 2026-08-06.** One `Table` reader with four callers (a per-file
+copy is how a parser acquires four subtly different bugs), `CardText` for
+Actions/Solutions/Modes, `ProblemText` for the columns already vendored.
+The GROUND card now explains itself; Problems show their own titles.
**The load-bearing test asserts the text is a substring of the vendored
-file**, not equal to a Rust literal — a test comparing against a
-hardcoded expectation would pass for a hand-copied string, which is the
-drift this ends. Plus counts (5 actions, 24 solutions, 3 modes), because a
-reader returning one row would pass a "the text is real" test.
+file**, not equal to a Rust literal — a hardcoded expectation would pass
+for a hand-copied string, which is the drift this ends.
-**`edition` came out from behind `#[cfg(feature = "scenarios")]`.** It was
-gated because its only consumer was; but the edition is the game's own
-data and the shipped runtime now reads it. **Test machinery and game
-content are different things and only one of them is optional.**
-
-**`edition-check` was written for a single-file world** and compared the
-first recorded digest against `Problems.csv` regardless of which file it
-described. It now checks every file both ways — a vendored file with no
-digest fails, and a digest naming an absent file fails — and asserts
-ADR-0015 D3's falsifier directly: no doubled quotes, no embedded
-newlines.
+**`edition` came out from behind `#[cfg(feature = "scenarios")]`**: it was
+gated because its only consumer was, but the edition is the game's own
+data and the runtime now reads it. **Test machinery and game content are
+different things and only one is optional.** `edition-check`, written for
+a single-file world, now checks every file both ways.
## Task: an overhead view of a real table
```task
id: CB-WP-0028-T03
-status: todo
+status: done
priority: high
state_hub_task_id: "2ff677ac-80ff-4618-ad7c-05b62c8f80c8"
```
@@ -204,6 +173,25 @@ as a circle and once as a row. **One table, not two diagrams.**
- 2 through 6 seats all lay out without overlap, asserted per seat count
rather than eyeballed at 3.
+**Done 2026-08-06.** One `table_svg`: seats around an elliptical table
+starting at the **bottom** (the viewer sits nearest the reader, as at a
+real table), Problems and both stacks in the middle, each seat's played
+card between it and the centre, relations drawn between seats.
+
+**Two renderers were deleted** — `relations_svg` and `piles_svg`. The task
+said *one table, not two diagrams*, and leaving the old ones would have
+meant drawing the same thing twice and letting them drift.
+
+**No coverage probe cost**, through a restructure that merged three
+diagrams and removed two functions. **Second confirmation of CB-WP-0027's
+finding**: a probe naming a *fact* survives a reflow; one naming a
+*presentation* does not.
+
+The new control is per seat count: **no two seat circles closer than 70px
+at 2, 3, 4, 5 or 6 seats** — asserted rather than eyeballed at three,
+which is the only count anyone ever looks at. Plus: the viewer's seat is
+labelled *and* visually distinct, and a spectator is not given one.
+
## Task: take cards from the stack, or let the table do it
```task
@@ -369,7 +357,7 @@ are shown as ties.
```task
id: CB-WP-0028-T08
-status: todo
+status: done
priority: medium
state_hub_task_id: "a20a529e-0edf-49cc-9c80-7dba3b76cff5"
```
@@ -387,3 +375,25 @@ state_hub_task_id: "a20a529e-0edf-49cc-9c80-7dba3b76cff5"
- **What the overhead view cost the coverage gate**, against CB-WP-0027's
finding that a probe naming a fact survives a reflow.
- **Quote CB-WP-0027's cost by re-running the instrument.**
+
+**Done 2026-08-06.**
+[CB-EV-0026](../evidence/CB-EV-0026-the-table-you-sit-at.md).
+
+- **Seven of nine observations were engine defects**, one was a design
+ finding, and one was already true and nobody could tell.
+- **Observations 4 and 5 both dissolved**, and had one cause: nothing on
+ the page said how drawing works, so a player built a mental model to
+ fill the gap and reported the gap as two feature requests.
+- **The import gap was worse than "one of nineteen"** — 5 of 13 columns
+ read from the file we *did* vendor, discarded at parse time for eight
+ days. Rule coverage was 59/59 throughout: the gate measures whether
+ rules are *exercised*, and **nothing measures whether a player can read
+ the game.**
+- **Two gates were written for a smaller world** — `edition-check`
+ compared one digest across four files, and a `cb-play` test asserted the
+ literal `"game over"` and went red when a won game said *"solved"*.
+ Neither was wrong when written.
+- **Chaos window 2 closes with zero overrides in eleven declarations.**
+ Third and final statement: d8 bought rarity by spending evidence, and a
+ mechanism producing no data across a full window cannot be evaluated by
+ it.