CB-WP-0017: legible interaction, and the chaos window's verdict
Some checks failed
ci / check (push) Failing after 4s
Some checks failed
ci / check (push) Failing after 4s
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 <noreply@anthropic.com>
This commit is contained in:
parent
ca92db53bc
commit
a55e878bf0
9 changed files with 911 additions and 77 deletions
19
INTENT.md
19
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,
|
||||
|
|
|
|||
|
|
@ -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<String> = Vec::new();
|
||||
for t in targets {
|
||||
out.push(match t.split_once('-') {
|
||||
Some(("seat", n)) => n
|
||||
.parse::<u8>()
|
||||
.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,
|
||||
"<span data-drop=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
|
||||
"<span data-drop=\"freedom-{raw}\" class=\"act pick\"><span class=\"k\">freedom</span> \
|
||||
{ready}{lifted}</span><br>",
|
||||
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<String> = 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,
|
||||
"<div class=\"card act\" data-drop=\"{id}\">drag {a:?} onto a seat, a problem, \
|
||||
or the table</div>",
|
||||
"<div class=\"card act pick\" data-drop=\"{id}\" \
|
||||
data-targets=\"{targets}\">{a:?}<br>\
|
||||
<span class=\"k\">onto</span> {names}</div>",
|
||||
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,
|
||||
"<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
|
||||
"<div class=\"card btn pick\" data-drop=\"cmd-{i}\">{}</div>",
|
||||
esc(&format!("{c:?}"))
|
||||
);
|
||||
}
|
||||
|
|
@ -461,7 +568,9 @@ pub fn document(
|
|||
);
|
||||
}
|
||||
if may_pass {
|
||||
s.push_str("<div class=\"card btn\" data-drop=\"pass\">pass \u{2014} decline to act</div>");
|
||||
s.push_str(
|
||||
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
|
||||
);
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
|
|
|
|||
|
|
@ -55,32 +55,79 @@ pub fn scripts(html: &str) -> Vec<String> {
|
|||
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<Vec<Posted>, 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 <script> block".to_string());
|
||||
}
|
||||
for (i, s) in found.iter().enumerate() {
|
||||
ctx.eval(s)
|
||||
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
|
||||
}
|
||||
prepare(&ctx, html)?;
|
||||
|
||||
// If the page never registered handlers, the gesture below would be a
|
||||
// no-op and the test would read as "sent nothing" rather than "the
|
||||
|
|
@ -137,6 +175,73 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every droppable in the document, as `(data-drop, data-targets)`.
|
||||
///
|
||||
/// The stub's node set is built from the **real emitted page**, not from a
|
||||
/// list the harness invented. CB-EV-0014 §2: the old stub synthesized
|
||||
/// `{target:{id}}` from whatever id the test passed in, so it could not
|
||||
/// tell whether the page had such an element at all.
|
||||
pub fn droppables(html: &str) -> Vec<(String, Option<String>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = html;
|
||||
while let Some(i) = rest.find("data-drop=\"") {
|
||||
let after = &rest[i + 11..];
|
||||
let Some(j) = after.find('"') else { break };
|
||||
let key = after[..j].to_string();
|
||||
// `data-targets`, when present, is written on the same element, so
|
||||
// it is between here and the end of this tag.
|
||||
let tail = &after[j..];
|
||||
let end = tail.find('>').unwrap_or(tail.len());
|
||||
let targets = tail[..end].find("data-targets=\"").and_then(|k| {
|
||||
let t = &tail[k + 14..];
|
||||
t.find('"').map(|e| t[..e].to_string())
|
||||
});
|
||||
out.push((key, targets));
|
||||
rest = tail;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Install the DOM stub, register the page's droppables, and evaluate the
|
||||
/// page's own scripts into a context the **caller** created.
|
||||
///
|
||||
/// **The caller must own the `Context`, and that is not a style
|
||||
/// preference.** QuickJS records its stack limit at runtime creation,
|
||||
/// relative to the frame it was created in, and later checks the current
|
||||
/// frame against it. Creating the context inside a helper and returning it
|
||||
/// leaves every subsequent `eval` running in a *shallower* frame, the
|
||||
/// comparison underflows, and QuickJS reports `SyntaxError: stack
|
||||
/// overflow` for even `__all.length`. Cost an hour; recorded so it costs
|
||||
/// nobody else one.
|
||||
///
|
||||
/// Shared so a test cannot set up a different page than the harness runs:
|
||||
/// one hand-rolled setup had already skipped registration silently.
|
||||
pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
|
||||
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
|
||||
register(ctx, html)?;
|
||||
let found = scripts(html);
|
||||
if found.is_empty() {
|
||||
return Err("the document carries no <script> block".to_string());
|
||||
}
|
||||
for (i, sc) in found.iter().enumerate() {
|
||||
ctx.eval(sc)
|
||||
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
|
||||
for (key, targets) in droppables(html) {
|
||||
let call = match targets {
|
||||
Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)),
|
||||
None => format!("__register({}, null);", json_lit(&key)),
|
||||
};
|
||||
ctx.eval(&call)
|
||||
.map_err(|e| format!("register {key}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_lit(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
|
|
@ -296,7 +401,6 @@ mod tests {
|
|||
/// 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();
|
||||
|
|
@ -306,10 +410,8 @@ mod tests {
|
|||
})
|
||||
.expect("cb");
|
||||
ctx.add_callback("__reloaded", || 0i32).expect("cb");
|
||||
ctx.eval(DOM).expect("dom");
|
||||
for s in scripts(&html) {
|
||||
ctx.eval(&s).expect("script");
|
||||
}
|
||||
prepare(&ctx, &page()).expect("prepare");
|
||||
|
||||
ctx.eval("__down('action-attack'); __upNowhere();")
|
||||
.expect("gesture");
|
||||
|
||||
|
|
@ -325,6 +427,115 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Setup shared by the drag-visibility tests.
|
||||
fn ctx_for(html: &str) -> quick_js::Context {
|
||||
let ctx = quick_js::Context::new().expect("ctx");
|
||||
ctx.add_callback("__post", |_u: String, _b: String| 0i32)
|
||||
.expect("cb");
|
||||
ctx.add_callback("__reloaded", || 0i32).expect("cb");
|
||||
prepare(&ctx, html).expect("prepare");
|
||||
ctx
|
||||
}
|
||||
|
||||
/// **ADR-0010 Decision 2, property 1.** The highlighted set must equal
|
||||
/// the set Rust emitted — not a subset, not a superset.
|
||||
///
|
||||
/// This is the load-bearing control, and it replaces the vocabulary
|
||||
/// grep as the thing that actually holds Decision 1. A script that
|
||||
/// *derived* legality by pattern-matching ids would produce a
|
||||
/// plausible highlight and drift from `data-targets` the moment the
|
||||
/// two disagreed; this is stated over observable behaviour rather than
|
||||
/// over source text, which is the same move that fixed control 5.
|
||||
#[test]
|
||||
fn picking_up_marks_exactly_the_targets_rust_emitted() {
|
||||
let html = page();
|
||||
let ctx = ctx_for(&html);
|
||||
|
||||
// What Rust wrote on the card, read back out of the document.
|
||||
let emitted: String = droppables(&html)
|
||||
.into_iter()
|
||||
.find(|(k, _)| k == "action-attack")
|
||||
.and_then(|(_, t)| t)
|
||||
.expect("the action card carries its legal targets");
|
||||
let mut want: Vec<&str> = emitted.split(' ').collect();
|
||||
want.sort_unstable();
|
||||
|
||||
ctx.eval("__down('action-attack');").expect("pointerdown");
|
||||
let marked: String = ctx.eval_as("__marked()").expect("marked");
|
||||
|
||||
// Compared as SETS, because a seat is drawn twice — card and graph
|
||||
// node — and CB-WP-0016 made both droppable. Marking both is
|
||||
// correct and the first draft of this assertion was wrong about
|
||||
// it: it read `seat-1,seat-1` and expected `seat-1`.
|
||||
let mut got: Vec<&str> = marked.split(',').filter(|s| !s.is_empty()).collect();
|
||||
got.sort_unstable();
|
||||
got.dedup();
|
||||
assert_eq!(got, want, "emitted {emitted:?}, marked {marked:?}");
|
||||
|
||||
// And the duplication is asserted rather than tolerated: a seat
|
||||
// that highlighted on only one of its two drawings would send the
|
||||
// player back to hunting for the one that works.
|
||||
assert_eq!(
|
||||
marked.matches("seat-1").count(),
|
||||
2,
|
||||
"both drawings of a seat must highlight, got {marked:?}"
|
||||
);
|
||||
// And it is not simply marking everything.
|
||||
let all: i32 = ctx.eval_as("__all.length").expect("count");
|
||||
assert!(
|
||||
(want.len() as i32) < all,
|
||||
"the page marked every droppable, which proves nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The grabbed element is visibly held, and a ghost follows the
|
||||
/// pointer — otherwise a drag is invisible and the maintainer cannot
|
||||
/// tell it is happening (CB-WP-0017).
|
||||
#[test]
|
||||
fn the_grabbed_element_is_held_and_a_ghost_follows_the_pointer() {
|
||||
let ctx = ctx_for(&page());
|
||||
ctx.eval("__down('action-attack');").expect("pointerdown");
|
||||
assert_eq!(
|
||||
ctx.eval_as::<String>("__heldKeys()").expect("held"),
|
||||
"action-attack"
|
||||
);
|
||||
assert_eq!(ctx.eval_as::<i32>("__ghosts()").expect("ghosts"), 1);
|
||||
|
||||
ctx.eval("__move();").expect("pointermove");
|
||||
let left: String = ctx
|
||||
.eval_as("__body.children[0].style.left")
|
||||
.expect("ghost position");
|
||||
assert_eq!(left, "9px", "the ghost did not follow the pointer");
|
||||
}
|
||||
|
||||
/// A drag that ends leaves no residue. Without this the next drag
|
||||
/// highlights a stale set, which is worse than no highlight at all.
|
||||
#[test]
|
||||
fn ending_a_drag_clears_every_mark_however_it_ends() {
|
||||
for ending in ["__up('seat-1');", "__upNowhere();", "__cancel();"] {
|
||||
let ctx = ctx_for(&page());
|
||||
ctx.eval("__down('action-attack');").expect("pointerdown");
|
||||
assert_ne!(ctx.eval_as::<String>("__marked()").expect("m"), "");
|
||||
|
||||
ctx.eval(ending).expect("ending");
|
||||
assert_eq!(
|
||||
ctx.eval_as::<String>("__marked()").expect("m"),
|
||||
"",
|
||||
"marks survived {ending}"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.eval_as::<String>("__heldKeys()").expect("h"),
|
||||
"",
|
||||
"held survived {ending}"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.eval_as::<i32>("__ghosts()").expect("g"),
|
||||
0,
|
||||
"the ghost survived {ending}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_script_blocks_are_extracted_in_order() {
|
||||
let found = scripts(&page());
|
||||
|
|
|
|||
|
|
@ -467,6 +467,69 @@ mod affordances {
|
|||
assert!(n >= 50, "checked only {n} decision point(s)");
|
||||
}
|
||||
|
||||
/// **ADR-0010 Decision 2, property 2.** A target the page marks legal
|
||||
/// must resolve.
|
||||
///
|
||||
/// If the page can advertise a drop that Rust then refuses, the two
|
||||
/// have drifted and the highlighting is *worse* than none — it teaches
|
||||
/// the player something false. This walks real games and checks the
|
||||
/// emitted `data-targets` against `resolve` itself, so the page's
|
||||
/// promise and the referee's answer cannot disagree.
|
||||
#[test]
|
||||
fn everything_the_page_advertises_actually_resolves() {
|
||||
let checked = Rc::new(RefCell::new(0usize));
|
||||
for seed in 0..4u64 {
|
||||
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
||||
.map(|i| {
|
||||
Box::new(AdvertisedPolicy {
|
||||
inner: RandomPolicy::new(seed * 7 + i),
|
||||
checked: checked.clone(),
|
||||
}) as Box<dyn Policy>
|
||||
})
|
||||
.collect();
|
||||
play(fresh(seed), &mut policies).expect("a bot game completes");
|
||||
}
|
||||
let n = *checked.borrow();
|
||||
assert!(n >= 50, "checked only {n} advertised target(s)");
|
||||
}
|
||||
|
||||
struct AdvertisedPolicy {
|
||||
inner: RandomPolicy,
|
||||
checked: Rc<RefCell<usize>>,
|
||||
}
|
||||
|
||||
impl Policy for AdvertisedPolicy {
|
||||
fn name(&self) -> &'static str {
|
||||
"advertised-target-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);
|
||||
|
||||
for (grab, targets) in crate::jsrun::droppables(&html) {
|
||||
let Some(spec) = targets else { continue };
|
||||
for drop in spec.split(' ').filter(|s| !s.is_empty()) {
|
||||
let fact = crate::PointerFact::new(&grab, drop);
|
||||
assert!(
|
||||
crate::resolve(&fact, legal, seat).is_ok(),
|
||||
"the page advertises {grab} -> {drop} but resolve refuses it \
|
||||
(seat {seat:?}, step {:?})",
|
||||
state.step
|
||||
);
|
||||
*self.checked.borrow_mut() += 1;
|
||||
}
|
||||
}
|
||||
self.inner.choose(state, seat, legal, may_pass)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The regression test for the defect actually reported**, and the
|
||||
/// reason the check above is not sufficient on its own.
|
||||
///
|
||||
|
|
|
|||
136
decisions/ADR-0010-what-the-script-may-do.md
Normal file
136
decisions/ADR-0010-what-the-script-may-do.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# ADR-0010: the script may render state it was given; it may not derive any
|
||||
|
||||
status: accepted
|
||||
date: 2026-08-02
|
||||
decided by: agent, under the standing loop authorization
|
||||
tier: M (structural S — presentation work inside an existing capability;
|
||||
chaos d4=4 → OVERRIDE, drawn M. InnerLoop v1.6). Tier M merges survey and
|
||||
decision into one document, which this is.
|
||||
references: [CB-WP-0017](../workplans/CB-WP-0017-legible-interaction.md),
|
||||
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5 (control 5),
|
||||
[CB-EV-0014](../evidence/CB-EV-0014-the-drop-target.md) §4
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0007 Decision 5 constrains the page:
|
||||
|
||||
> 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"*. Every request in
|
||||
CB-WP-0017 needs it to do more than one thing — hold a drag in flight,
|
||||
follow the pointer, and add and remove classes on **other** elements.
|
||||
|
||||
So the constraint has to be restated or the work does not happen. Restating
|
||||
a control in order to permit the thing it forbade is how controls die, and
|
||||
this ADR exists so that happens out loud or not at all.
|
||||
|
||||
## The survey: three ways to make interaction legible
|
||||
|
||||
| option | where legality is decided | script size |
|
||||
|---|---|---|
|
||||
| **A. Rust emits legality as data; the script toggles classes** | Rust | ~2× today |
|
||||
| B. The script derives legality from the rendered page | **the page** | ~3× |
|
||||
| C. Server round-trip on pointerdown to ask what is legal | Rust | ~1.5×, plus a request per grab |
|
||||
|
||||
**B is the one control 5 exists to forbid**, and it is not hypothetical:
|
||||
the page already contains every action id and every target id, so a script
|
||||
*could* infer plausible pairs. It would be wrong in exactly the cases that
|
||||
matter — Investigate is legal on problems 2 and 3 but not 1, and no amount
|
||||
of looking at the DOM reveals why.
|
||||
|
||||
**C is honest and too slow.** A highlight that arrives after a network
|
||||
round trip is not a highlight; it also makes a pointerdown a state-changing
|
||||
request, which the token guard's threat model did not consider.
|
||||
|
||||
**A is the decision.** But the survey's real output is that A and B are
|
||||
*indistinguishable from the outside*: both are "the page highlights some
|
||||
elements". A control that cannot tell them apart is not a control.
|
||||
|
||||
## Decision 1 — the script may render state it was given; it may not derive any
|
||||
|
||||
The replacement for *"it does one thing"*:
|
||||
|
||||
> **Every game fact the page acts on must arrive from Rust as data.** The
|
||||
> script may read it, match it, and turn it into presentation. It may not
|
||||
> compute, infer, filter, or default one.
|
||||
|
||||
Under this rule, class-toggling is permitted: the legal target set is a
|
||||
`data-targets` attribute Rust wrote, and the script does string matching on
|
||||
it. Deriving that set by pattern-matching ids would be forbidden even
|
||||
though the visible result is identical.
|
||||
|
||||
## Decision 2 — the honest argument, and why the old test was weak
|
||||
|
||||
The tempting defence is *"the page only renders what Rust said"*. That is
|
||||
exactly what a page constructing commands would also say, so it earns
|
||||
nothing on its own.
|
||||
|
||||
The existing control is a test that greps `SCRIPT` for game vocabulary.
|
||||
CB-WP-0014 already found that shape too weak once — it was replaced for
|
||||
control 5 by an assertion on **what the script puts on the wire**. The same
|
||||
weakness applies here and is now worse, because the script legitimately
|
||||
handles more.
|
||||
|
||||
So the vocabulary grep stays as a cheap first line, and the load-bearing
|
||||
control becomes a pair of properties a test can check:
|
||||
|
||||
1. **The highlighted set equals the set Rust emitted.** Not a subset, not a
|
||||
superset. A script that derived legality would drift from the attribute
|
||||
the moment the two disagreed, and this catches that with no reference to
|
||||
how the script is written.
|
||||
2. **A target the page marks legal must `resolve`.** If the page can
|
||||
advertise a drop that Rust then refuses, the two have drifted and the
|
||||
highlighting is worse than none — it would teach the player something
|
||||
false.
|
||||
|
||||
Property 1 is the real replacement for the grep. It is stated over
|
||||
observable behaviour rather than over source text, which is the same move
|
||||
that fixed control 5.
|
||||
|
||||
## Decision 3 — what the script may still never do
|
||||
|
||||
Unchanged from ADR-0007 D5, and restated because this ADR widens the rest:
|
||||
|
||||
- it may not name an action, a seat, a rule, or a phase;
|
||||
- it may not construct a command, in any encoding;
|
||||
- what it posts stays **exactly two fields**, `down` and `up`, carrying two
|
||||
element keys and nothing derived from them.
|
||||
|
||||
The body-shape assertion from CB-WP-0014 continues to hold this, and
|
||||
nothing in this ADR touches it.
|
||||
|
||||
## Decision 4 — the size of the script is not the control
|
||||
|
||||
`SCRIPT` roughly doubles. That is not a violation of anything: ADR-0007's
|
||||
concern was *rules leaking into the page*, not line count, and it said so —
|
||||
*"if a rule ever needs to appear in it, the decision is wrong"*. Marginal
|
||||
AM-4a cost is zero either way, so the dependency budget does not decide
|
||||
this.
|
||||
|
||||
What decides it is Decision 1's rule plus the two properties in Decision 2.
|
||||
Recording this explicitly so that a future pass does not cite the growth as
|
||||
either a licence or an objection.
|
||||
|
||||
## Decision 5 — what this ADR does not claim
|
||||
|
||||
**No visual property here is verified.** Whether a shadow reads as *"you
|
||||
can pick this up"*, whether a highlight is noticeable, whether a drag feels
|
||||
followable — none of that is reachable by any test in this repo, and
|
||||
CB-WP-0016 is the standing evidence for what happens when that gap is
|
||||
papered over.
|
||||
|
||||
The testable core is the *correspondence* between what the page marks and
|
||||
what Rust will accept. The perceptual half stays a human check, and stage 1
|
||||
stays open on it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The JS DOM stub must model `classList` and element lookup. A stub too
|
||||
thin to express a failure is how the failure survives (CB-EV-0014 §2),
|
||||
and today's stub cannot express any of this.
|
||||
- The vocabulary grep is now explicitly the *weak* control, kept for cost.
|
||||
If it ever fails while property 1 passes, prefer property 1 and say so.
|
||||
- If a later pass needs the script to make a decision Rust cannot pre-empt
|
||||
— an animation that depends on rules, say — Decision 1 forbids it and
|
||||
this ADR is what to revisit.
|
||||
|
|
@ -113,14 +113,21 @@ would have missed it.
|
|||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| **CB-WP-0015** | product | 136 | **$15.14** | 0.111 |
|
||||
| **CB-WP-0015** | product | ~~136~~ **166** | ~~$15.14~~ **$22.70** | ~~0.111~~ **0.137** |
|
||||
| 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.
|
||||
**CORRECTED 2026-08-02 (CB-EV-0015 §6).** This file originally reported
|
||||
CB-WP-0015 at **$15.14 / 136** and claimed that figure was *"the first
|
||||
this project has quoted at a boundary that had actually settled."* **The
|
||||
claim was false and the number was wrong.** $15.14/136 was read from
|
||||
`make status` during CB-WP-0015 itself and carried forward — it was that
|
||||
pass's own in-flight figure, which is precisely what CB-EV-0012's rule
|
||||
exists to forbid. Measured after CB-WP-0017's declaration closed the
|
||||
window: **$22.70 / 166**, higher by 50%.
|
||||
|
||||
So the defect is not only the boundary CB-EV-0013 §5 identified. It is
|
||||
that a figure gets *read once and quoted later*, and a number read earlier
|
||||
in a session is an in-flight number no matter which pass's name is on it.
|
||||
|
||||
## 6. Open
|
||||
|
||||
|
|
|
|||
221
evidence/CB-EV-0015-legible-interaction.md
Normal file
221
evidence/CB-EV-0015-legible-interaction.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# CB-EV-0015 — legible interaction, and the chaos window's verdict
|
||||
|
||||
CB-WP-0017 T04. Measured 2026-08-02 at `ca92db5`+. Pass kind `product`,
|
||||
tier **M** (structural S, **chaos d4=4 → OVERRIDE, drawn M**).
|
||||
**Declaration 12 of 12 — the chaos calibration window closes here.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The page was lying, and now it is not
|
||||
|
||||
The maintainer could drag after CB-WP-0016 but could not tell what was
|
||||
draggable, what was held, where it could go, or what any of it would do.
|
||||
Underneath that was something worse than a missing affordance: the page
|
||||
was **wrong about which moves exist**.
|
||||
|
||||
Seat P1, round 1, step Select — **9 legal commands**, rendered as 5 cards
|
||||
each claiming all three target kinds. Before and after, from the live
|
||||
server:
|
||||
|
||||
| card | before | after |
|
||||
|---|---|---|
|
||||
| Investigate | *"onto a seat, a problem, or the table"* | **onto problem 2, problem 3** |
|
||||
| Solve | *"onto a seat, a problem, or the table"* | **onto problem 1** |
|
||||
| Attack | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
|
||||
| Support | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
|
||||
| Ground | *"onto a seat, a problem, or the table"* | **onto the table** |
|
||||
|
||||
Investigate is legal on problems 2 and 3 but **not** 1; Solve on 1 but not
|
||||
2 or 3. The old text was a `const` in the emitter, printed whenever *any*
|
||||
legal command used that action. `legal` carried the exact targets and the
|
||||
renderer threw them away.
|
||||
|
||||
So drop-target highlighting is not decoration on a working table. It is
|
||||
the first time the page tells the truth about what is legal.
|
||||
|
||||
## 2. ADR-0010: the constraint that replaced "it does one thing"
|
||||
|
||||
ADR-0007 D5 said the page *"reports raw pointer facts and nothing else"*
|
||||
and `doc.rs` said `SCRIPT` *"does one thing"*. Everything asked for here
|
||||
needs it to do more — hold a drag, follow the pointer, mark other
|
||||
elements. Restating a control in order to permit what it forbade is how
|
||||
controls die, so it was restated out loud:
|
||||
|
||||
> **Every game fact the page acts on must arrive from Rust as data.** The
|
||||
> script may read it, match it, and turn it into presentation. It may not
|
||||
> compute, infer, filter, or default one.
|
||||
|
||||
The survey's real finding: the permitted design (Rust emits legality, the
|
||||
script matches it) and the forbidden one (the script derives legality from
|
||||
the DOM) are **indistinguishable from the outside** — both are "the page
|
||||
highlights some elements". A control that cannot separate them is not a
|
||||
control.
|
||||
|
||||
The old control was a grep of `SCRIPT` for game vocabulary. CB-WP-0014
|
||||
already found that shape too weak once. It is now demoted to a cheap first
|
||||
line, and the load-bearing control is stated over behaviour:
|
||||
|
||||
**Property 1 — the highlighted set equals the set Rust emitted.** The
|
||||
mutation that makes the script derive targets by pattern-matching ids
|
||||
produces a *plausible* highlight — `seat-0, seat-1, seat-2` where only
|
||||
`seat-1` is legal — and the test catches it. That is Decision 1 actually
|
||||
enforced rather than asserted.
|
||||
|
||||
**Property 2 — a target the page marks legal must `resolve`.** A page that
|
||||
advertises a drop Rust then refuses is worse than no highlighting, because
|
||||
it teaches the player something false. Mutation: adding `table` to
|
||||
Investigate's targets → *"the page advertises action-investigate -> table
|
||||
but resolve refuses it"*.
|
||||
|
||||
## 3. What is visible now, and what is merely tested
|
||||
|
||||
| request | done | verified how |
|
||||
|---|---|---|
|
||||
| what can be picked up | `.pick` — shadow, lift on hover, `grab` cursor | **not verified.** Whether it *reads* as pickable is a human check |
|
||||
| what is held | `.held` — dimmed and scaled where it sat | JS test: `__heldKeys()` is exactly the grabbed key |
|
||||
| where it can go | `.dropok` on every legal target, **both drawings of a seat** | JS test: set equality with `data-targets` |
|
||||
| where it is going | a ghost following the pointer | JS test: ghost exists, and its `left` tracks `clientX` |
|
||||
| what moves exist | the card names its real targets | live server, §1 |
|
||||
|
||||
**A seat is marked on both of its drawings** — card and graph node. That
|
||||
is asserted rather than tolerated: highlighting only one would send the
|
||||
player back to hunting for the one that works, which is the CB-WP-0016
|
||||
defect in a new form. The first draft of that assertion was *wrong* about
|
||||
it, expecting `seat-1` and reading `seat-1,seat-1`.
|
||||
|
||||
**Third mutation:** dropping the `pointercancel` handler and the `clear()`
|
||||
on pointerup leaves marks behind — *"marks survived `__up('seat-1')`"*. A
|
||||
stale highlight set is worse than none.
|
||||
|
||||
### What this pass does not claim
|
||||
|
||||
No visual property is verified. Whether the shadow reads as *"pick me
|
||||
up"*, whether the highlight is noticeable, whether the drag feels
|
||||
followable — none of it is reachable from here, and ADR-0010 D5 says so
|
||||
rather than letting the tests imply otherwise.
|
||||
|
||||
## 4. The harness had to get deeper, and cost an hour to a real trap
|
||||
|
||||
The DOM stub could not express any of this: it synthesized
|
||||
`{target:{id}}` from whatever key the test passed. It now models
|
||||
`classList`, `querySelectorAll`, `createElement`, and a body — **and its
|
||||
node set is built from the real emitted page** via `droppables(html)`,
|
||||
not from a list the harness invented.
|
||||
|
||||
**The trap, recorded because it will recur:** QuickJS fixes its stack
|
||||
limit at `Context` creation *relative to the frame it was created in*. A
|
||||
helper that creates a context and returns it leaves every later `eval`
|
||||
running in a shallower frame, the comparison underflows, and QuickJS
|
||||
reports `SyntaxError: stack overflow` — for `__all.length`. The fix is
|
||||
that the caller owns the `Context` and the helper only prepares it.
|
||||
|
||||
That is also why one test had silently skipped registration: each test
|
||||
hand-rolled its setup. `prepare()` is now shared, so a test cannot set up
|
||||
a different page than the harness runs.
|
||||
|
||||
## 5. The chaos calibration window: the verdict it has owed
|
||||
|
||||
`gates.toml` states the retirement condition in its own words:
|
||||
|
||||
> *"the window closes with no overridden tier producing a different outcome
|
||||
> than the argued one — the evaluation this window exists to make
|
||||
> possible"*
|
||||
|
||||
Twelve declarations, **two overrides**, one in each direction. That is the
|
||||
minimum that makes an evaluation possible at all, and it arrived on the
|
||||
last declaration.
|
||||
|
||||
### Override 1 — CB-WP-0011, structural L → S
|
||||
|
||||
The deleted survey would have opened on 2D toolkits. What the pass found
|
||||
instead, at tier S, was that the **existing** text renderer was showing 24
|
||||
of 41 view fields (CB-EV-0009 §1) — a defect a toolkit survey would have
|
||||
walked straight past. Priced on the same subject: **0.099 $/response at S
|
||||
against 0.123 at L** (CB-EV-0010 §5).
|
||||
|
||||
**Different outcome than the argued one: yes.** The argued tier would have
|
||||
bought a survey of alternatives; the rolled tier bought a defect in what
|
||||
already existed.
|
||||
|
||||
### Override 2 — CB-WP-0017, structural S → M
|
||||
|
||||
The question the retirement condition asks: **what did the extra ADR buy
|
||||
that a tier-S provenance paragraph would not have?**
|
||||
|
||||
It bought the restatement of control 5. At tier S this pass would have
|
||||
widened what the JavaScript does — pointermove, class-toggling on other
|
||||
elements, a ghost node — under a one-paragraph commit note, and ADR-0007
|
||||
D5's *"reports raw pointer facts and nothing else"* would have been
|
||||
silently outgrown rather than replaced. The forced document is what
|
||||
produced Decision 1's rule and, more importantly, the finding that the
|
||||
permitted and forbidden designs are indistinguishable from outside — which
|
||||
is what turned the vocabulary grep from *the* control into a cheap first
|
||||
line, and produced the two properties that are now the real controls.
|
||||
|
||||
**Different outcome than the argued one: yes**, and in the direction that
|
||||
is harder to see from inside — the extra process caught a control quietly
|
||||
expiring.
|
||||
|
||||
### Verdict: keep, at a lower rate
|
||||
|
||||
Both overrides changed the outcome, so the retirement condition is not
|
||||
met. But the honest reading of the cost is that d4 fired twice in twelve
|
||||
and both were informative *because they were rare*; a mechanism that
|
||||
overrides a quarter of all declarations stops being a calibration and
|
||||
starts being the tier system.
|
||||
|
||||
**Recommendation: keep the chaos roll, drop the rate from d4 to d8, and
|
||||
open a second window of 12** with a new retirement condition — retire if
|
||||
an override changes nothing twice running. This is a change to how the
|
||||
loop constrains its own operation and is therefore **tier M work that this
|
||||
pass does not do**: it is recorded here as the recommendation and owed to
|
||||
the next declaration, which must make it with its own tier and its own
|
||||
roll.
|
||||
|
||||
## 6. Cost
|
||||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| **CB-WP-0016** | product | 64 | **$14.93** | 0.233 |
|
||||
| CB-WP-0017 | product | *provisional — not quoted* | | |
|
||||
|
||||
Read from `make status` **at the moment of writing**, after CB-WP-0017's
|
||||
declaration closed CB-WP-0016's window.
|
||||
|
||||
### The self-quoting rule failed a second way, and CB-EV-0014 is corrected
|
||||
|
||||
CB-EV-0014 §5 quoted CB-WP-0015 at **$15.14 / 136** and claimed it was
|
||||
*"the first figure this project has quoted at a boundary that had actually
|
||||
settled."* Both parts were wrong. The same tool now reports CB-WP-0015 at
|
||||
**$22.70 / 166** — higher by 50%. CB-EV-0014 has been corrected in place.
|
||||
|
||||
$15.14/136 was read during CB-WP-0015 itself and carried forward into the
|
||||
next pass's write-up. So there are **two** defects, not one:
|
||||
|
||||
1. the boundary is wrong — a window runs to the *next* pass's first commit
|
||||
(CB-EV-0013 §5);
|
||||
2. **a figure read earlier in a session is an in-flight figure regardless
|
||||
of whose name is on it.** Quoting from memory defeats the rule even
|
||||
when the boundary is right.
|
||||
|
||||
Five for five now, always low: every self-reported cost in this project
|
||||
has been an underestimate. The rule owed is therefore not just *"quote two
|
||||
passes back"* but *"re-run the instrument at the moment you quote it"* —
|
||||
and the second half is what this file did.
|
||||
|
||||
**Meta budget: 0% `[ok]`** over the trailing three, all product.
|
||||
|
||||
## 7. Open
|
||||
|
||||
- **INTENT stage 1: the human check, again.** Everything perceptual here
|
||||
is unverified by construction. `cb-play --serve 0`: can you see what is
|
||||
pickable, what you are holding, and where it may go?
|
||||
- **Chaos: the d4→d8 recommendation** and a second window. §5.
|
||||
- **The self-quoting rule still names the wrong boundary.** CB-EV-0013 §5.
|
||||
- **AM-4b's scope defect (408,237 uncounted lines)** and its 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** unratified after deciding two
|
||||
dependency questions; **ADR-0010** now rests on D5, which is also
|
||||
unratified.
|
||||
13
gates.toml
13
gates.toml
|
|
@ -125,9 +125,22 @@ added = "2026-07-30"
|
|||
review_by = "2026-09-30"
|
||||
caught = [
|
||||
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
|
||||
"CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it",
|
||||
"CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)",
|
||||
]
|
||||
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"
|
||||
# VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides).
|
||||
# Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a
|
||||
# defect in the existing renderer that the deleted survey would have walked
|
||||
# past, at 0.099 $/response against 0.123. CB-WP-0017 (S→M) bought the
|
||||
# restatement of control 5 — at tier S the script would have outgrown
|
||||
# ADR-0007 D5 under a one-paragraph commit note.
|
||||
# RECOMMENDED, and owed to the next declaration as tier-M work: drop the rate
|
||||
# d4 → d8 and open a second window of 12. Both overrides were informative
|
||||
# BECAUSE they were rare; a mechanism firing on a quarter of declarations
|
||||
# stops being a calibration and becomes the tier system.
|
||||
# New retirement condition proposed: retire if an override changes nothing
|
||||
# twice running.
|
||||
|
||||
[[gate]]
|
||||
id = "GATE-REVIEW"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
id: CB-WP-0017
|
||||
kind: product
|
||||
title: "Legible interaction: what can be picked up, what is being dragged, where it can go"
|
||||
status: todo
|
||||
status: done
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
|
@ -66,7 +66,7 @@ It is the first time the page will tell the truth about what is legal.
|
|||
|
||||
```task
|
||||
id: CB-WP-0017-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -91,11 +91,23 @@ Decide, explicitly:
|
|||
- **The cost.** Marginal AM-4a is zero either way, so the budget does not
|
||||
decide this. Say what does.
|
||||
|
||||
**Done 2026-08-02.**
|
||||
[ADR-0010](../decisions/ADR-0010-what-the-script-may-do.md) — *the script
|
||||
may render state it was given; it may not derive any.*
|
||||
|
||||
The survey's real output: the permitted design (Rust emits legality, the
|
||||
script matches it) and the forbidden one (the script derives legality from
|
||||
the DOM) are **indistinguishable from the outside** — both are "the page
|
||||
highlights some elements". So the vocabulary grep is demoted to a cheap
|
||||
first line and the load-bearing controls are stated over behaviour: the
|
||||
highlighted set must **equal** the set Rust emitted, and anything the page
|
||||
marks legal must **resolve**. Both are mutation-proven below.
|
||||
|
||||
## Task: tell the truth about which moves are legal
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -114,11 +126,20 @@ wrong.
|
|||
advertise a target `resolve` then refuses, the two have drifted and the
|
||||
highlighting is worse than none.
|
||||
|
||||
**Done 2026-08-02.** From the live server: *"Solve onto problem 1"*,
|
||||
*"Investigate onto problem 2, problem 3"*, *"Attack onto P2, P3"*,
|
||||
*"Ground onto the table"*. The constant sentence is gone.
|
||||
|
||||
`everything_the_page_advertises_actually_resolves` drives four real bot
|
||||
games and checks every emitted target against `resolve` itself. Mutation —
|
||||
adding `table` to Investigate's targets — goes red with *"the page
|
||||
advertises action-investigate -> table but resolve refuses it"*.
|
||||
|
||||
## Task: make picking up, dragging, and dropping visible
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -149,11 +170,39 @@ The JS DOM stub cannot express any of this today. It will have to model
|
|||
and CB-WP-0016 showed that a stub too thin to express a failure is how the
|
||||
failure survives.
|
||||
|
||||
**Done 2026-08-02.** `.pick` (resting shadow, lift on hover), `.held`
|
||||
(dimmed and scaled), `.dropok` (dashed outline on every legal target), and
|
||||
a ghost following the pointer.
|
||||
|
||||
The stub now models `classList`, `querySelectorAll`, `createElement` and a
|
||||
body, **and its node set is built from the real emitted page** via
|
||||
`droppables(html)` rather than from a list the harness invented.
|
||||
|
||||
Three mutations, each red:
|
||||
|
||||
| mutation | result |
|
||||
|---|---|
|
||||
| the script *derives* targets from the DOM instead of reading `data-targets` | red — marks `seat-0, seat-1, seat-2` where only `seat-1` is legal. **A plausible-looking highlight, caught.** This is ADR-0010 D1 enforced |
|
||||
| the page advertises a target `resolve` refuses | red |
|
||||
| `pointercancel` and the `clear()` on pointerup are dropped | red — *"marks survived `__up('seat-1')`"* |
|
||||
|
||||
**A seat highlights on both of its drawings** — card and graph node — and
|
||||
that is asserted rather than tolerated. The first draft of the assertion
|
||||
was wrong about it: it expected `seat-1` and read `seat-1,seat-1`.
|
||||
|
||||
**A real trap, recorded:** QuickJS fixes its stack limit at `Context`
|
||||
creation relative to the frame it was created in, so a helper that creates
|
||||
a context and *returns* it makes every later `eval` report `SyntaxError:
|
||||
stack overflow` — for `__all.length`. The caller must own the `Context`.
|
||||
|
||||
**Nothing perceptual is verified.** Whether a shadow reads as "pick me up"
|
||||
is not reachable from here (ADR-0010 D5).
|
||||
|
||||
## Task: evidence, and close the chaos calibration window
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -184,3 +233,25 @@ 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.
|
||||
|
||||
**Done 2026-08-02.**
|
||||
[CB-EV-0015](../evidence/CB-EV-0015-legible-interaction.md). `make all`
|
||||
exits 0.
|
||||
|
||||
- **The chaos window's verdict: keep, at a lower rate.** Both overrides
|
||||
changed the outcome, so the retirement condition is not met. CB-WP-0011
|
||||
(L→S) bought a defect in the existing renderer that a toolkit survey
|
||||
would have walked past, at 0.099 $/response against 0.123. CB-WP-0017
|
||||
(S→M) bought the restatement of control 5 — at tier S the script would
|
||||
have outgrown ADR-0007 D5 under a one-paragraph commit note.
|
||||
**Recommendation: d4 → d8, second window of 12, retire if an override
|
||||
changes nothing twice running.** That is a change to how the loop
|
||||
constrains its own operation, so it is tier-M work this pass does not
|
||||
do — it is owed to the next declaration.
|
||||
- **CB-EV-0014 is corrected.** It quoted CB-WP-0015 at $15.14/136 and
|
||||
claimed that was the first settled figure this project had quoted. Both
|
||||
wrong: it now reads **$22.70/166**. The number had been read during
|
||||
CB-WP-0015 itself and carried forward, so there are two defects — the
|
||||
boundary, and quoting a figure from memory rather than re-running the
|
||||
instrument. Five for five, always low.
|
||||
- **Stage 1 stays open**, and now on a purely perceptual question.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue