// ui.ts translates between app/game state and the visual language of the DOM and // canvas: user input into game operations, and game state into whatever the view needs. // Also owns the CSS-variable mapping for theme chrome and the theme preference in // localStorage -- both DOM/storage concerns, not theme.ts's pure data. import * as canvas from "./canvas.js"; import * as game from "./game.js"; import * as history from "./history.js"; import * as theme from "./theme.js"; export type PanelElements = { playerList: HTMLElement; status: HTMLElement; endTurn: HTMLButtonElement; concede: HTMLButtonElement; saveTranscript: HTMLButtonElement; log: HTMLElement; }; const buildPlayerRow = ( player: game.Player, colors: theme.PlayerColors, score: number, isActive: boolean, isEliminated: boolean, isConceded: boolean, ): HTMLElement => { const row = document.createElement("div"); row.className = "player-row"; if (isActive) { row.classList.add("active"); } if (isEliminated) { row.classList.add("eliminated"); } if (isConceded) { row.classList.add("conceded"); } const swatch = document.createElement("span"); swatch.className = "player-swatch"; swatch.style.background = colors.stone; const name = document.createElement("span"); name.className = "player-name"; // Conceded takes precedence over eliminated: it's permanent, and the // eliminated state is meaningless once the player's stones -- including // the base elimination depends on -- are gone. name.textContent = isConceded ? `${player.name} (conceded)` : isEliminated ? `${player.name} (out)` : player.name; const scoreEl = document.createElement("span"); scoreEl.className = "player-score"; scoreEl.textContent = String(score); row.append(swatch, name); // A DOM text glyph is fine here -- the "don't use glyphs" note in canvas.ts // is about ctx.fillText ignoring fillStyle, which doesn't apply to CSS // `color`. The flag is panel-only: a conceded player's pieces are removed // from the board, so there's no piece left to draw an emblem on. if (isConceded) { const flag = document.createElement("span"); flag.className = "player-flag"; flag.textContent = "⚑"; row.append(flag); } row.append(scoreEl); return row; }; // Rebuilt from scratch on every render rather than diffed -- four players is // a handful of elements, and rebuilding keeps "render is a pure function of // state" literally true. The End Turn button lives outside this element so // that its one-time listener (see attachEndTurn) never gets dropped. const renderPlayerList = ( listEl: HTMLElement, state: game.BoardState, eliminated: readonly boolean[], activeTheme: theme.Theme, ): void => { listEl.replaceChildren( ...state.players.map((player, index) => buildPlayerRow( player, theme.playerColors(activeTheme, index), state.scores[index], index === state.activePlayer, eliminated[index], state.conceded[index], )), ); }; const statusText = ( state: game.BoardState, result: game.GameResult, viewingTurn: number | null, botStatus: string | null, ): string => { if (viewingTurn !== null) { return `Viewing turn ${viewingTurn} — read-only`; } // Checked before the result branches: while a bot's animated turn is // playing out, botStatus is what the panel should read, even though the // underlying result is still "playing" (or briefly stale mid-animation). if (botStatus !== null) { return botStatus; } if (result.kind === "won") { return `${state.players[result.player].name} wins!`; } if (result.kind === "draw") { return "Draw"; } if (game.isOpeningTurn(state) && game.remaining(state) > 0) { return `Opening: place all ${state.placementsPerTurn} beside your home base.`; } const budget = game.placementBudget(state); const placed = budget - game.remaining(state); const bonus = game.bonusPlacements(state); return bonus > 0 ? `${placed} of ${budget} placed (+${bonus} from cuts)` : `${placed} of ${budget} placed`; }; const buildLogDot = (color: string): HTMLElement => { const dot = document.createElement("span"); dot.className = "log-dot"; dot.style.background = color; return dot; }; const buildLogChip = (count: number, color: string): HTMLElement => { const chip = document.createElement("span"); chip.className = "log-chip"; chip.append(buildLogDot(color), document.createTextNode(String(count))); return chip; }; const buildLogRow = ( frame: history.Frame, viewing: number | null, activeTheme: theme.Theme, ): HTMLElement => { const row = document.createElement("button"); row.type = "button"; row.className = "log-row"; row.dataset.index = String(frame.turn); if (viewing === frame.turn || (viewing === null && frame.pending)) { row.classList.add("selected"); } if (frame.pending) { row.classList.add("pending"); } const label = document.createElement("span"); label.className = "log-label"; label.textContent = `Turn ${frame.turn}`; label.style.color = theme.playerColors(activeTheme, frame.player).stone; const placedChip = buildLogChip(frame.placed, theme.playerColors(activeTheme, frame.player).stone); const capturedChips = frame.captured.map((entry) => buildLogChip(entry.count, theme.playerColors(activeTheme, entry.player).stone)); row.append(label, placedChip, ...capturedChips); return row; }; // Captures scrollTop before rebuilding (replaceChildren, same "render is a // pure function of state" reasoning as the player list) and restores it // afterward -- unless a turn just ended while live, in which case it // follows the newest row instead of yanking the reader back down mid-scrub. const renderLog = ( listEl: HTMLElement, frameList: readonly history.Frame[], viewing: number | null, activeTheme: theme.Theme, ): void => { const priorScrollTop = listEl.scrollTop; const priorCount = listEl.childElementCount; listEl.replaceChildren(...frameList.map((frame) => buildLogRow(frame, viewing, activeTheme))); if (frameList.length > priorCount && viewing === null) { listEl.scrollTop = listEl.scrollHeight; } else { listEl.scrollTop = priorScrollTop; } }; export const attachLog = (listEl: HTMLElement, onView: (index: number) => void): void => { listEl.addEventListener("click", (event) => { const row = (event.target as Element).closest(".log-row"); if (row instanceof HTMLElement && row.dataset.index !== undefined) { onView(Number(row.dataset.index)); } }); }; // game.gameStatus runs the underlying flood fill once and hands back both // the per-player elimination flags and the derived result, rather than each // of the three things below independently re-deriving one or the other. // // botStatus/isLocalTurn default to their human-solo-game values (null, // true) so every existing call site keeps working unchanged. disabled is a // display detail, not the real guard against a human playing a bot's turn // -- that guard lives in handleClick and main.ts's endHumanTurn, per // separation-of-concerns.md boundary rule 3 (the window-level Enter/Space // listener in attachEndTurn bypasses `disabled` entirely). export const showPanel = ( elements: PanelElements, record: history.History, activeTheme: theme.Theme, botStatus: string | null = null, isLocalTurn: boolean = true, ): void => { const state = history.visible(record); const { eliminated, result } = game.gameStatus(state); renderPlayerList(elements.playerList, state, eliminated, activeTheme); elements.status.textContent = statusText(state, result, record.viewing, botStatus); const disabled = result.kind !== "playing" || history.isViewingPast(record) || !isLocalTurn; elements.endTurn.disabled = disabled; elements.concede.disabled = disabled; renderLog(elements.log, history.frames(record), record.viewing, activeTheme); }; // eliminatedPlayers runs one flood fill per player; computed once here and // reused across every piece, rather than once per piece (which would run a // full elimination check -- itself N flood fills -- for every stone on the // board). export const toCanvasPieces = (state: game.BoardState, activeTheme: theme.Theme): canvas.Piece[] => { const eliminated = game.eliminatedPlayers(state); const doomed = new Set(game.pendingCaptures(state)); return state.pieces.map((piece) => { const colors = theme.playerColors(activeTheme, piece.player); return { row: piece.position.row, col: piece.position.col, color: piece.isHomeBase ? colors.home : colors.stone, provisional: game.isRetractable(state, piece), doomed: doomed.has(piece), emblem: piece.isHomeBase ? (eliminated[piece.player] ? "skull" : "crown") : undefined, }; }); }; export const handleClick = ( state: game.BoardState, layout: canvas.BoardLayout, board: canvas.BoardConfig, point: canvas.Point, isLocalTurn: boolean, ): game.BoardState | null => { // Checked first, before any board/game logic: a human can never play a // bot's turn by clicking during it. isLocalTurn is the real guard here; // showPanel's `disabled` on the End Turn/Concede buttons is a display // detail that doesn't cover clicks on the board itself. if (!isLocalTurn) { return null; } const position = canvas.pixelToGridPoint(layout, board, point); if (!position) { return null; } // Try retract first, fall back to place. This can no longer branch on // "is there a piece here" the way it used to: a doomed cell already has a // piece (the victim) but the click should place an overlay there, not // attempt to retract a piece the active player never placed. `game.pieceAt` // always resolves to that victim, never a same-cell overlay (see game.ts), // so it can't tell these cases apart either -- retract's own legality // check is the only thing that can. // // Not a `??` trick: this is ui.ts asking game.ts's rules layer both // questions -- "is this retractable" and, only if not, "is this // placeable" -- and taking whichever one answers, rather than encoding a // rule ("occupied means retract") in the translation layer itself. return game.retract(state, position) ?? game.place(state, position); }; const clientPointToCanvasPoint = (canvasEl: HTMLCanvasElement, event: MouseEvent): canvas.Point => { const rect = canvasEl.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; export const attachInteraction = ( canvasEl: HTMLCanvasElement, board: canvas.BoardConfig, getLayout: () => canvas.BoardLayout, getState: () => game.BoardState, isLocalTurn: () => boolean, onState: (next: game.BoardState) => void, ): void => { canvasEl.addEventListener("click", (event) => { const point = clientPointToCanvasPoint(canvasEl, event); const next = handleClick(getState(), getLayout(), board, point, isLocalTurn()); if (next) { onState(next); } }); }; const isEndTurnKey = (key: string): boolean => key === "Enter" || key === " "; // Any focused form control -- not just the End Turn button itself -- should // keep Enter/Space for its own native behaviour (opening a ) rather than have this window-level listener steal the // keystroke and end the turn instead. Subsumes the original button-only // check. const isFocusedFormControl = (element: Element | null): boolean => element instanceof HTMLSelectElement || element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLButtonElement; // A 's showModal() makes the rest of the page inert -- no pointer // events, no focus, no tab stops out there -- but that inertness does NOT // extend to window-level listeners: a keydown dispatched on any element // inside the dialog (even non-focusable body text, which throws focus onto // the itself) still bubbles through document to window like any // other keydown. isFocusedFormControl alone can't be trusted to catch this // -- it only holds while focus happens to sit on a button/input/etc, and a // click on plain text inside an open dialog breaks that. This checks the // actual thing that matters instead. const isModalOpen = (): boolean => document.querySelector("dialog[open]") !== null; // Takes an onEndTurn callback rather than calling game.endTurn itself: // main.ts needs to see the board BEFORE endTurn runs, to build that turn's // transcript.TurnRecord (endTurn deletes the captured pieces and advances // the turn, destroying the evidence a per-rule capture breakdown needs -- // see transcript.ts). main.ts owns the actual game.endTurn call and the // applyState/log-push that follow it. export const attachEndTurn = ( button: HTMLButtonElement, onEndTurn: () => void, ): void => { button.addEventListener("click", onEndTurn); window.addEventListener("keydown", (event) => { // button.disabled suppresses the button's own click event but has no // effect on this window-level listener, and a disabled button can never // become document.activeElement -- so without this check, Enter/Space // would keep ending turns after a win even with the button greyed out. // isModalOpen() is the load-bearing guard against any open dialog (see // its comment above) -- isFocusedFormControl is kept alongside it for // ordinary form controls (a ) outside any dialog. if ( button.disabled || !isEndTurnKey(event.key) || isFocusedFormControl(document.activeElement) || isModalOpen() ) { return; } event.preventDefault(); onEndTurn(); }); }; // Enabled at all times, unlike End Turn/Concede -- a long or stuck game // (see docs/bots.md: captures free up cells, so boardIsFull can take a // long while) should never lose its transcript to a closed tab just // because the game hasn't formally ended yet. export const attachSaveTranscript = (button: HTMLButtonElement, onSave: () => void): void => { button.addEventListener("click", onSave); }; // Native + showModal gives a focus trap and Escape-to-cancel for // free, and makes the background inert to pointer events and focus -- but // NOT to window-level keydown listeners (see isModalOpen above, which is // what actually keeps attachEndTurn's Enter/Space handler from firing behind // this overlay). Confirm is never dismissed by backdrop click -- a // destructive action requires an explicit Cancel or Escape -- so no backdrop // listener is added. export const attachConcede = ( button: HTMLButtonElement, dialog: HTMLDialogElement, confirmButton: HTMLButtonElement, cancelButton: HTMLButtonElement, getState: () => game.BoardState, onState: (next: game.BoardState) => void, ): void => { const nameEl = dialog.querySelector("[data-concede-name]"); button.addEventListener("click", () => { if (nameEl) { nameEl.textContent = game.currentPlayer(getState()).name; } dialog.showModal(); // Cancel, not Confirm, gets focus: a stray Enter should cancel, not // concede. cancelButton.focus(); }); confirmButton.addEventListener("click", () => { dialog.close(); const next = game.concede(getState()); if (next) { onState(next); } }); cancelButton.addEventListener("click", () => { dialog.close(); }); }; // Shows the saved-transcript confirmation, filling in the path the server // reported. A separate imperative function rather than folded into // attachSaveTranscript's click listener, since the trigger here is an // async fetch's success -- main.ts's saveTranscript -- not a click. export const showTranscriptSaved = (dialog: HTMLDialogElement, path: string): void => { const pathEl = dialog.querySelector("[data-transcript-path]"); if (pathEl) { pathEl.textContent = path; } dialog.showModal(); }; // Unlike attachConcede, nothing here is destructive, so a backdrop click // dismisses it too -- a click whose target is the dialog element itself, // not any of its children, since native has no separate backdrop // click event of its own. export const attachTranscriptSavedDialog = ( dialog: HTMLDialogElement, okButton: HTMLButtonElement, ): void => { okButton.addEventListener("click", () => dialog.close()); dialog.addEventListener("click", (event) => { if (event.target === dialog) { dialog.close(); } }); }; // --- Theme: CSS variables, chrome application, picker, persistence ------- // The variable names are a DOM concern, so this mapping lives here rather // than in theme.ts, which stays pure data. export const cssVariables = (activeTheme: theme.Theme): Readonly> => ({ "--bb-page-bg": activeTheme.chrome.pageBg, "--bb-panel-bg": activeTheme.chrome.panelBg, "--bb-text": activeTheme.chrome.text, "--bb-muted-text": activeTheme.chrome.mutedText, "--bb-title-accent": activeTheme.chrome.titleAccent, "--bb-swatch-border": activeTheme.chrome.swatchBorder, "--bb-button-bg": activeTheme.chrome.buttonBg, "--bb-button-text": activeTheme.chrome.buttonText, "--bb-button-border": activeTheme.chrome.buttonBorder, "--bb-danger": activeTheme.chrome.danger, "--bb-danger-text": activeTheme.chrome.dangerText, "--bb-success": activeTheme.chrome.success, "--bb-success-text": activeTheme.chrome.successText, // Sourced from the board palette rather than duplicating the hex in // Chrome: this is the same gold the crown and skull emblems use, so one // gold per theme stays the single source of truth. "--bb-flag": activeTheme.board.emblemFill, }); export const applyChrome = (root: HTMLElement, activeTheme: theme.Theme): void => { Object.entries(cssVariables(activeTheme)).forEach(([name, value]) => { root.style.setProperty(name, value); }); }; // The option list is static (theme.themes never changes at runtime), so it's // populated once at attach rather than rebuilt on every theme change. export const attachThemePicker = ( select: HTMLSelectElement, current: theme.Theme, onTheme: (next: theme.Theme) => void, ): void => { select.replaceChildren( ...theme.themes.map((candidate) => { const option = document.createElement("option"); option.value = candidate.id; option.textContent = candidate.name; return option; }), ); select.value = current.id; select.addEventListener("change", () => { const next = theme.byId(select.value); if (next) { onTheme(next); } }); }; const THEME_STORAGE_KEY = "bit-blossom.theme"; // localStorage throws in some private-browsing and embedded contexts, on // read as well as write, so both directions are wrapped. export const storedThemeId = (): string | null => { try { return localStorage.getItem(THEME_STORAGE_KEY); } catch { return null; } }; export const storeThemeId = (id: string): void => { try { localStorage.setItem(THEME_STORAGE_KEY, id); } catch { // Ignored: persistence is a convenience, not a requirement. } };