From ca92db53bc0211e6f0195337ab00fa8f99556111 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 22:32:50 +0200 Subject: [PATCH 1/2] Declare CB-WP-0017: legible interaction, tier M (structural S, chaos 4) Provenance: the maintainer ran stage 1's check after CB-WP-0016, could drag, and reported that the UI gives no way to tell what can be picked up, what is being dragged, or where it may be dropped. Combined with the prior finding that the page hides which moves are legal -- measured, 9 legal commands rendered as 5 cards each claiming all three target kinds, with Investigate legal on problems 2 and 3 but not 1 -- highlighting drop targets is not decoration, it is the first time the page tells the truth. Structural tier S: presentation work inside an existing capability. CHAOS ROLLED 4 -> OVERRIDE, drawn tier M. Second override in twelve declarations and it rolls the opposite way from the first (CB-WP-0011 was L rolled down to S), so the calibration window closes with one of each, which is the minimum that makes its evaluation possible. Declaration 12 of 12 -- the window closes here and T04 owes the verdict. Co-Authored-By: Claude Opus 5 --- workplans/CB-WP-0017-legible-interaction.md | 186 ++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 workplans/CB-WP-0017-legible-interaction.md diff --git a/workplans/CB-WP-0017-legible-interaction.md b/workplans/CB-WP-0017-legible-interaction.md new file mode 100644 index 0000000..a8991dd --- /dev/null +++ b/workplans/CB-WP-0017-legible-interaction.md @@ -0,0 +1,186 @@ +--- +id: CB-WP-0017 +kind: product +title: "Legible interaction: what can be picked up, what is being dragged, where it can go" +status: todo +--- + +# Purpose + +``` +structural tier S (presentation work inside an existing capability — + no new port, no canonical interface, no new + dependency, no change to the loop's own constraints) +chaos d4 = 4 → OVERRIDE, tier drawn: M +declared tier M (structural S, chaos 4 → M) +``` + +**Declaration 12 of 12 — the chaos calibration window closes here**, and +owes its evaluation (T04). + +This is the **second override in twelve declarations**, and it rolls the +opposite way from the first: CB-WP-0011 was structural L rolled *down* to +S, this is structural S rolled *up* to M. The window therefore has one of +each to evaluate, which is the minimum that makes an evaluation possible +at all. + +Tier M means survey and decision merge into one document and the +adversarial review is optional. + +## The report this pass exists for + +The maintainer ran stage 1's human check again after CB-WP-0016 and could +drag — but: + +> *"it is not possible to get what moves are possible and how they affect +> the state of the game"* … *"Can i have better visualization of which +> Elements in the ui can be manipulated"* … *"If the element isnt shown as +> picked up, there is no way to understand that i am actually dragging it +> or where i am dragging it"* … *"visual clues about where something can be +> dropped when it is picked up"* + +Four requests, and one prior finding that makes them one pass: **the page +already hides which moves are legal.** + +Measured, seat P1 at round 1 step Select — **9 legal commands**, rendered +as 5 cards each claiming all three target kinds: + +| the card says | actually legal | +|---|---| +| Investigate → *"a seat, a problem, or the table"* | **problem-2, problem-3** | +| Solve → *"a seat, a problem, or the table"* | **problem-1** | +| Attack → *"a seat, a problem, or the table"* | **seat-1, seat-2** | +| Support → *"a seat, a problem, or the table"* | **seat-1, seat-2** | +| Ground → *"a seat, a problem, or the table"* | **table** | + +Investigate is legal on problems 2 and 3 but not 1; Solve on 1 but not 2 +or 3. The card text is a **constant** — `"drag {a:?} onto a seat, a +problem, or the table"` — emitted whenever *any* legal command uses that +action, then naming all three kinds regardless. `legal` carries the exact +targets and the renderer discards them. + +So highlighting drop targets is not decoration on top of a working table. +It is the first time the page will tell the truth about what is legal. + +## Task: decide how far the JavaScript may grow + +```task +id: CB-WP-0017-T01 +status: todo +priority: high +``` + +Write `decisions/ADR-0010-*.md` (tier M: survey and decision in one). + +**This is a real decision, not a formality.** ADR-0007 Decision 5 says the +page *"reports raw pointer facts and nothing else, and Rust decides what +command they mean"*, and `doc.rs` says of `SCRIPT`: *"it does one thing"*. +Everything asked for here needs the script to do **more** than one thing — +track a drag in flight, move a ghost with the pointer, add and remove +classes on other elements. + +Decide, explicitly: + +- **Does class-toggling violate control 5?** The honest argument is that + legality still comes from Rust as data and the script only renders it — + but that argument must be *made*, because "the page renders what Rust + said" is exactly what a page constructing commands would also claim. +- **What is the invariant that replaces "it does one thing"?** If the + script may grow, say what it may never do, in a form a test can check. + The existing no-game-vocabulary test is the candidate and it is weak. +- **The cost.** Marginal AM-4a is zero either way, so the budget does not + decide this. Say what does. + +## Task: tell the truth about which moves are legal + +```task +id: CB-WP-0017-T02 +status: todo +priority: high +``` + +Emit the legal targets **derived from `legal`**, per action, and render +them so the table is legible without dragging anything. + +The generic sentence goes. A card that says *"onto a seat, a problem, or +the table"* when only `problem-1` is legal is not a simplification, it is +wrong. + +**Controls:** +- The emitted target set must equal the affordance targets of the legal + list exactly — pure Rust, no browser, and it must go red when the sets + diverge. +- A drop that the page marked legal must **resolve**. If the page can + advertise a target `resolve` then refuses, the two have drifted and the + highlighting is worse than none. + +## Task: make picking up, dragging, and dropping visible + +```task +id: CB-WP-0017-T03 +status: todo +priority: high +``` + +The four requests, in order of how badly they are needed: + +1. **What can be manipulated at all** — a resting affordance on every + pickable element. The maintainer asked for a slight shadow; the point + is that interactive and inert must not look identical. +2. **What is picked up** — the grabbed element must visibly change while + the pointer is down. +3. **Where it can go** — every legal drop target for *the thing currently + held* highlights, and nothing else does. +4. **Where it is going** — the drag must be followable, not invisible. + +**Be honest about what is testable.** Whether a shadow reads as "you can +pick this up" is not something any test here can settle, and claiming +otherwise would be this project's characteristic error in its newest +costume. What *is* testable, and must be: + +- pointerdown marks the grabbed element and **exactly** the legal targets + for it, and nothing else; +- pointerup and cancellation clear every mark — a drag that ends leaves no + residue, or the next drag highlights a stale set; +- the highlighted set is the same set T02 emitted. + +The JS DOM stub cannot express any of this today. It will have to model +`classList` and element lookup, which is a real deepening of the harness — +and CB-WP-0016 showed that a stub too thin to express a failure is how the +failure survives. + +## Task: evidence, and close the chaos calibration window + +```task +id: CB-WP-0017-T04 +status: todo +priority: high +``` + +`evidence/CB-EV-0015-*.md`, and then **the evaluation the window has owed +since 2026-07-31**. + +`gates.toml` states the retirement condition in its own words: + +> *retire_if = "the window closes with no overridden tier producing a +> different outcome than the argued one — the evaluation this window +> exists to make possible"* + +So answer it, on the two overrides actually observed: + +- **CB-WP-0011**, structural L → S. What did the deleted survey cost or + save? CB-EV-0009 §1 and CB-EV-0010 §5 already priced part of this + (0.123 $/response at L against 0.099 at S on the same subject). +- **CB-WP-0017**, structural S → M. What did the *extra* ADR buy that a + tier-S provenance paragraph would not have? If the answer is nothing, + say so — that is the finding the window was opened to produce, and a + mechanism that survives its own evaluation by being graded generously is + worse than one that is retired. + +Then: keep, retire, or change the rate, with the argument. + +Also due: + +- **Whether stage 1 closes.** It needs a human to drag again. Say so. +- **Quote CB-WP-0016's cost**, and apply CB-EV-0013 §5's correction — + quote a boundary that has actually settled, or say it has not. From a55e878bf0a0a86035cb0d3f9ec0cf572b185860 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 22:42:45 +0200 Subject: [PATCH 2/2] CB-WP-0017: legible interaction, and the chaos window's verdict Provenance (tier M, structural S, chaos d4=4 -> OVERRIDE drawn M): the maintainer could drag after CB-WP-0016 but could not tell what was pickable, held, or droppable. Underneath that, the page was WRONG about which moves exist: 9 legal commands rendered as 5 cards each claiming all three target kinds, from a const string in the emitter. Investigate is legal on problems 2 and 3 but not 1; Solve on 1 but not 2 or 3. The live page now says 'Solve onto problem 1'. ADR-0010 restates control 5, which this work would otherwise have outgrown in silence: every game fact the page acts on must arrive from Rust as data; the script may read, match and render it, never compute, infer, filter or default one. The survey's real finding is that the permitted and forbidden designs are indistinguishable from outside, so the vocabulary grep is demoted to a cheap first line and two behavioural properties become the controls -- the highlighted set EQUALS the set Rust emitted, and anything the page marks legal must resolve. Both mutation-proven; the derive-legality mutation produces a plausible highlight (seat-0,1,2 where only seat-1 is legal) and is caught. Visible now: .pick resting shadow, .held on the grabbed element, .dropok on every legal target including BOTH drawings of a seat, and a ghost following the pointer. Nothing perceptual is verified and ADR-0010 D5 says so. The DOM stub now models classList/querySelectorAll/createElement and builds its node set from the real emitted page. Trap recorded: QuickJS fixes its stack limit at Context creation relative to that frame, so a helper returning a Context makes every later eval report 'SyntaxError: stack overflow'. CHAOS WINDOW CLOSED, 12 declarations, 2 overrides, one each way. Both changed the outcome, so the retirement condition is not met. Verdict: keep, and recommend d4 -> d8 with a second window of 12 -- that is a change to the loop's own constraints and is owed to the next declaration as tier-M work, not made here. CB-EV-0014 corrected: it quoted CB-WP-0015 at $15.14/136 and called it the first settled figure quoted. Now $22.70/166. The number had been read during CB-WP-0015 itself, so there are two defects -- the boundary, and quoting from memory instead of re-running the instrument. make all exits 0. Co-Authored-By: Claude Opus 5 --- INTENT.md | 19 +- crates/cb-render-html/src/doc.rs | 163 +++++++++-- crates/cb-render-html/src/jsrun.rs | 273 ++++++++++++++++--- crates/cb-render-html/src/lib.rs | 63 +++++ decisions/ADR-0010-what-the-script-may-do.md | 136 +++++++++ evidence/CB-EV-0014-the-drop-target.md | 19 +- evidence/CB-EV-0015-legible-interaction.md | 221 +++++++++++++++ gates.toml | 13 + workplans/CB-WP-0017-legible-interaction.md | 81 +++++- 9 files changed, 911 insertions(+), 77 deletions(-) create mode 100644 decisions/ADR-0010-what-the-script-may-do.md create mode 100644 evidence/CB-EV-0015-legible-interaction.md 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