diff --git a/Cargo.lock b/Cargo.lock index fd2566b..305976a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,7 +91,6 @@ dependencies = [ name = "cb-render-html" version = "0.1.0" dependencies = [ - "cb-game-runtime", "cb-kernel", "games-ground", "quick-js", diff --git a/INTENT.md b/INTENT.md index 2a13569..8fb0fbd 100644 --- a/INTENT.md +++ b/INTENT.md @@ -64,14 +64,11 @@ game. and scenario tests, simple bots. No rendering, no physics. 1. **Inspectable 2D table** — card/token/hand/relationship-graph visualization, drag-to-propose, debug inspector, hot-seat play. - *Open on one human verification. The first run of it (2026-08-02) - found the table legible and the drag **broken**: drop targets were - `id`s, an `id` must be unique, so the relationship-graph circle held - `seat-0` and the seat card the page points at had none. Fixed in - CB-WP-0016 — drop keys are `data-drop` — and verified by tests, by - mutation, and against a live server, but **not** by a human dragging, - which is the standard that found it. Run `cb-play --serve 0`, open the - printed URL, and drag an action onto a seat card.* + *Open on one human verification (CB-EV-0012 §4): the inspector, + drag-to-propose and hot-seat play are evidenced by executing code; + the visualization is evidenced only as correctly emitted, because no + browser is available to the loop. Run `cb-play --serve 0`, open the + printed URL, and confirm the table reads and a drag works.* 2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera and pointer controls, snap zones, asset importer. 3. **Networked sessions** — authoritative host, private projections, diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml index d86d83c..204baaa 100644 --- a/crates/cb-render-html/Cargo.toml +++ b/crates/cb-render-html/Cargo.toml @@ -25,11 +25,6 @@ quick-js = { version = "0.4", optional = true } [dev-dependencies] # The coverage gate walks the serialized view; nothing else needs it. serde_json.workspace = true -# CB-WP-0016 T03 drives a real bot game to render at real decision points. -# A workspace crate, so AM-4 is unmoved: it counts third-party code. -# `scenarios` carries GroundState::setup, which the walk needs to deal a game. -cb-game-runtime = { workspace = true, features = ["scenarios"] } -games-ground = { workspace = true, features = ["scenarios"] } quick-js = "0.4" [lints] diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 4962034..503f09b 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -57,25 +57,15 @@ fn cards(list: &[games_ground::SolutionCard]) -> String { pub const SCRIPT: &str = r#" (function () { var down = null; - function key(e) { + function id(e) { var n = e.target; - while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; } - return n ? n.getAttribute('data-drop') : null; + while (n && !n.id) { n = n.parentNode; } + return n ? n.id : null; } - document.addEventListener('pointerdown', function (e) { down = key(e); }); + document.addEventListener('pointerdown', function (e) { down = id(e); }); document.addEventListener('pointerup', function (e) { - var up = key(e); - if (!down || !up) { - // CB-WP-0016 T02: refusing is right; refusing SILENTLY is what let a - // broken drop target survive a human sitting in front of it. Report - // the raw fact — which element the pointer took and where it let go. - // This decides nothing: it names elements, not moves. - document.getElementById('cb-status').textContent = - down ? 'took ' + down + ', let go over nothing droppable' - : 'nothing droppable under the pointer'; - down = null; - return; - } + var up = id(e); + if (!down || !up) { down = null; return; } var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); down = null; fetch(window.CB_ENDPOINT, { @@ -132,7 +122,7 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { }; let _ = write!( out, - "\ {label}\ priority {priority}\ @@ -192,7 +182,7 @@ fn relations_svg(view: &GroundView) -> String { .map(|f| format!(" \u{2192}{}", seat_name(*f))); let _ = write!( s, - "\ {name}\ @@ -214,13 +204,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView let is_viewer = view.viewer == Some(id); let _ = write!( out, - // CB-WP-0016 T01: the seat card is a drop target. It could not be - // while drop keys were `id`s — the graph node had already taken - // `seat-{n}` and ids must be unique, so the card the instruction - // text points at silently had none. - "
\ - {name}{you}
", - raw = id.0, + "
{name}{you}
", name = seat_name(id), you = if is_viewer { " (you)" } else { "" }, ); @@ -232,7 +216,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView ); let _ = write!( out, - "freedom \ + "freedom \ {ready}{lifted}
", raw = id.0, ready = if p.freedom_ready { "READY" } else { "spent" }, @@ -438,7 +422,7 @@ pub fn document( if offered { let _ = write!( s, - "
drag {a:?} onto a seat, a problem, \ + "
drag {a:?} onto a seat, a problem, \ or the table
", id = action_id(a), ); @@ -450,18 +434,18 @@ pub fn document( if !spatial { let _ = write!( s, - "
{}
", + "
{}
", esc(&format!("{c:?}")) ); } } s.push_str( - "
the table \u{2014} drop here for an \ + "
the table \u{2014} drop here for an \ untargeted action
", ); } if may_pass { - s.push_str("
pass \u{2014} decline to act
"); + s.push_str("
pass \u{2014} decline to act
"); } let _ = write!( @@ -501,34 +485,6 @@ fn json_string(s: &str) -> String { /// that parse: it drops markup and returns what a reader would see, plus /// the ids a pointer can address. A substring search over the raw source /// would happily find a token inside a comment or a style rule. -/// Every `data-drop="…"` value in the document. -/// -/// CB-WP-0016. A real parse of the attribute rather than a substring -/// search: `html.contains("seat-1")` would be satisfied by the *text* -/// "seat-1" and by `data-drop="seat-10"`, and the point of the check this -/// feeds is that an affordance can name a target that is not there. -/// -/// **Drop keys are `data-drop`, not `id`, and that is the fix for -/// CB-WP-0016.** An `id` must be unique in a document, so exactly one -/// element could ever be `seat-0` — the relationship-graph circle took it -/// and the seat card the instruction text points at went without. A seat -/// is drawn twice and both drawings are the seat. -pub fn drop_keys(html: &str) -> std::collections::BTreeSet { - let mut out = std::collections::BTreeSet::new(); - let mut rest = html; - while let Some(i) = rest.find("data-drop=\"") { - let after = &rest[i + 11..]; - match after.find('"') { - Some(j) => { - out.insert(after[..j].to_string()); - rest = &after[j..]; - } - None => break, - } - } - out -} - pub fn text_of(html: &str) -> String { let mut out = String::with_capacity(html.len() / 2); let bytes: Vec = html.chars().collect(); diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 496d67c..484462b 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -67,20 +67,8 @@ function fetch(url, opts) { var chainable = { then: function () { return chainable; } }; return chainable; } -// A node carrying one data-drop key, with a null parent — so the -// script's walk up the tree terminates the way it does in a browser. -function __node(k) { - return { - getAttribute: function (a) { return a === 'data-drop' ? k : null; }, - parentNode: null - }; -} -function __down(k) { __handlers['pointerdown']({ target: __node(k) }); } -function __up(k) { __handlers['pointerup']({ target: __node(k) }); } -// A node with no key at all, whose parent chain ends: what the pointer -// lands on when it is dropped somewhere that is not a target. -function __downNowhere() { __handlers['pointerdown']({ target: __node(null) }); } -function __upNowhere() { __handlers['pointerup']({ target: __node(null) }); } +function __down(id) { __handlers['pointerdown']({ target: { id: id } }); } +function __up(id) { __handlers['pointerup']({ target: { id: id } }); } "#; /// Run the document's scripts, then a pointer gesture, and report what @@ -288,43 +276,6 @@ mod tests { assert!(e.contains("no pointer handlers"), "{e}"); } - /// CB-WP-0016 T02. A drop on nothing must SAY so. - /// - /// The old script returned without posting and without touching the - /// status line, so a broken drop target was indistinguishable from a - /// page that was working — which is exactly how the seat-card defect - /// survived until a human tried it. - #[test] - fn a_drop_on_nothing_reports_instead_of_going_quiet() { - let html = page(); - let ctx = quick_js::Context::new().expect("ctx"); - let posted = Arc::new(Mutex::new(0usize)); - let sink = posted.clone(); - ctx.add_callback("__post", move |_u: String, _b: String| { - *sink.lock().expect("posted") += 1; - 0i32 - }) - .expect("cb"); - ctx.add_callback("__reloaded", || 0i32).expect("cb"); - ctx.eval(DOM).expect("dom"); - for s in scripts(&html) { - ctx.eval(&s).expect("script"); - } - ctx.eval("__down('action-attack'); __upNowhere();") - .expect("gesture"); - - // Nothing goes on the wire: there is no fact to report. - assert_eq!(*posted.lock().expect("posted"), 0); - // But the page does not go quiet about it. - let status: String = ctx - .eval_as("__status.textContent") - .expect("status readable"); - assert!( - status.contains("action-attack") && status.contains("nothing droppable"), - "a drop on nothing said {status:?}" - ); - } - #[test] fn both_script_blocks_are_extracted_in_order() { let found = scripts(&page()); diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 9eebc83..c39b300 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -331,184 +331,5 @@ mod coverage { } } -/// CB-WP-0016 T03: every affordance the page offers must name an element -/// the page actually contains. -/// -/// **This is the check that closes the class the human check found.** On -/// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an -/// action onto a seat. Every test in the repo was green. The cause: the -/// visible seat cards carried no `id`, so `seat-{n}` existed only on the -/// 26 px circles inside the relationship graph, while every action card -/// read *"drag Attack onto a seat…"*. -/// -/// Nothing could catch it. `jsrun::gesture` feeds element ids straight -/// into a synthetic `{target:{id}}` and never hit-tests, so it establishes -/// *"the script posts the ids it was given"* — never *"there is an element -/// there to give."* The 42-path coverage gate asserts each view field is -/// present in the parsed document, which a `
` with no id satisfies. -/// -/// So: drive a real game, and at every decision point require that both -/// halves of every offered affordance appear as real `id` attributes. -#[cfg(test)] -mod affordances { - use std::cell::RefCell; - use std::rc::Rc; - - use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer}; - use cb_kernel::PlayerId; - use games_ground::bot::{play, Choice, Policy, RandomPolicy}; - use games_ground::{GroundCommand, GroundState}; - - use crate::{doc, input}; - - fn fresh(seed: u64) -> GroundState { - GroundState::setup( - &Setup { - players: 3, - preset: "standard-3p".into(), - patch: std::collections::BTreeMap::new(), - }, - seed, - ) - .expect("a standard 3p deal") - } - - /// Renders the page at every real decision point and checks it, then - /// delegates the actual choice. - /// - /// Hooking `Policy` rather than re-driving the game by hand matters: - /// these are the *same* decision points `cb-play --serve` renders at, - /// with the same `legal` list. A hand-rolled walk would be a second - /// implementation of the loop, and could agree with itself while - /// disagreeing with the thing shipped. - struct CheckingPolicy { - inner: RandomPolicy, - checked: Rc>, - } - - impl Policy for CheckingPolicy { - fn name(&self) -> &'static str { - "affordance-checking" - } - - fn choose( - &mut self, - state: &GroundState, - seat: PlayerId, - legal: &[GroundCommand], - may_pass: bool, - ) -> Choice { - let view = state.project(Viewer::Player(seat)); - let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass); - let present = doc::drop_keys(&html); - - for cmd in legal { - let Some((from, to)) = input::affordance(cmd, seat) else { - // A command with no affordance is offered through the - // numbered-button path instead. That is a stated - // shape, not a missing element. - continue; - }; - assert!( - present.contains(&from), - "the page offers {cmd:?} whose GRAB id {from:?} is not an \ - element in the document (seat {seat:?}, step {:?})", - state.step - ); - assert!( - present.contains(&to), - "the page offers {cmd:?} whose DROP id {to:?} is not an \ - element in the document (seat {seat:?}, step {:?}). \ - Present ids: {present:?}", - state.step - ); - } - *self.checked.borrow_mut() += 1; - self.inner.choose(state, seat, legal, may_pass) - } - } - - /// **The check that closes the class the human check found.** - /// - /// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not - /// drag an action onto a seat. Every test in the repo was green. The - /// cause: the visible seat cards carried no `id`, so `seat-{n}` existed - /// only on the 26 px circles inside the relationship graph, while every - /// action card read *"drag Attack onto a seat…"*. - /// - /// Nothing could catch it. `jsrun::gesture` feeds element ids straight - /// into a synthetic `{target:{id}}` and never hit-tests, so it - /// establishes *"the script posts the ids it was given"* — never - /// *"there is an element there to give."* The coverage gate asserts - /// each view field appears in the parsed document, which a `
` with - /// no id satisfies perfectly. - /// - /// An affordance naming an element that does not exist is the - /// harness-does-nothing shape in the presentation layer, and until now - /// it had no detector at all. - #[test] - fn every_offered_affordance_names_an_element_that_exists() { - let checked = Rc::new(RefCell::new(0usize)); - for seed in 0..4u64 { - let mut policies: Vec> = (0..3) - .map(|i| { - Box::new(CheckingPolicy { - inner: RandomPolicy::new(seed * 10 + i), - checked: checked.clone(), - }) as Box - }) - .collect(); - play(fresh(seed), &mut policies).expect("a bot game completes"); - } - // Positive control: a run that rendered nothing would assert - // nothing and read as a pass — the exact failure this test exists - // to catch, one level up. - let n = *checked.borrow(); - assert!(n >= 50, "checked only {n} decision point(s)"); - } - - /// **The regression test for the defect actually reported**, and the - /// reason the check above is not sufficient on its own. - /// - /// `every_offered_affordance_names_an_element_that_exists` passes on - /// the broken tree. `seat-0` *did* exist — on the 26 px circle in the - /// relationship graph — so an existence check over the whole document - /// cannot see that the seat *card*, which is what the instruction text - /// points at, was not droppable. - /// - /// A seat is drawn twice and both drawings are the seat. This asserts - /// the card specifically, by requiring the drop key on the element - /// that also carries `data-viewer` — the card, and nothing else. - #[test] - fn every_seat_card_is_a_drop_target_not_only_the_graph_node() { - let view = crate::testfix::view(Some(PlayerId(0))); - let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false); - - for seat in view.players.keys() { - let card = format!( - "data-viewer=\"{}\" data-drop=\"seat-{}\"", - view.viewer == Some(*seat), - seat.0 - ); - assert!( - html.contains(&card), - "seat {seat:?} has a card that is not a drop target; looked for {card:?}" - ); - } - - // And the graph node keeps working — someone will have learned to - // aim at the circle, and this fix must not take that away. - let keys = doc::drop_keys(&html); - for seat in view.players.keys() { - assert!(keys.contains(&format!("seat-{}", seat.0))); - } - assert_eq!( - html.matches("data-drop=\"seat-0\"").count(), - 2, - "seat 0 should be droppable in exactly two places: card and graph node" - ); - } -} - #[cfg(test)] mod testfix; diff --git a/evidence/CB-EV-0014-the-drop-target.md b/evidence/CB-EV-0014-the-drop-target.md deleted file mode 100644 index e5e722b..0000000 --- a/evidence/CB-EV-0014-the-drop-target.md +++ /dev/null @@ -1,139 +0,0 @@ -# CB-EV-0014 — what the human check bought - -CB-WP-0016 T03. Measured 2026-08-02 at `4df2d0a`+. Pass kind `product`, -tier **S** (chaos d4=3, no override). Declaration 11 of 12. - -Cost quotes **CB-WP-0015's** figure, per CB-EV-0012's rule and with the -correction CB-EV-0013 §5 attached to it. See §5. - ---- - -## 1. The check found a defect every test in the repo was blind to - -CB-EV-0012 §4 kept INTENT stage 1 open on one action the loop could not -perform. The maintainer ran it. **The table reads. The drag did not work.** - -Diagnosed against the live server *before* any code changed, which is what -made the rest cheap: - -``` -POST down=action-attack&up=seat-1 → ok (the game advanced) -POST down=action-attack&up=action-attack → "not a legal move here" -``` - -Socket, token guard, `resolve` and dispatch: all correct. The defect was in -the page, and it had a root cause worth more than the instance: - -> **Drop targets were `id`s, and an `id` must be unique.** So exactly one -> element could ever be `seat-0`. The relationship-graph circle took it, -> and the seat *card* — which every action card's own text points at, -> *"drag Attack onto a seat…"* — silently had none. - -A seat is drawn twice and both drawings are the seat. The document model -could not express that. - -## 2. Why nothing caught it, stated precisely - -| control | why it was blind | -|---|---| -| `jsrun::gesture` | calls `__down(id)`, which synthesized `{target:{id}}`. It feeds element ids straight in and **never hit-tests** — it establishes *"the script posts the ids it was given"*, never *"there is an element there to give"* | -| the 42-path coverage gate | asserts each view field is present in the **parsed document**. A `
` with no id satisfies that perfectly | -| `resolve`'s unit tests | test the mapping from a fact to a command. The fact never arrives | -| M-D1-MUT | its population is the AM-* acceptance rows. None of them is about the page | - -Every one was green. This is the shape CB-WP-0015 closed one layer in — a -harness answering a narrower question than its name implies — recurring in -the presentation layer. - -## 3. The fix, and the check that is honestly insufficient - -**Drop keys are now `data-drop`, not `id`.** Any number of elements may -carry the same key, so a seat is droppable on its card *and* on its graph -node. Measured on the live page: `seat-0`, `seat-1`, `seat-2` each appear -**twice**; `id` survives on exactly one element, `cb-status`, which is the -only one the script looks up. - -Two checks, and the difference between them is the finding: - -| check | catches the reported defect? | -|---|---| -| **every offered affordance names a key that exists** — drives four real bot games through `Policy::choose`, renders at every real decision point | **NO.** `seat-0` *did* exist, on the graph circle | -| **every seat card is a drop target, not only the graph node** | **yes** | - -The general check is worth having — it fails when a target is wholly -absent, which is a real class — but **it would not have found the bug the -maintainer found**, and saying otherwise would be the exact error this -project keeps catching. An existence check over a whole document cannot -tell you the element the user is being *pointed at* is the one that works. - -Hooking `Policy::choose` rather than re-driving the game by hand matters: -those are the same decision points `cb-play --serve` renders at, with the -same `legal` list. A hand-rolled walk would be a second implementation of -the loop, free to agree with itself while disagreeing with what ships. - -### Mutations, each red for its stated reason - -| mutation | result | -|---|---| -| the seat card loses its drop key — *the reported defect, reintroduced* | red, **and only the targeted test fired**; the general one stayed green | -| the table stops being a drop target | red — *"whose DROP id `table` is not an element in the document"* | -| the silent `return` comes back | red — *"a drop on nothing said `""`"* | - -## 4. Silence was the second defect - -`SCRIPT` did `if (!down || !up) { down = null; return; }` — no POST, no -status line, nothing at all. **That is why a human sitting in front of it -could not tell a broken target from a working page.** - -The Rust side already held the right principle: `resolve` refuses rather -than substituting a default, because *"a drag that means nothing must mean -nothing, not the first legal move."* Refusing is right. Refusing -**silently** is not, and the two had been conflated. - -The page now reports the raw fact — *"took action-attack, let go over -nothing droppable"*. It names elements, not moves, so ADR-0007 control 5 -is intact and the body-shape assertion still holds. - -## 5. What the human check cost, and what it bought - -Stage 1 was held open on this check for **two passes** (CB-WP-0014, -CB-WP-0015), against a standing temptation to close it on green tests — -CB-EV-0012 §4 recorded that temptation explicitly and refused it. - -It bought a defect that made the stage's headline interaction -**non-functional on its primary target**, plus a root cause in the document -model, plus a control class that did not exist. Two passes of delay was the -right price, and the reasoning that kept it open — *"no test in this repo -can reach it"* — was exactly correct rather than merely cautious. - -**The stage does not close here either.** The fix is verified by tests, by -mutation, and against a live server; it is **not** verified by a human -dragging. That is the same standard that found this, and the same one that -would have missed it. - -| pass | kind | responses | cost | $/response | -|---|---|---|---|---| -| **CB-WP-0015** | product | 136 | **$15.14** | 0.111 | -| CB-WP-0016 | product | *provisional — not quoted* | | | - -CB-EV-0013 §5 found the self-quoting rule fixes the wrong boundary: a -pass's window runs to the *next* pass's first commit, so CB-WP-0015's -figure was still open when quoted. It is quoted here after CB-WP-0016's -declaration commit closed it, which is the first figure this project has -quoted at a boundary that had actually settled. - -## 6. Open - -- **INTENT stage 1: still one human verification**, now of a fix rather - than of an unknown. `cb-play --serve 0`, drag an action onto a seat - *card*. -- **The self-quoting rule still names the wrong boundary** — quote two - passes back. Owed since CB-EV-0013 §5. -- **AM-4b's scope defect (408,237 uncounted lines)** and its unmeasured - proc-macro share. -- **`python3` as a toolchain dependency was never argued.** -- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000. -- **ADR-0007 D3's acquisition rule** remains unratified after deciding two - dependency questions. -- **Chaos: 11 of 12 declarations, 1 override.** The calibration window - closes on the next declaration and owes an evaluation. diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index c900dd9..d75d2af 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -295,13 +295,9 @@ mod tests { let replies = client.join().expect("client thread"); assert!(replies[0].contains("200 OK"), "{}", replies[0]); assert!(replies[0].contains("GROUND"), "the page was not the table"); - // Through the parser, not a raw attribute match: this assertion - // read `id="action-ground"` and CB-WP-0016 moved drop keys to - // `data-drop`, so a substring test drifts silently on the next - // rename while still describing itself as checking the page. assert!( - cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"), - "the offered action was not a drop target on the page" + replies[0].contains("id=\"action-ground\""), + "the offered action was not on the page" ); assert!(replies[1].contains("200 OK")); assert!(server.refusals().is_empty()); diff --git a/workplans/CB-WP-0016-the-drop-target.md b/workplans/CB-WP-0016-the-drop-target.md deleted file mode 100644 index 6423ce8..0000000 --- a/workplans/CB-WP-0016-the-drop-target.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -id: CB-WP-0016 -kind: product -title: "The drop target that was never there" -status: done ---- - -# Purpose - -``` -structural tier S (a defect fix inside an existing capability — no new - port, no canonical interface, no new dependency, and - the check it adds is a product test, not a control - gate) -chaos d4 = 3 → no override -declared tier S -``` - -Declaration 11 of 12. Tier S: one provenance paragraph in the commit -message, no survey and no ADR. Steps 4–5 are not skipped. - -## Found by the human check, and by nothing else - -CB-EV-0012 §4 kept INTENT stage 1 open on one action the loop could not -perform: run `cb-play --serve 0`, open the URL, confirm the table reads and -a drag works. The maintainer ran it on 2026-08-02. **The table reads. The -drag does not.** - -Diagnosed against the running server before any code was changed: - -``` -POST down=action-attack&up=seat-1 → ok (the game advanced) -POST down=action-attack&up=action-attack → "not a legal move here" -``` - -So the socket, the token guard, `resolve`, and command dispatch are all -correct. The defect is in the emitted page. - -**`seat-{n}` ids exist only on the ~26 px circles inside the relationship -graph** (``, `doc.rs:185`). The visible *seats* section -cards are emitted by `player_card` as `
` -with **no id**. Every action card says *"drag Attack onto a seat, a -problem, or the table"*, and the seat it points at is not a target. - -`#table`, `problem-{n}` and `freedom-{n}` are real targets, so the page is -not wholly inert — the most natural target is. - -## Why every test in the repo passed - -`jsrun::gesture(html, down, up)` calls `__down(id)`, which synthesizes -`{ target: { id: id } }`. **It feeds element ids straight in and never -hit-tests.** So it establishes *"the script posts the ids it was given"* -and never *"the page has an identified element where the instruction says -to drop."* - -The 42-path coverage gate is blind for a related reason: it asserts each -view field is **present in the parsed document**, which a `
` with no -id satisfies perfectly. - -CB-WP-0015 closed a clause whose test was named for a property it did not -check. This is the same shape one layer out — a harness answering a -narrower question than its name implies — and it is the argument for -having kept the human check as a blocker rather than closing stage 1 on -green tests two passes ago. - -## Task: make the seat a drop target, and keep ids unique - -```task -id: CB-WP-0016-T01 -status: done -priority: high -``` - -The seat card must carry the id `affordance()` already names for it. -`seat-{n}` is currently taken by the SVG node, and duplicate ids are -invalid HTML — so one of the two has to change, and **both must remain -droppable.** The graph circle works today and someone will have learned it. - -Decide where the aliasing lives. Rust decides what a pointer fact means -(ADR-0007 D5), so widening what `resolve` accepts is in bounds and -inventing a new id vocabulary in the page is not. - -**Control:** dropping on the seat *card* and dropping on the graph *node* -must produce the same command, and a test must go red if either stops -resolving. - -**Done 2026-08-02.** The aliasing was not needed, because the constraint -that caused the defect was removed instead. - -**Drop keys are now `data-drop`, not `id`.** An `id` must be unique, so -exactly one element could ever be `seat-0` — the graph circle took it and -the card went without. Any number of elements may carry the same -`data-drop`, so a seat is droppable on both of its drawings. Measured on -the live page: `seat-0/1/2` each appear **twice**, and `id` survives on -exactly one element, `cb-status`, which is the only one the script looks -up. `down=action-attack&up=seat-1` against a real server returns `ok`. - -The mutation that reintroduces the reported defect — the seat card losing -its key — goes red, **and only the targeted test fires**; the general -existence check stays green. That is the point of T03's finding. - -## Task: a gesture that lands nowhere must say so - -```task -id: CB-WP-0016-T02 -status: done -priority: high -``` - -`SCRIPT` currently does `if (!down || !up) { down = null; return; }` — no -POST, no status line, nothing. That silence is why this defect survived a -human sitting in front of it. - -The Rust side already holds the right principle: `resolve` refuses rather -than substituting a default, because *"a drag that means nothing must mean -nothing, not the first legal move."* Refusing is correct. Refusing -**silently** is not. - -Make the outcome visible. Keep ADR-0007 control 5 intact — whatever the -page reports must still be raw pointer facts with no game vocabulary, and -the existing body-shape assertion must still hold. - -**Done 2026-08-02.** The page now writes *"took action-attack, let go over -nothing droppable"* to the status line. It names elements, not moves, so -control 5 holds and the body-shape assertion is untouched. Restoring the -bare `return` turns -`jsrun::tests::a_drop_on_nothing_reports_instead_of_going_quiet` red with -*"a drop on nothing said `\"\"`"*. - -The JS DOM stub had to grow a real `getAttribute` and a null-parent node -to model a pointer landing on nothing — it previously could not express -the case at all, which is part of why the silence was invisible. - -## Task: the check that closes the class - -```task -id: CB-WP-0016-T03 -status: done -priority: high -``` - -**For every legal command, both ids `affordance()` produces must occur in -the emitted document.** Pure Rust, no JS engine, no browser, deterministic. -It fails on today's tree and it would have caught this the day the -renderer was written. - -This is the deliverable that matters — T01 fixes one instance, this closes -the class. An affordance naming an element that does not exist is exactly -the harness-does-nothing shape, and it currently has no detector at all. - -Then `evidence/CB-EV-0014-*.md`: - -- **What the human check bought**, priced honestly against what it cost to - keep stage 1 open for two passes. -- **Whether stage 1 closes now.** It needs the maintainer to drag again; - say so rather than closing it on a passing test, which is the failure - mode this whole pass is evidence for. -- **Quote CB-WP-0015's final cost, not this pass's** — and note that - CB-EV-0013 §5 found the self-quoting rule fixes the wrong boundary, so - say plainly whether the figure has settled. -- **Chaos: declaration 11 of 12.** The calibration window closes next - declaration. - -**Done 2026-08-02.** -[CB-EV-0014](../evidence/CB-EV-0014-the-drop-target.md). `make all` -exits 0. - -- **The general check does not catch the reported defect, and the evidence - says so plainly.** `seat-0` existed — on the graph circle — so an - existence check over the whole document cannot tell that the element the - user is *pointed at* is not the one that works. It is kept because a - wholly absent target is a real class, and paired with a targeted - regression test that does catch it. -- **Three mutations, each red for its stated reason**, including the - reported defect reintroduced. -- **Stage 1 does not close here either.** The fix is verified by tests, by - mutation, and against a live server — not by a human dragging, which is - the standard that found it. -- **A cb-play assertion drifted**: it matched `id=\"action-ground\"` as a - substring and silently described itself as checking the page. Rewritten - through `doc::drop_keys`.