//! The emitted document: HTML shell, inline SVG table, inline JavaScript.
//!
//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string
//! formatting, and the browser draws it.
//!
//! ## What the JavaScript is allowed to be
//!
//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is
//! therefore the whole of it, it is a constant, and it does one thing:
//! record which element the pointer went down on, which it came up on, and
//! POST the pair. It contains no game vocabulary — no action names, no
//! seats, no rules. If a rule ever needs to appear in it, the decision is
//! wrong and ADR-0007 says to revisit it rather than widen the control.
use std::fmt::Write as _;
use cb_kernel::PlayerId;
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
use crate::input::action_id;
/// Escape for text and attribute contexts alike.
///
/// Everything interpolated into the document goes through this. Card
/// suits and seat numbers cannot currently carry a `<`, but "cannot
/// currently" is how injection bugs are written.
pub fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn seat_name(p: PlayerId) -> String {
format!("P{}", p.0 + 1)
}
fn cards(list: &[games_ground::SolutionCard]) -> String {
if list.is_empty() {
return "none".to_string();
}
list.iter()
.map(|c| format!("{:?}", c.suit))
.collect::>()
.join(" ")
}
/// The only JavaScript in the project. See the module docs.
pub const SCRIPT: &str = r#"
(function () {
var down = null, held = null, marked = [], ghost = null, ghostLabel = '';
// What is actually under the pointer, not what the browser decided the
// event belongs to. CB-WP-0020 T03: for touch and pen a browser
// implicitly captures the pointer to the pointerdown target, so
// `e.target` on pointerup can be the element you STARTED on wherever
// you release. That makes a drop look like a drop-on-itself, and the
// "nothing droppable" message unreachable. elementFromPoint is correct
// under both behaviours.
function under(e) {
if (document.elementFromPoint && e.clientX !== undefined) {
return document.elementFromPoint(e.clientX, e.clientY) || e.target;
}
return e.target;
}
function node(e) {
var n = under(e);
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n;
}
function key(n) { return n ? n.getAttribute('data-drop') : null; }
function status(t) { document.getElementById('cb-status').textContent = t; }
// The session has ended server-side, so nothing on this page can work
// any more -- and nothing on it may look like it can (CB-WP-0024 T01).
//
// The old page kept a full table and a live `play again` control after
// the server had stopped listening, because the script acted only on
// 'ok'. A control that cannot work must stop being a control, so the
// `data-drop` attribute is REMOVED rather than styled: that is the
// attribute the script's own walk reads, so the element becomes
// undroppable by the same rule that made it droppable.
//
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
// lifecycle word, exactly like the 'ok' branch below it.
function seal() {
var all = document.querySelectorAll('[data-drop]');
for (var i = 0; i < all.length; i++) {
all[i].classList.add('sealed');
// removeAttribute, NOT setAttribute(_, null): the latter writes the
// literal string "null" in a real browser, which is truthy, so the
// control would stay droppable while the test stub said otherwise.
all[i].removeAttribute('data-drop');
}
}
// 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.
// ADR-0010 D1 again: `data-descs` is written by Rust, in step with
// `data-targets`. The script pairs them by index and renders one. It
// does not compose a description from an id.
function describeOf(held, key) {
if (!held) { return null; }
var t = held.getAttribute('data-targets');
var d = held.getAttribute('data-descs');
if (!t || !d) { return null; }
var i = t.split(' ').indexOf(key);
var all = d.split('|');
return i >= 0 && i < all.length ? all[i] : null;
}
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;
ghostLabel = '';
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';
// Keep the label as markup, so the explanation can be appended
// rather than replacing it (CB-WP-0020 T02). The first version set
// textContent, which collapsed the card's line break and then got
// overwritten by the explanation -- losing the only sign of what
// was being carried, exactly when it was needed.
ghostLabel = (n.getAttribute('data-drop') || '').replace('action-', '');
ghost.innerHTML = '' + ghostLabel + '';
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';
// The explanation, beside the pointer and therefore beside the
// target it is over (CB-WP-0018 T03).
var over = describeOf(held, key(node(e)));
ghost.innerHTML = '' + ghostLabel + ''
+ (over ? '' + over + '' : '');
ghost.className = over ? 'over' : '';
});
document.addEventListener('pointercancel', clear);
document.addEventListener('pointerup', function (e) {
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(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) {
status(t);
if (t.indexOf('ok') === 0) { window.location.reload(); }
else if (t.indexOf('closed') === 0) { seal(); }
});
});
})();
"#;
const STYLE: &str = "
body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde}
h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start}
.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26}
.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)}
/* 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).
CB-WP-0020 T01, at the maintainer's instruction: change the EXISTING
border, do not draw a new box. `outline` + `outline-offset` drew a
second rectangle outside the element, which an SVG viewport clips (the
reported missing top and left edges) and which made a seat's highlight
the size of a whole card. Restyling the border in place cannot move
anything, because the border is already in the layout. */
.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33}
.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3}
.dropok text{fill:#cfe}
/* The ghost that follows the pointer, so a drag is not invisible. */
#cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe}
/* The thing in your hand. Deliberately NOT card-shaped: it used to be a
textContent copy of the card, so the line break collapsed and it read
as a second card sitting next to the first (CB-WP-0020 T02). */
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.25rem .55rem;
border-radius:999px;background:#2b4a3a;border:1px solid #7ca;
color:#dfe;font:12px ui-monospace,monospace;
box-shadow:0 6px 16px #000b;transform:translate(-50%,-160%);
white-space:nowrap}
/* The explanation is ADDITIONAL, never a replacement: losing the label is
losing the only sign of what you are carrying. */
#cb-ghost b{color:#cfe}
#cb-ghost .why{color:#9cf;margin-left:.4rem}
/* What you picked up, left visibly behind so there are not two cards. */
.held{cursor:grabbing;opacity:.35;border-style:dashed}
/* CB-WP-0024 T01: a control the server can no longer serve. The script
removes its `data-drop` so it is genuinely inert; this is only how that
reads. `pointer-events:none` is belt and braces, not the mechanism --
styling alone would leave a dead control that still looks alive to
anything reading the DOM. */
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
/* CB-WP-0027 T02. `minmax(0,...)` on both tracks, because a grid child
defaults to min-content width and the SVG table would refuse to shrink,
pushing the meta column off-screen instead of narrowing.
The single-column fallback is deliberate rather than incidental: the
page was responsive by accident before this. */
.cb-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,24rem);
gap:1.2rem;align-items:start}
.cb-game{min-width:0}
.cb-meta{min-width:0;border-left:1px solid #2a3140;padding-left:1.1rem}
#cb-note{display:flex;flex-direction:column;gap:.5rem}
#cb-note textarea{width:100%;box-sizing:border-box;font:inherit;color:#dde;
background:#12141a;border:1px solid #445;border-radius:5px;padding:.5rem}
#cb-note button{align-self:flex-start;font:inherit;cursor:pointer;color:#dde;
background:#332a3a;border:1px solid #a7d;border-radius:5px;padding:.35rem .8rem}
.note{border-left:2px solid #a7d;padding-left:.6rem;margin:.4rem 0;white-space:pre-wrap}
@media (max-width:64rem){
.cb-cols{grid-template-columns:minmax(0,1fr)}
.cb-meta{border-left:none;border-top:1px solid #2a3140;padding-left:0;padding-top:1rem}
}
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
.played{display:block;margin:.3rem 0}
/* CB-WP-0028 T02: the card's own words. The tagline reads as the card's
voice, not as engine chrome. */
.tag{display:block;color:#9cb;font-style:italic;margin:.15rem 0 .3rem}
details summary{cursor:pointer;color:#89a;font-size:.9em;margin-top:.3rem}
details p,details{margin:.2rem 0}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
#cb-log{max-height:16rem;overflow-y:auto;font-size:13px}
#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(", ")
}
/// The edition's title for a Problem, by `hidden_priority` (ADR-0015 D1).
///
/// These columns were in the vendored file from the start and thrown away
/// at parse time — the page showed `Repair 2` for a card that reads
/// *"Missed Deadline"*.
fn problem_title(priority: u32) -> Option {
games_ground::edition::problem_texts("SCN_01")
.ok()?
.into_iter()
.find(|t| u32::from(t.priority) == priority)
.map(|t| t.title)
}
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"),
ProblemView::FaceUp {
suit,
value,
denied,
claimed_by,
protected_this_round,
} => {
let mut sub = String::new();
if *denied {
sub.push_str("denied ");
}
if *protected_this_round {
sub.push_str("protected ");
}
if let Some(c) = claimed_by {
let _ = write!(sub, "claimed by {}", seat_name(*c));
}
(
format!("{suit:?} {value}"),
sub.trim_end().to_string(),
"#26303a",
)
}
};
let _ = write!(
out,
"\
{label}\
{name}\
{sub}",
x = x,
// The card's own name where a priority number used to be. Falls
// back to the number rather than blanking: a missing title must
// not remove the only identifier the player had.
name = esc(&problem_title(priority).unwrap_or_else(|| format!("priority {priority}"))),
tx = x + 10,
label = esc(&label),
sub = esc(&sub),
);
}
/// The relationship graph — the one element every Rust 2D toolkit would
/// have left us to hand-roll, and the reason SVG earns its place here
/// rather than merely fitting the budget (CB-RES-0006 §5).
/// One pile, drawn as a small stack of offset cards with its count on top.
///
/// `depth` is how many card-backs to suggest, not the count — a pile of 17
/// is not seventeen rectangles. The **number** is the truth; the stack is
/// how you tell at a glance that there is a pile there at all.
fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) {
let depth = match count {
0 => 0,
1..=3 => 1,
4..=9 => 2,
_ => 3,
};
// The title carries the count in words. It is what a screen reader
// announces, and it is the stable thing a coverage probe can match --
// the on-canvas number is a bare numeral that could be anything.
let _ = write!(
out,
"{} pile: {count} remaining",
esc(label),
);
if depth == 0 {
// An EMPTY pile is drawn, not omitted. A missing slot reads as
// "this game has no discard", which is a different statement from
// "the discard is empty" (CB-WP-0024 T02).
let _ = write!(
out,
"",
);
}
for d in (0..depth).rev() {
let dx = x + d * 3;
let dy = 14 - d * 3;
let _ = write!(
out,
"",
);
}
let _ = write!(
out,
"{count}\
{label}\
{note}",
tx = x + 38,
count = count,
label = esc(label),
note = esc(note),
);
}
fn piles_body(out: &mut String, view: &GroundView) {
let deck = view.solution_deck_len;
let discard = view.solution_discard.len();
let will_reshuffle = deck == 0 && discard > 0;
pile_svg(
out,
10,
"draw",
deck,
"#26303a",
if will_reshuffle { "empty" } else { "" },
);
pile_svg(
out,
120,
"discard",
discard,
"#2a2f3a",
if will_reshuffle {
"shuffles in on next draw"
} else {
""
},
);
if will_reshuffle {
out.push_str(
"\
",
);
}
}
/// The table, seen from above (CB-WP-0028 T03).
///
/// **One diagram, not three.** The seats were already placed on a circle
/// for the relationship graph, while the Problems were a row above it and
/// the piles a separate picture below — three views of one table, and the
/// player had to assemble them. Now: seats around the edge, the Problems
/// and the two stacks in the middle, each seat's played card in front of
/// it, relations drawn between seats.
///
/// The existing `problem_svg` and `pile_svg` are reused rather than
/// reimplemented — they carry the tokens the coverage gate probes for,
/// and drawing the same thing twice is how the two drift.
fn table_svg(view: &GroundView) -> String {
let n = view.players.len().max(1);
let (cx, cy) = (380.0f64, 300.0f64);
let seat_r = 240.0f64;
let pos: Vec<(PlayerId, f64, f64)> = view
.players
.keys()
.enumerate()
.map(|(i, p)| {
// Start at the bottom, not the top: the viewer sits nearest
// the reader, which is where you sit at a real table.
let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2;
(*p, cx + seat_r * a.cos(), cy + seat_r * a.sin() * 0.72)
})
.collect();
let find = |p: PlayerId| {
pos.iter()
.find(|(q, _, _)| *q == p)
.map(|(_, x, y)| (*x, *y))
};
let mut s = String::from(
"");
s
}
/// A revealed selection, phrased the way the log phrases the command.
///
/// Deliberately the same shape as `event_line`'s `ActionSelected` arm —
/// a second vocabulary for the same fact drifts from the first, which is
/// exactly what `{:?}` was doing here.
fn selection_words(s: &games_ground::Selection) -> String {
// Every field that is set is named. The first draft matched on
// `(target, problem)` and showed only the target when both were
// present — the coverage gate caught it, because a field the document
// never shows is a field a player never sees. The aggregate does not
// currently produce both, but a renderer that silently drops one is
// the omission class this crate exists to guard against.
let mut out = format!("{:?}", s.action);
if let Some(t) = s.target {
let _ = write!(out, " on {}", seat_name(t));
}
if let Some(n) = s.problem {
let _ = write!(out, " for problem {n}");
}
out
}
/// The card a seat has played, as a card.
///
/// **The face-down back is a constant.** It takes no argument, because
/// `SelectionView::Hidden` carries nothing and this function must not be
/// able to leak what it does not receive. GR-R02/R04 hide another seat's
/// choice until Reveal, and `view.rs`'s own test asserts the projection
/// obeys that — but a renderer can leak what the model did not, by
/// tinting the back with the suit or shaping it by the action. So there is
/// exactly one back, with no data path into it.
const CARD_BACK: &str = "";
/// The played card without its own `