diff --git a/INTENT.md b/INTENT.md index 2a13569..d884b5a 100644 --- a/INTENT.md +++ b/INTENT.md @@ -64,14 +64,17 @@ 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, and every run of it so far has found + something no test could. 2026-08-02, run 1: the drag was broken — drop + targets were `id`s, which must be unique, so the graph circle held + `seat-0` and the seat card had none (CB-WP-0016). Run 2: the drag + worked but the page was **wrong about which moves exist**, showing 5 + cards each claiming all three target kinds where 9 specific commands + were legal, with no way to see what was pickable, held, or droppable + (CB-WP-0017). Both fixed. What remains is perceptual and unreachable + from here by construction (ADR-0010 D5): run `cb-play --serve 0` and + confirm you can see what can be picked up, what you are holding, and + where it may go.* 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/src/doc.rs b/crates/cb-render-html/src/doc.rs index 4962034..c436109 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -56,34 +56,85 @@ fn cards(list: &[games_ground::SolutionCard]) -> String { /// The only JavaScript in the project. See the module docs. pub const SCRIPT: &str = r#" (function () { - var down = null; - function key(e) { + var down = null, held = null, marked = [], ghost = null; + + function node(e) { var n = e.target; while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; } - return n ? n.getAttribute('data-drop') : null; + return n; } - document.addEventListener('pointerdown', function (e) { down = key(e); }); + function key(n) { return n ? n.getAttribute('data-drop') : null; } + function status(t) { document.getElementById('cb-status').textContent = t; } + + // 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 + // legal would be forbidden even though the visible result is identical. + function mark(n) { + var spec = n.getAttribute('data-targets'); + if (!spec) { return; } + var want = spec.split(' '); + var all = document.querySelectorAll('[data-drop]'); + for (var i = 0; i < all.length; i++) { + if (want.indexOf(all[i].getAttribute('data-drop')) >= 0) { + all[i].classList.add('dropok'); + marked.push(all[i]); + } + } + } + function clear() { + for (var i = 0; i < marked.length; i++) { marked[i].classList.remove('dropok'); } + marked = []; + if (held) { held.classList.remove('held'); held = null; } + if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); } + ghost = null; + down = null; + } + + document.addEventListener('pointerdown', function (e) { + clear(); + var n = node(e); + down = key(n); + if (!n || !down) { return; } + held = n; + n.classList.add('held'); + mark(n); + if (n.getAttribute('data-targets')) { + ghost = document.createElement('div'); + ghost.id = 'cb-ghost'; + ghost.textContent = n.textContent; + ghost.style.left = e.clientX + 'px'; + ghost.style.top = e.clientY + 'px'; + document.body.appendChild(ghost); + } + }); + + document.addEventListener('pointermove', function (e) { + if (!ghost) { return; } + ghost.style.left = e.clientX + 'px'; + ghost.style.top = e.clientY + 'px'; + }); + + document.addEventListener('pointercancel', clear); + 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; + var up = key(node(e)); + var grabbed = down; + clear(); + if (!grabbed || !up) { + // Refusing is right; refusing SILENTLY is what let a broken drop + // target survive a human sitting in front of it (CB-WP-0016). + status(grabbed ? 'took ' + grabbed + ', let go over nothing droppable' + : 'nothing droppable under the pointer'); return; } - var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); - down = null; + var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up); fetch(window.CB_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }).then(function (r) { return r.text(); }).then(function (t) { - document.getElementById('cb-status').textContent = t; + status(t); if (t.indexOf('ok') === 0) { window.location.reload(); } }); }); @@ -98,11 +149,51 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} .card[data-viewer=true]{border-color:#9cf} .act{cursor:grab;user-select:none;background:#243;border-color:#5a7} .btn{cursor:pointer;background:#332a3a;border-color:#a7d} + +/* CB-WP-0017. Interactive and inert must not look identical: `.pick` is + the resting affordance on anything that can be picked up. ADR-0010 D5 + claims no evidence that this READS as pickable -- that is a human + check, and stage 1 is open on it. */ +.pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset; + transition:transform .08s,box-shadow .08s} +.pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)} +/* Held: the thing in your hand is lifted and dimmed where it used to be. */ +.held{cursor:grabbing;opacity:.45;transform:scale(.97)} +/* A legal destination for the thing currently held -- and ONLY for that + thing. The set is written by Rust into data-targets; the script matches + it and never derives it (ADR-0010 D1). */ +.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33} +.dropok text{fill:#cfe} +/* The ghost that follows the pointer, so a drag is not invisible. */ +#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem; + border-radius:6px;background:#243;border:1px solid #5a7;color:#dde; + font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b; + transform:translate(-50%,-140%)} .k{color:#89a} #cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} svg{background:#1b1e26;border:1px solid #445;border-radius:6px} "; +/// Render drop keys as something a player can read. +/// +/// The keys are what the page posts; these are what it shows. Both come +/// from the same list, so they cannot disagree about which moves exist. +fn target_names(targets: &[String]) -> String { + let mut out: Vec = Vec::new(); + for t in targets { + out.push(match t.split_once('-') { + Some(("seat", n)) => n + .parse::() + .map(|n| seat_name(PlayerId(n))) + .unwrap_or_else(|_| t.clone()), + Some(("problem", n)) => format!("problem {n}"), + _ if t == "table" => "the table".to_string(), + _ => t.clone(), + }); + } + out.join(", ") +} + fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { let (label, sub, fill) = match p { ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"), @@ -232,7 +323,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" }, @@ -430,17 +521,33 @@ pub fn document( games_ground::Action::Attack, games_ground::Action::Ground, ] { - let offered = seat.is_some_and(|seat| { - legal.iter().any(|c| { - crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a)) + // ADR-0010 D1: the legal targets come from `legal` and are + // written into the page as data. The script matches on them; + // it never derives them. The old text was a CONSTANT — + // "onto a seat, a problem, or the table" — emitted whenever + // any legal command used this action, and it was wrong + // wherever the real target set was narrower, which is almost + // everywhere: Investigate is legal on problems 2 and 3 but + // not 1 (CB-WP-0017). + let targets: Vec = seat + .map(|seat| { + legal + .iter() + .filter_map(|c| crate::input::affordance(c, seat)) + .filter(|(f, _)| *f == action_id(a)) + .map(|(_, t)| t) + .collect() }) - }); - if offered { + .unwrap_or_default(); + if !targets.is_empty() { let _ = write!( s, - "
drag {a:?} onto a seat, a problem, \ - or the table
", + "
{a:?}
\ + onto {names}
", id = action_id(a), + targets = esc(&targets.join(" ")), + names = esc(&target_names(&targets)), ); } } @@ -450,7 +557,7 @@ pub fn document( if !spatial { let _ = write!( s, - "
{}
", + "
{}
", esc(&format!("{c:?}")) ); } @@ -461,7 +568,9 @@ pub fn document( ); } if may_pass { - s.push_str("
pass \u{2014} decline to act
"); + s.push_str( + "
pass \u{2014} decline to act
", + ); } let _ = write!( diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 496d67c..5713ee3 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -55,32 +55,79 @@ pub fn scripts(html: &str) -> Vec { const DOM: &str = r#" var __handlers = {}; var __status = { textContent: "" }; +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; } }; +var __all = []; + +// A DOM node with a real classList and real attributes. CB-WP-0016 found +// that a stub too thin to express a failure is how the failure survives; +// the previous stub could not express class-toggling at all. +function __mk(key, targets, text) { + var n = { + parentNode: null, + textContent: text || key, + style: {}, + _cls: {}, + _attr: { 'data-drop': key, 'data-targets': targets || null }, + getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, + setAttribute: function (a, v) { this._attr[a] = v; }, + classList: { + add: function (c) { n._cls[c] = true; }, + remove: function (c) { delete n._cls[c]; }, + contains: function (c) { return !!n._cls[c]; } + } + }; + return n; +} +function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; } + var document = { + body: __body, addEventListener: function (type, fn) { __handlers[type] = fn; }, - getElementById: function (id) { return __status; } + getElementById: function (id) { return __status; }, + createElement: function (_tag) { return __mk(null, null, ""); }, + querySelectorAll: function (_sel) { return __all; } }; var window = { location: { reload: function () { __reloaded(); } } }; function fetch(url, opts) { __post(url, (opts && opts.body) || ""); - // The script chains .then(...).then(...); give it something to chain on - // without ever resolving, so response handling stays out of scope here. 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 - }; + +// Gestures are driven against REGISTERED nodes, so the script's walk, its +// classList writes and its querySelectorAll all see the same objects a +// browser would -- rather than a synthetic {target:{id}} that bypasses +// every one of them (CB-EV-0014 section 2). +function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); } +function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); } +function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } } +function __cancel() { __handlers['pointercancel']({ target: __find(null) }); } +function __find(k) { + for (var i = 0; i < __all.length; i++) { + if (__all[i].getAttribute('data-drop') === k) { return __all[i]; } + } + return __mk(null, 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 __downNowhere() { __handlers['pointerdown']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); } +function __upNowhere() { __handlers['pointerup']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); } +function __marked() { + var out = []; + for (var i = 0; i < __all.length; i++) { + if (__all[i].classList.contains('dropok')) { out.push(__all[i].getAttribute('data-drop')); } + } + return out.sort().join(','); +} +function __heldKeys() { + var out = []; + for (var i = 0; i < __all.length; i++) { + if (__all[i].classList.contains('held')) { out.push(__all[i].getAttribute('data-drop')); } + } + return out.sort().join(','); +} +function __ghosts() { return __body.children.length; } "#; /// Run the document's scripts, then a pointer gesture, and report what @@ -105,16 +152,7 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> ctx.add_callback("__reloaded", || 0i32) .map_err(|e| format!("register __reloaded: {e}"))?; - ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?; - - let found = scripts(html); - if found.is_empty() { - return Err("the document carries no