user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.

This commit is contained in:
Jesse
2026-08-05 19:18:26 -04:00
parent 1c321d4cea
commit 543584a2ca
20 changed files with 1851 additions and 320 deletions
+274 -188
View File
@@ -6,12 +6,15 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
@@ -25,6 +28,15 @@ const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
* WHAT the picked card is being used for.
*
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
* board for a play, the three Department piles for a discard. Without this the two flows would need
* two selections, which is the thing being removed.
*/
let mode: 'play' | 'discard' | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
@@ -48,6 +60,14 @@ let districtMode: 'auto' | 'open' | 'closed' = 'auto';
* before any audio may start.
*/
let soundOn = false;
/**
* The phase the page last drew, so a change of phase can be announced.
*
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
@@ -59,76 +79,37 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
* "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a
* player working out which way the leg will point once the card is down. Rendered by `officeSvg` —
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
* belong to a position, and this is a card on its own.
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
'',
);
}
// ---------------------------------------------------------------------------
/**
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
*
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
* player could not see what had already happened this Stage or what was coming. The printed game
* has a turn chart for exactly this reason.
*
* Each phase carries a tooltip describing what actually happens in it — the first three are the
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
* sheet does not spell them out.
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
function renderTurnChart(f: ReturnType<typeof view>): void {
$('tc-day').textContent = `Day ${f.day}`;
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
$('tc-clock').textContent = f.clock;
$('tc-phase').textContent = f.phase;
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
$('tc-phases').innerHTML = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
function start(): void {
@@ -152,7 +133,13 @@ function render(): void {
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
// entry that carries its squares is keyed the same way the menu keys it.
const forPlay =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
const chosen = forPlay
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
: undefined;
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
@@ -212,47 +199,52 @@ function render(): void {
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
$('facs').innerHTML = facilitiesHtml(f);
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
/**
* THE HAND, AS THE ACTION SURFACE.
*
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
* again in the action list — once as subjects to play and once as one button per Department to
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
*/
$('hand').innerHTML = menu.hand.length
? menu.hand
.map((h) => {
const canPlace = h.placeKey !== null && h.spots > 0;
const canPlay = canPlace || h.playNow !== null;
const canDiscard = h.discard.some((d) => d !== null);
const picked = selected === `hand:${h.cardId}`;
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
on
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
: '';
// A track card shows what it would look like on the board, in every orientation it has —
// which is how a player sees a turnout has two at all, and what a curve actually is.
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
verb(
'play',
canPlay,
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
canPlace
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
: 'Play it. This card needs no square on the board.',
) +
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
`</div></div>`
);
})
.join('')
: '<span class="dim">empty</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
@@ -263,47 +255,78 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
$('depts').innerHTML = pilesHtml(f);
renderYards(f);
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
// highlighted in place, on the board or on the Department piles.
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
const node = b as HTMLElement;
node.onclick = () => {
const cardId = node.dataset['card']!;
const verb = node.dataset['verb'] as 'play' | 'discard';
const entry = menu.hand.find((h) => h.cardId === cardId);
if (!entry) return;
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
return;
}
const key = `hand:${cardId}`;
const same = selected === key && mode === verb;
selected = same ? null : key;
mode = same ? null : verb;
pendingAt = null;
render();
};
}
// -- discarding: the three Department piles become the targets. They already show their top card
// and their depth, which is exactly what you choose between.
const discarding = mode === 'discard' && selected !== null
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
const index = discarding.discard[slot];
if (index === null || index === undefined) continue;
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
render();
};
}
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
);
if (!car) continue;
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
render();
};
}
}
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
@@ -313,8 +336,25 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
/**
* SAY WHEN THE PHASE TURNS OVER.
*
* The five phases run themselves once Local Operations ends, so the page can change out from under
* a player between one click and the next. The turn chart already shows WHERE you are; this says
* that it moved, which is the part you miss when you were looking at the board.
*/
if (lastPhase !== null && lastPhase !== f.phaseKey) {
const el = $('phasenote');
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent?.startsWith(f.phase)) el.className = '';
}, 2600);
}
lastPhase = f.phaseKey;
renderDistrict(f);
renderActions(menu);
renderActions(menu, f);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
@@ -332,19 +372,8 @@ function render(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: ReturnType<typeof view>): void {
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
)
.join('');
$('divyard').innerHTML = row(f.yards.division);
$('clsyard').innerHTML = row(f.yards.classification);
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
@@ -384,16 +413,7 @@ function renderDistrict(f: ReturnType<typeof view>): void {
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
const el = $('actions');
if (game.state.status !== 'active') {
@@ -419,27 +439,61 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
};
// A long label is two things: the action, and why it is offered. Put the first on the button and
// the second on the tooltip, or the list crowds out the board.
/**
* A long label is two things: the action, and why it is offered. Put the first on the button and
* the second on the tooltip, or the list crowds out the board.
*
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
* label happening to contain an em-dash, so an action naming a card could have none at all while
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
const actionButton = (label: string, index: number): string => {
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
const rest = cut > 0 ? label.slice(cut + 3) : '';
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
/**
* MOVES LEFT, WHERE THE MOVES ARE.
*
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
: '';
// A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = '<h3 class="actions-hd">Actions</h3>';
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
*
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
@@ -455,17 +509,46 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
/**
* MAKING UP A TRAIN.
*
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
* way to finish when the yard holds nothing the train may take.
*
* Moving the cars onto the yard chips without this left a train being made up with no control on
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
* 775569289 with Train 10 waiting at the West Division Point.
*/
if (menu.makeUp) {
const addable = menu.makeUp.cars.length;
html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
`</div>` +
(menu.makeUp.pass !== null
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
: '') +
`</div>`;
}
/**
* The card is picked in the hand and the square on the board, so all that is left here is the
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
*/
const picked =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
for (const group of menu.placeable) {
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
if (relevant.length === 0) continue;
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
for (const item of relevant) {
{
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
@@ -474,7 +557,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
shown
.map((sp) => {
// The picture rides on the button that would lay it, so hovering a rotation shows
// that rotation — which is the question "which of these two do I want?" answered.
const fig = sp.links.length
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
.join('') +
`</div>`;
}
}
@@ -497,15 +592,6 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
@@ -559,7 +645,7 @@ function clearSave(): void {
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(pageStyle);
installTooltips();