diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index cee9049..45cd7da 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -74,6 +74,36 @@ export interface Engine { /** La chance d'un jet pour le joueur au trait. */ luck(position: PositionId, cube: EngineCube, roll: Roll): Promise; + + /** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu + * de lui, sans dés. */ + encodeMatch(cube: EngineCube): Promise; + + /** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */ + decodeMatch(matchId: MatchId): Promise; +} + +/** + * Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur + * du match, videau, joueur au trait. Le domaine ne le décode jamais + * lui-même : il le passe au moteur. + * + * Glossaire : docs/glossaire/identifiant-de-match.md + */ +export type MatchId = string; + +/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1, + * comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */ +export interface MatchDescription { + /** 0 : partie libre. */ + readonly matchLength: number; + readonly score: readonly [number, number]; + readonly cubeValue: number; + /** -1 : videau au centre ; sinon le joueur qui le possède. */ + readonly cubeOwner: -1 | 0 | 1; + readonly playerOnRoll: 0 | 1; + readonly crawford: boolean; + readonly jacoby: boolean; } /** diff --git a/app/src/domain/game/position-link.ts b/app/src/domain/game/position-link.ts new file mode 100644 index 0000000..dba3a44 --- /dev/null +++ b/app/src/domain/game/position-link.ts @@ -0,0 +1,126 @@ +// Une position du plateau dans un lien (ADR 0013) : le Position ID et le +// Match ID de GNU Backgammon, lus et écrits par le moteur. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Un lien décrit la position du point de vue du joueur au trait : c'est lui +// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à +// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux +// identifiants (aucune règle de représentation n'est réimplémentée). + +import type { Cube } from "./cube.js"; +import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js"; +import type { GameSetup, SideBoards } from "./game.js"; +import type { PositionId } from "./position.js"; +import type { Side } from "./side.js"; +import type { GameState } from "./state.js"; + +/** + * Ce que porte l'adresse d'une position : son Position ID, et son Match ID + * s'il est donné (sans lui : partie libre, videau au centre). + * + * Glossaire : docs/glossaire/lien-de-position.md + */ +export interface PositionLink { + readonly position: PositionId; + readonly matchId: MatchId | null; +} + +/** Une position partagée, lue par le moteur. Le camp « player » est celui + * au trait. */ +export interface SharedPosition { + readonly link: PositionLink; + readonly sides: SideBoards; + readonly cube: Cube; + /** null en partie libre ; sinon la longueur du match et le score. */ + readonly match: { + readonly length: number; + readonly score: Readonly>; + } | null; + /** La partie est déjà finie dans cette position (null sinon). */ + readonly result: BoardResult | null; +} + +/** Un lien que le moteur ne sait pas lire. */ +export class UnreadableLinkError extends Error { + constructor(reason: string) { + super(reason); + this.name = "UnreadableLinkError"; + } +} + +/** + * Lit une position partagée : le moteur décode le Match ID (s'il est + * donné), puis la position avec son videau. Erreur UnreadableLinkError si + * l'un des deux est illisible. + */ +export async function readPosition(engine: Engine, link: PositionLink): Promise { + let cube: EngineCube = { value: 1, owner: "centered" }; + let match: SharedPosition["match"] = null; + if (link.matchId !== null) { + const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => { + throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`); + }); + const onRoll = description.playerOnRoll; + cube = { + value: description.cubeValue, + owner: + description.cubeOwner === -1 + ? "centered" + : description.cubeOwner === onRoll + ? "onRoll" + : "opponent", + }; + if (description.matchLength > 0) { + match = { + length: description.matchLength, + score: { + player: description.score[onRoll], + computer: description.score[onRoll === 0 ? 1 : 0], + }, + }; + } + } + const board = await engine.board(link.position, cube).catch((error: unknown) => { + throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`); + }); + return { + link, + sides: { player: board.onRoll, computer: board.opponent }, + cube: { + value: cube.value, + owner: + cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer", + }, + match, + result: board.result, + }; +} + +/** + * Le lien de la position d'une partie : la position vue du camp au trait, + * et le Match ID que le moteur écrit pour son videau (partie libre, sans + * dés). Pendant le jet d'ouverture, la position de départ, vue du joueur. + */ +export async function linkOf(engine: Engine, state: GameState): Promise { + const onRoll: Side = state.turn ?? "player"; + const cube: EngineCube = + state.cube.owner === null + ? { value: state.cube.value, owner: "centered" } + : { + value: state.cube.value, + owner: state.cube.owner === onRoll ? "onRoll" : "opponent", + }; + return { position: state.position, matchId: await engine.encodeMatch(cube) }; +} + +/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la + * place du camp au trait, avec le même videau ; partie libre. */ +export function setupOf(shared: SharedPosition): GameSetup { + return { position: shared.link.position, firstTurn: "player", cube: shared.cube }; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index ef12813..39767f3 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -22,12 +22,19 @@ import type { Level, Luck, LuckRating, + MatchDescription, + MatchId, Move, MoveAnalysis, Ply, Skill, } from "../domain/game/engine.js"; -import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js"; +import { + formatHalfMoves, + type HalfMove, + type PositionId, + START_POSITION, +} from "../domain/game/position.js"; import type { Roll } from "../domain/game/roll.js"; /** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la @@ -208,6 +215,35 @@ export function createWasmEngine(run: RunCommand): Engine { return { value: answer.luck, rating }; }, + encodeMatch: matchId, + + async decodeMatch(id: MatchId): Promise { + // engine-cli n'a pas de commande qui décode seule un Match ID : + // l'évaluation à 0 pli (quelques millisecondes) le décode, le + // valide (SetCubeInfo de gnubg) et le renvoie lisible. + const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as { + match: { + match_length: number; + score: [number, number]; + cube_value: number; + cube_owner: number; + player_on_roll: number; + crawford: boolean; + jacoby: boolean; + }; + }; + const m = answer.match; + return { + matchLength: m.match_length, + score: m.score, + cubeValue: m.cube_value, + cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1, + playerOnRoll: m.player_on_roll === 1 ? 1 : 0, + crawford: m.crawford, + jacoby: m.jacoby, + }; + }, + async checkHalfMoves( position: PositionId, roll: Roll, diff --git a/app/src/main.ts b/app/src/main.ts index 3aef280..64cf9f8 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -6,6 +6,7 @@ // Licence : GPLv3 ou ultérieure (voir LICENSE). import { Game } from "./domain/game/game.js"; +import { linkOf, readPosition } from "./domain/game/position-link.js"; import { exportSgf } from "./domain/game/sgf.js"; import { createWasmEngine } from "./engine/wasm-engine.js"; import { startEngineWorker } from "./engine/worker-run.js"; @@ -35,7 +36,10 @@ const opening = (async () => { // Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY). const session = createGameSession( library, - { start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) }, + { + start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }), + resume: (journal) => Game.resume(engine, journal), + }, "2ply", ); const saved = await session.restore(); @@ -57,7 +61,11 @@ startApp({ stored = opened; }), ]), - newGame: () => storage().session.newGame(), + newGame: (setup) => storage().session.newGame(setup), + positions: { + read: (link) => readPosition(engine, link), + linkOf: (game) => linkOf(engine, game.state), + }, savedGame: () => storage().saved, resumePrevious: () => storage().session.resumePrevious(), saveGame: (journal) => storage().session.save(journal), diff --git a/app/src/storage/game-session.ts b/app/src/storage/game-session.ts index 26b6c4f..196ed9c 100644 --- a/app/src/storage/game-session.ts +++ b/app/src/storage/game-session.ts @@ -11,13 +11,13 @@ import { switchToBranch } from "../domain/game/branches.js"; import type { Level } from "../domain/game/engine.js"; import type { GameJournal } from "../domain/game/events.js"; -import type { Game } from "../domain/game/game.js"; +import type { Game, GameSetup } from "../domain/game/game.js"; import { type GameLibrary, newGameId } from "./game-library.js"; /** Comment créer une partie : commencée, ou reprise d'un journal (erreur si * le journal est illisible). */ export interface GameFactory { - readonly start: () => Game; + readonly start: (setup?: GameSetup) => Game; readonly resume: (journal: GameJournal) => Game; } @@ -27,9 +27,10 @@ export interface GameSession { /** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou * si elle est illisible. */ restore(): Promise; - /** Une nouvelle partie ; celle qu'elle remplace reste reprenable par - * resumePrevious, jusqu'au prochain changement de partie. */ - newGame(): Game; + /** Une nouvelle partie (depuis la position de setup si elle est donnée) ; + * celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au + * prochain changement de partie. */ + newGame(setup?: GameSetup): Game; /** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ; * un échec est ignoré : la partie continue). */ save(journal: GameJournal): void; @@ -79,10 +80,10 @@ export function createGameSession( return null; // partie illisible : on en commencera une nouvelle } }, - newGame() { + newGame(setup) { previousId = shownId; shownId = newGameId(); - return games.start(); + return games.start(setup); }, save(journal) { if (shownId !== null) { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index df692ae..869c329 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -14,11 +14,18 @@ // d'information, consigne, barre d'actions) ; en paysage, le plateau est // l'écran et porte ses commandes (ADR 0012). Toutes les actions passent par // une même table (actions), qu'on les déclenche au toucher ou au clavier. +// +// Navigation (ADR 0013, amendement du 2026-11-03) : chaque écran a son +// adresse, et l'adresse est la source de vérité. On change d'écran par de +// vrais liens (aide, glossaire, menu, mes parties, position) ; applyRoute +// dessine l'écran que désigne l'adresse, au démarrage et à chaque +// changement. Les actions de jeu restent des boutons. import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; -import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js"; +import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; import { @@ -44,8 +51,10 @@ import { GAMES_TEXTS } from "./games-texts.js"; import { createGamesView, type GamesView } from "./games-view.js"; import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js"; import { createHaptics, type Vibrate } from "./haptics.js"; -import { loadGlossaryEntry, showHelp } from "./help.js"; +import { createHelpView, loadGlossaryEntry } from "./help.js"; import { icon } from "./icons.js"; +import { replacingLink, startNavigation } from "./navigation.js"; +import { isHelpScreen, type Route, routeHash } from "./router.js"; import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js"; import { type PromptText, TEXTS } from "./texts.js"; @@ -67,8 +76,11 @@ export interface CachedBoard { export interface AppOptions { /** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */ readonly engineReady: Promise; - /** Une nouvelle partie (le jet d'ouverture est déjà tiré). */ - readonly newGame: () => Game; + /** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis + * une position donnée (setup). */ + readonly newGame: (setup?: GameSetup) => Game; + /** Les liens vers une position (ADR 0013). */ + readonly positions: PositionsAccess; /** La partie enregistrée, ou null s'il n'y en a pas. */ readonly savedGame: () => Game | null; /** « Reprendre l'ancienne » : la partie quittée par la dernière @@ -99,6 +111,14 @@ export interface SavedGamesAccess { readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>; } +/** Ce que la vue d'une position demande au domaine et au moteur. */ +export interface PositionsAccess { + /** La position d'un lien, lue par le moteur (erreur si illisible). */ + readonly read: (link: PositionLink) => Promise; + /** Le lien de la position d'une partie. */ + readonly linkOf: (game: Game) => Promise; +} + function byId(id: string): T { const element = document.getElementById(id); if (element === null) { @@ -162,21 +182,32 @@ export function startApp(options: AppOptions): void { again: byId("again"), moveHint: byId("move-hint"), replay: byId("replay"), - helpButton: byId("help-button"), + copyLink: byId("copy-link"), + helpButton: byId("help-button"), fullscreenButton: byId("fullscreen-button"), - menuButton: byId("menu-button"), - helpBack: byId("help-back"), + menuButton: byId("menu-button"), + helpBack: byId("help-back"), + helpTabs: byId("help-tabs"), game: byId("game"), help: byId("help"), helpContent: byId("help-content"), + position: byId("position-view"), + positionTitle: byId("position-title"), + positionMessage: byId("position-message"), + positionBoard: byId("position-board") as unknown as SVGSVGElement, + positionDetail: byId("position-detail"), + positionBack: byId("position-back"), + positionPlay: byId("position-play"), + positionCopy: byId("position-copy"), menuVeil: byId("menu-veil"), menu: byId("menu"), menuTitle: byId("menu-title"), menuClose: byId("menu-close"), menuNewGame: byId("menu-new-game"), - menuGames: byId("menu-games"), + menuGames: byId("menu-games"), + menuCopyLink: byId("menu-copy-link"), menuFullscreen: byId("menu-fullscreen"), - menuHelp: byId("menu-help"), + menuHelp: byId("menu-help"), menuTheme: byId("menu-theme"), menuVibrations: byId("menu-vibrations"), menuScreenOn: byId("menu-screen-on"), @@ -218,7 +249,8 @@ export function startApp(options: AppOptions): void { // ---------------------------------------------------- Textes fixes ui.board.setAttribute("aria-label", TEXTS.boardLabel); - const label = (button: HTMLButtonElement, text: string, name?: Parameters[0]) => { + ui.positionBoard.setAttribute("aria-label", TEXTS.boardLabel); + const label = (button: HTMLElement, text: string, name?: Parameters[0]) => { button.replaceChildren(); if (name !== undefined) { button.append(icon(name)); @@ -234,6 +266,9 @@ export function startApp(options: AppOptions): void { ui.replay.append(icon("replay")); ui.replay.setAttribute("aria-label", TEXTS.replayTitle); ui.replay.title = TEXTS.replayTitle; + ui.copyLink.append(icon("copy")); + ui.copyLink.setAttribute("aria-label", TEXTS.copyPositionLink); + ui.copyLink.title = TEXTS.copyPositionLink; ui.roll.textContent = TEXTS.roll; ui.double.textContent = TEXTS.double; ui.take.textContent = TEXTS.take; @@ -246,11 +281,20 @@ export function startApp(options: AppOptions): void { ui.doubleConfirm.textContent = TEXTS.double; ui.doubleCancel.textContent = TEXTS.cancel; ui.helpBack.textContent = TEXTS.back; + ui.helpTabs.setAttribute("aria-label", TEXTS.helpTabs); + for (const tab of ui.helpTabs.querySelectorAll("a[data-screen]")) { + const screen = tab.dataset.screen as "help" | "glossary" | "about"; + tab.textContent = TEXTS.helpTabNames[screen]; + } + ui.positionBack.textContent = TEXTS.backToGame; + ui.positionPlay.textContent = TEXTS.playFromHere; + label(ui.positionCopy, TEXTS.copyPositionLink, "copy"); ui.menuTitle.textContent = TEXTS.menu; ui.menuClose.append(icon("close")); ui.menuClose.setAttribute("aria-label", TEXTS.close); label(ui.menuNewGame, TEXTS.newGame, "newGame"); label(ui.menuGames, GAMES_TEXTS.button, "games"); + label(ui.menuCopyLink, TEXTS.copyPositionLink, "copy"); label(ui.menuHelp, TEXTS.helpAndGlossary, "help"); ui.menuTheme.prepend(icon("theme")); child(ui.menuTheme, ".item-label").textContent = TEXTS.theme; @@ -260,7 +304,12 @@ export function startApp(options: AppOptions): void { child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn; ui.bannerText.textContent = TEXTS.newGameStarted; ui.resumeOld.textContent = TEXTS.resumeOld; - child(ui.bubble, ".link-button").textContent = TEXTS.seeGlossary; + const glossaryLink = child(ui.bubble, ".link-button"); + glossaryLink.textContent = TEXTS.seeGlossary; + glossaryLink.href = routeHash({ + screen: "glossary", + term: TEXTS.pipCountFile.replace(/\.md$/, ""), + }); // ------------------------------------------------ État de l'écran @@ -294,6 +343,15 @@ export function startApp(options: AppOptions): void { let immersive = false; let rotated = false; let bubbleSide: Side | null = null; + // Le lien de la position de la partie, recalculé à chaque mise à jour : + // « Copier le lien » écrit alors tout de suite dans le presse-papiers. + let currentLink: PositionLink | null = null; + // Un message lié à l'adresse (adresse inconnue, lien copié…), montré + // dans la ligne d'information jusqu'à la prochaine action. + let notice: string | null = null; + // La position partagée affichée (#/position/...), une fois lue. + let shownPosition: SharedPosition | null = null; + let positionRequest = 0; const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)"); const portraitQuery = window.matchMedia("(orientation: portrait)"); @@ -349,6 +407,7 @@ export function startApp(options: AppOptions): void { if (game.pendingHalfMoves.length === 0) { options.saveBoard({ sides, cube: game.state.cube }); } + currentLink = await options.positions.linkOf(game).catch(() => null); } /** Recalcule ce qui demande le moteur, puis redessine. */ @@ -403,7 +462,9 @@ export function startApp(options: AppOptions): void { // Ligne d'information : un jet sans coup possible reste annoncé // jusqu'à l'action suivante, même après un rechargement de la page. - if (stuck && state.roll !== null) { + if (notice !== null) { + ui.info.textContent = notice; + } else if (stuck && state.roll !== null) { ui.info.textContent = TEXTS.youCannotMove(state.roll); } else if (computerCouldNotMove(game)) { ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game)); @@ -434,6 +495,8 @@ export function startApp(options: AppOptions): void { show(ui.drop, doubled); show(ui.again, over && panelDismissed); show(ui.replay, shownMove !== null); + // Le coup de l'ordinateur montré : sa position se copie en lien. + show(ui.copyLink, shownMove !== null && currentLink !== null); for (const button of [ ui.roll, ui.double, @@ -444,10 +507,11 @@ export function startApp(options: AppOptions): void { ui.replay, ui.again, ui.menuNewGame, + ui.positionPlay, ]) { button.disabled = busy; } - ui.menuGames.disabled = false; + ui.menuCopyLink.disabled = currentLink === null; ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; renderPanel(game, landscape, doubled); renderMenu(); @@ -487,12 +551,17 @@ export function startApp(options: AppOptions): void { ui.skip, ui.again, ui.replay, + ui.copyLink, ]) { button.hidden = true; } ui.moveHint.hidden = true; ui.menuNewGame.disabled = true; - ui.menuGames.disabled = true; + ui.menuCopyLink.disabled = true; + ui.positionPlay.disabled = true; + if (notice !== null) { + ui.info.textContent = notice; + } renderMenu(); } @@ -577,7 +646,9 @@ export function startApp(options: AppOptions): void { /** Une seule vibration par fin de partie. */ function announceEnd(g: Game): void { const head = g.journal.head; - if (g.state.phase !== "over" || head === announcedEnd) { + // Pendant l'animation du dernier coup, une prise vibrerait après + // la fin et la remplacerait : on l'annonce au dessin qui suit. + if (g.state.phase !== "over" || head === announcedEnd || animating) { return; } announcedEnd = head; @@ -727,6 +798,7 @@ export function startApp(options: AppOptions): void { targets = new Map(); shownMove = null; bubbleSide = null; + notice = null; ui.bubble.hidden = true; render(); try { @@ -740,18 +812,24 @@ export function startApp(options: AppOptions): void { } } - function startNewGame(): void { - game = options.newGame(); + function startNewGame(setup?: GameSetup): void { + game = options.newGame(setup); panelDismissed = false; + announcedEnd = null; saveSettings({ gamesStarted: settings.gamesStarted + 1 }); const opening = game.events.findLast((e) => e.type === "DiceRolled"); info = - opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : ""; + setup !== undefined + ? TEXTS.startedFromPosition + : opening?.type === "DiceRolled" + ? TEXTS.opening(opening.roll[0], opening.roll[1]) + : ""; } - /** « Nouvelle partie » : tout de suite, sans confirmation ; une partie en - * cours reste reprenable pendant 10 s (interaction.md). */ - function newGame(): void { + /** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une + * position) : tout de suite, sans confirmation ; une partie en cours + * reste reprenable pendant 10 s (interaction.md). */ + function newGame(setup?: GameSetup): void { closeOverlays(); if (busy || game === null) { return; @@ -760,7 +838,7 @@ export function startApp(options: AppOptions): void { if (old !== null) { showBanner(old); } - void act(() => startNewGame()); + void act(() => startNewGame(setup)); } function showBanner(journal: GameJournal): void { @@ -808,6 +886,10 @@ export function startApp(options: AppOptions): void { info = ""; } + // L'adresse de la page dit quel écran montrer (applyRoute, plus bas). + const navigation = startNavigation((route) => applyRoute(route)); + const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games"; + // « Mes parties » : reprendre une partie (ou une branche) en change à // l'écran, sans recharger la page. gamesView = createGamesView({ @@ -820,12 +902,16 @@ export function startApp(options: AppOptions): void { } const other = await options.games.open(id, branchHead); hideBanner(); + leaveSheet(); await act(() => showGame(other)); }, remove: options.games.remove, exportSgf: options.games.exportSgf, + onClose: () => leaveSheet(), }); + const helpView = createHelpView(ui.helpContent, ui.helpTabs); + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa * validation. Le pion qui se pose vibre (deux fois s'il frappe). */ function playHalfMove(halfMove: HalfMove): void { @@ -920,32 +1006,37 @@ export function startApp(options: AppOptions): void { void settle(true); }, again: () => newGame(), + // Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son + // lien) ; le refermer, revenir à l'écran d'avant. menu: () => { - if (ui.menuVeil.hidden) { + if (!ui.menuVeil.hidden) { closeOverlays(); - renderMenu(); - openOverlay(ui.menuVeil, ui.menuNewGame); } else { - closeOverlays(); + closeTransient(); + navigation.go("#/menu"); } }, help: () => { - closeOverlays(); - ui.game.hidden = true; - ui.help.hidden = false; - ui.root.classList.add("help-open"); - window.scrollTo(0, 0); - void showHelp(ui.helpContent); + closeTransient(); + navigation.go("#/aide"); }, fullscreen: () => { closeOverlays(); void (immersive ? exitImmersive() : enterImmersive()); }, + // Échap : la confirmation ou la bulle ouverte, sinon l'écran courant + // (feuille, aide, position), comme le retour du téléphone. close: () => { - if (!ui.help.hidden) { - closeHelp(); - } else { - closeOverlays(); + const route = navigation.route; + if (!ui.doubleVeil.hidden || !ui.bubble.hidden) { + closeTransient(); + render(); + } else if (isSheet(route)) { + leaveSheet(); + } else if (isHelpScreen(route)) { + navigation.leave(isHelpScreen); + } else if (route.screen === "position") { + navigation.leave((r) => r.screen === "position"); } }, }; @@ -959,12 +1050,10 @@ export function startApp(options: AppOptions): void { focus.focus(); } - /** Ferme le menu, la confirmation du double et la bulle ; ne touche - * jamais à la partie. */ - function closeOverlays(): void { - const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden; - gamesView?.close(); - ui.menuVeil.hidden = true; + /** Ferme la confirmation du double et la bulle, qui n'ont pas + * d'adresse ; ne touche jamais à la partie. */ + function closeTransient(): void { + const open = !ui.doubleVeil.hidden; ui.doubleVeil.hidden = true; bubbleSide = null; ui.bubble.hidden = true; @@ -973,24 +1062,40 @@ export function startApp(options: AppOptions): void { } } - function closeHelp(): void { - ui.help.hidden = true; - ui.game.hidden = false; - ui.root.classList.remove("help-open"); - render(); + /** Ferme aussi le menu ou « Mes parties » : on quitte leur adresse. */ + function closeOverlays(): void { + closeTransient(); + if (isSheet(navigation.route)) { + leaveSheet(); + } } - for (const veil of [ui.menuVeil, ui.doubleVeil]) { - veil.addEventListener("click", (event) => { - if (event.target === veil) { - closeOverlays(); - } - }); + /** Quitte le menu ou « Mes parties » : la feuille se ferme tout de + * suite, l'adresse suit (le retour dans l'historique est asynchrone). */ + function leaveSheet(): void { + const menuWasOpen = !ui.menuVeil.hidden; + ui.menuVeil.hidden = true; + gamesView?.hide(); + if (menuWasOpen) { + (menuReturnFocus ?? ui.menuButton).focus(); + } + navigation.leave(isSheet); } - ui.doubleCancel.onclick = () => closeOverlays(); + ui.menuVeil.addEventListener("click", (event) => { + if (event.target === ui.menuVeil) { + closeOverlays(); + } + }); + ui.doubleVeil.addEventListener("click", (event) => { + if (event.target === ui.doubleVeil) { + closeTransient(); + } + }); + + ui.doubleCancel.onclick = () => closeTransient(); ui.doubleConfirm.onclick = () => { - closeOverlays(); + closeTransient(); void act(async (g) => { await g.double(); info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value); @@ -1015,21 +1120,231 @@ export function startApp(options: AppOptions): void { // --------------------------------------------------------- Menu - ui.menuButton.onclick = actions.menu; + // Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à + // brancher. Les liens du menu remplacent son adresse : le retour du + // téléphone ne rouvre pas le menu. + replacingLink(ui.menuGames, navigation); + replacingLink(ui.menuHelp, navigation); ui.menuClose.onclick = () => closeOverlays(); ui.menuNewGame.onclick = () => newGame(); - ui.menuGames.onclick = () => { + ui.menuCopyLink.onclick = () => { + const link = currentLink; closeOverlays(); - void gamesView?.open(); + if (link !== null) { + void copyLink(link); + } }; ui.menuFullscreen.onclick = actions.fullscreen; - ui.menuHelp.onclick = actions.help; ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) }); ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations }); ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn }); - ui.helpButton.onclick = actions.help; - ui.helpBack.onclick = closeHelp; ui.fullscreenButton.onclick = actions.fullscreen; + // « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été + // ouverte par son adresse). + leavingLink(ui.helpBack, isHelpScreen); + leavingLink(ui.positionBack, (route) => route.screen === "position"); + + /** Un lien « Retour » : il revient dans l'historique plutôt que d'y + * empiler la partie (sauf ouvert dans un nouvel onglet). */ + function leavingLink(link: HTMLAnchorElement, stay: (route: Route) => boolean): void { + link.addEventListener("click", (event) => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + event.preventDefault(); + navigation.leave(stay); + }); + } + + // ----------------------------------------------------------- Adresses + + let menuReturnFocus: HTMLElement | null = null; + + /** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */ + function applyRoute(route: Route): void { + const help = isHelpScreen(route); + const position = route.screen === "position"; + const wasPage = ui.game.hidden; + ui.game.hidden = help || position; + ui.help.hidden = !help; + ui.position.hidden = !position; + ui.root.classList.toggle("page-open", help || position); + if (wasPage && !ui.game.hidden) { + window.scrollTo(0, 0); + } + + // Feuilles : le menu et « Mes parties ». + const menuOpen = route.screen === "menu"; + if (menuOpen && ui.menuVeil.hidden) { + closeTransient(); + menuReturnFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + renderMenu(); + ui.menuVeil.hidden = false; + ui.menuNewGame.focus(); + } else if (!menuOpen && !ui.menuVeil.hidden) { + ui.menuVeil.hidden = true; + if (route.screen === "game") { + (menuReturnFocus ?? ui.menuButton).focus(); + } + } + if (route.screen === "games") { + const expanded = route.game; + // La liste vient du stockage, ouvert avec le moteur. + void options.engineReady.then( + () => { + const now = navigation.route; + if (now.screen === "games" && now.game === expanded) { + void gamesView?.show(expanded); + } + }, + () => undefined, + ); + } else { + gamesView?.hide(); + } + + if (help) { + void helpView.show(route); + } + if (position) { + void showPosition(route); + } else { + positionRequest++; + shownPosition = null; + } + + // Écrans à venir (0e.5) et adresses inconnues : la partie, avec un + // message, à l'adresse de la partie. + if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") { + notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet; + navigation.replace("#/"); + return; + } + render(); + } + + // -------------------------------------------- Vue d'une position + + /** #/position/... : lit la position par le moteur, puis la montre. */ + async function showPosition(route: Extract): Promise { + const request = ++positionRequest; + shownPosition = null; + renderPosition({ kind: "loading" }); + try { + await options.engineReady; + const shared = await options.positions.read({ + position: route.position, + matchId: route.match, + }); + if (request === positionRequest) { + shownPosition = shared; + renderPosition({ kind: "shown", shared }); + } + } catch { + if (request === positionRequest) { + renderPosition({ kind: "unreadable" }); + } + } + } + + type PositionState = + | { kind: "loading" } + | { kind: "unreadable" } + | { kind: "shown"; shared: SharedPosition }; + + function renderPosition(state: PositionState): void { + const shown = state.kind === "shown"; + ui.positionTitle.textContent = + state.kind === "unreadable" ? TEXTS.unreadablePositionTitle : TEXTS.positionTitle; + ui.positionMessage.textContent = + state.kind === "loading" + ? TEXTS.prompts.loading.short + : state.kind === "unreadable" + ? TEXTS.unreadablePosition + : ""; + ui.positionBoard.toggleAttribute("hidden", !shown); + ui.position.classList.toggle("with-board", shown); + ui.positionDetail.hidden = !shown; + ui.positionCopy.hidden = !shown; + ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null; + if (state.kind !== "shown") { + return; + } + const shared = state.shared; + // En paysage, le plateau occupe toute la hauteur, à gauche de la + // colonne du titre et des actions (app.css), avec la géométrie du + // jeu : la colonne gauche du cadre garde le videau, sans menu ni + // bouton annuler (il ne se joue pas). + const landscape = layout() === "landscape"; + const box = ui.positionBoard.getBoundingClientRect(); + const width = box.width || Math.min(window.innerWidth, 640); + const height = box.height || window.innerHeight; + const model = boardModel(shared.sides); + renderBoard( + ui.positionBoard, + landscape + ? boardGeometry("landscape", width / height, height / 520) + : boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), + model, + { + dice: [], + cube: shared.cube, + ...NO_MARKS, + band: null, + controls: null, + }, + ); + ui.positionDetail.textContent = TEXTS.positionDetail( + model.pipCount, + shared.cube, + shared.match, + shared.result === null + ? null + : shared.result.winner === "onRoll" + ? "player" + : "computer", + ); + } + + ui.positionPlay.onclick = () => { + const shared = shownPosition; + if (shared === null || busy || game === null) { + return; + } + // La nouvelle partie est l'écran de jeu, à la place de la position + // (le retour du téléphone ne rouvre pas la position). + navigation.replace("#/"); + newGame(setupOf(shared)); + }; + ui.positionCopy.onclick = () => { + if (shownPosition !== null) { + void copyLink(shownPosition.link, ui.positionMessage); + } + }; + ui.copyLink.onclick = () => { + if (currentLink !== null) { + void copyLink(currentLink); + } + }; + + /** Copie l'adresse complète d'une position ; le résultat s'affiche dans + * target (par défaut, la ligne d'information de la partie). */ + async function copyLink(link: PositionLink, target?: HTMLElement): Promise { + const url = new URL(location.href); + url.hash = routeHash({ + screen: "position", + position: link.position, + match: link.matchId, + }); + const text = (await copyText(url.href)) ? TEXTS.linkCopied : TEXTS.linkNotCopied(url.href); + if (target === undefined) { + notice = text; + render(); + } else { + target.textContent = text; + } + } // ------------------------------------------------- Plein écran (ADR 0012) @@ -1100,6 +1415,9 @@ export function startApp(options: AppOptions): void { if (!animating) { render(); } + if (shownPosition !== null) { + renderPosition({ kind: "shown", shared: shownPosition }); + } }); }; window.addEventListener("resize", relayout); @@ -1130,7 +1448,6 @@ export function startApp(options: AppOptions): void { for (const side of ["computer", "player"] as const) { ui.strips[side].count.onclick = () => void toggleBubble(side); } - child(ui.bubble, ".link-button").onclick = actions.help; // ------------------------------------------------------ Touchers @@ -1153,6 +1470,10 @@ export function startApp(options: AppOptions): void { closeOverlays(); render(); } + // Un lien du plateau (le menu, en paysage) : le navigateur le suit. + if (target?.closest("a[href]") != null) { + return; + } const action = target?.closest("[data-action]")?.getAttribute("data-action"); if (action != null && action in actions) { actions[action as ActionName](); @@ -1184,7 +1505,8 @@ export function startApp(options: AppOptions): void { if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) { return; } - const onButton = event.target instanceof HTMLButtonElement; + const onButton = + event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement; let name: ActionName | null = null; switch (event.key) { case " ": @@ -1224,6 +1546,11 @@ export function startApp(options: AppOptions): void { name = "close"; break; } + // Hors de l'écran de jeu (aide, position), seules les touches de + // navigation servent. + if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) { + name = null; + } if (name !== null) { event.preventDefault(); actions[name](); @@ -1232,8 +1559,9 @@ export function startApp(options: AppOptions): void { // ---------------------------------------------------------- Démarrage - // Réponse immédiate : le plateau est dessiné avant que le moteur arrive. - render(); + // Réponse immédiate : le plateau est dessiné avant que le moteur arrive, + // ou l'écran que désigne l'adresse. + applyRoute(navigation.route); options.engineReady.then( async () => { game = options.savedGame(); @@ -1267,6 +1595,32 @@ export function startApp(options: AppOptions): void { // ------------------------------------------------- Lecture de la partie +/** Écrit un texte dans le presse-papiers ; faux si le navigateur refuse. + * Hors contexte sûr (http sur le réseau local), navigator.clipboard + * n'existe pas : repli sur une zone de texte sélectionnée, hors de l'écran. */ +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Repli ci-dessous. + } + const area = document.createElement("textarea"); + area.value = text; + area.readOnly = true; + area.className = "offscreen"; + document.body.append(area); + area.select(); + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + area.remove(); + return copied; +} + /** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */ function isPlaying(game: Game): boolean { const phase = game.state.phase; diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index ca3b0a2..15a08ec 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -532,7 +532,26 @@ function drawControls( transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`, }); add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] }); - if (on) { + if (!on) { + return; + } + if (action === "menu") { + // Le menu a son adresse (ADR 0013) : un vrai lien, comme dans + // la barre haute du portrait. + const link = add(hits, "a", { + href: "#/menu", + "data-action": action, + role: "link", + "aria-label": label, + }); + add(link, "rect", { + class: "hit action", + x: Math.max(0, x - g.target / 2), + y: Math.max(0, y - g.target / 2), + width: g.target, + height: g.target, + }); + } else { addAction(g, hits, action, label, { x, y }, g.target); } }; diff --git a/app/src/ui/games-view.ts b/app/src/ui/games-view.ts index 20891c8..3d3f9d6 100644 --- a/app/src/ui/games-view.ts +++ b/app/src/ui/games-view.ts @@ -8,12 +8,17 @@ // dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le // stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations // (GamesViewOptions), et change lui-même de partie quand on en reprend une. +// +// La feuille a son adresse (#/parties), et chaque partie la sienne +// (#/parties/, ses branches dépliées) : l'écran de jeu l'ouvre et la +// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien. import { branches } from "../domain/game/branches.js"; import type { GameJournal } from "../domain/game/events.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { GAMES_TEXTS } from "./games-texts.js"; import { icon } from "./icons.js"; +import { routeHash } from "./router.js"; export interface GamesViewOptions { /** Les parties, la dernière jouée d'abord. */ @@ -23,17 +28,21 @@ export interface GamesViewOptions { /** L'identifiant de la partie affichée à l'écran. */ readonly shownId: () => string | null; /** Reprend une partie, au bout de la branche branchHead si elle est - * donnée (sinon là où elle en était). */ + * donnée (sinon là où elle en était), et quitte la feuille. */ readonly resume: (id: string, branchHead?: string) => Promise; readonly remove: (id: string) => Promise; /** Le fichier SGF d'une partie : son nom et son contenu. */ readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>; + /** La croix ou le voile touché : quitter la feuille (l'écran de jeu + * change d'adresse, puis appelle hide). */ + readonly onClose: () => void; } export interface GamesView { - /** Ouvre la liste, à jour. */ - open(): Promise; - close(): void; + /** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les + * branches de la partie expanded dépliées. */ + show(expanded: string | null): Promise; + hide(): void; readonly isOpen: boolean; } @@ -51,7 +60,7 @@ export function createGamesView(options: GamesViewOptions): GamesView { const title = element("h2", "sheet-title"); title.id = "games-title"; title.textContent = GAMES_TEXTS.title; - const close = button("", () => hide(), "icon-button"); + const close = button("", () => options.onClose(), "icon-button"); close.id = "games-close"; close.setAttribute("aria-label", GAMES_TEXTS.close); close.append(icon("close")); @@ -64,13 +73,12 @@ export function createGamesView(options: GamesViewOptions): GamesView { document.body.append(veil); veil.addEventListener("click", (event) => { if (event.target === veil) { - hide(); + options.onClose(); } }); - function hide(): void { - veil.hidden = true; - } + // La partie dont les branches sont dépliées (adresse #/parties/). + let expanded: string | null = null; /** Lance une action ; une erreur s'affiche dans la fenêtre. */ const run = (action: () => Promise) => async () => { @@ -110,24 +118,11 @@ export function createGamesView(options: GamesViewOptions): GamesView { actions.append( button( GAMES_TEXTS.resume, - run(async () => { - await options.resume(game.id); - hide(); - }), + run(() => options.resume(game.id)), "", "resume", ), - button( - GAMES_TEXTS.showBranches, - run(async () => { - if (branchList.hidden) { - await renderBranches(game.id, branchList); - } - branchList.hidden = !branchList.hidden; - }), - "secondary", - "branches", - ), + branchesLink(game.id), button( GAMES_TEXTS.exportSgf, run(async () => download(await options.exportSgf(game.id))), @@ -149,6 +144,7 @@ export function createGamesView(options: GamesViewOptions): GamesView { } await options.remove(game.id); await render(); + await expand(); }), "secondary", "remove", @@ -164,6 +160,17 @@ export function createGamesView(options: GamesViewOptions): GamesView { return item; } + /** « Branches » : le lien qui déplie les branches d'une partie (ou les + * replie, quand elles le sont déjà). */ + function branchesLink(id: string): HTMLAnchorElement { + const link = element("a", "button secondary"); + link.textContent = GAMES_TEXTS.showBranches; + link.dataset.action = "branches"; + link.href = routeHash({ screen: "games", game: id }); + link.setAttribute("aria-expanded", "false"); + return link; + } + async function renderBranches(id: string, target: HTMLElement): Promise { const journal = await options.journal(id); const all = journal === null ? [] : branches(journal); @@ -182,10 +189,7 @@ export function createGamesView(options: GamesViewOptions): GamesView { text, button( GAMES_TEXTS.resumeBranch, - run(async () => { - await options.resume(id, branch.head); - hide(); - }), + run(() => options.resume(id, branch.head)), "secondary", "resume-branch", ), @@ -195,14 +199,39 @@ export function createGamesView(options: GamesViewOptions): GamesView { ); } + /** Déplie les branches de la partie expanded, replie les autres. */ + async function expand(): Promise { + for (const item of list.querySelectorAll(".games-item")) { + const id = item.dataset.id ?? ""; + const open = id === expanded; + const link = item.querySelector('[data-action="branches"]'); + link?.setAttribute("aria-expanded", String(open)); + link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id })); + const branchList = item.querySelector(".games-branches"); + if (branchList === null) { + continue; + } + if (open && branchList.hidden) { + await run(() => renderBranches(id, branchList))(); + } + branchList.hidden = !open; + } + } + return { - async open() { - message.textContent = ""; - await render(); - veil.hidden = false; - close.focus(); + async show(next) { + expanded = next; + if (veil.hidden) { + message.textContent = ""; + veil.hidden = false; + await run(render)(); + close.focus(); + } + await expand(); + }, + hide() { + veil.hidden = true; }, - close: hide, get isOpen() { return !veil.hidden; }, diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 67301fb..6a3ca07 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -1,23 +1,31 @@ -// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le -// glossaire, lus à l'exécution dans les copies de docs/ faites par make site -// (ADR 0008). Rien n'est recopié dans le code. +// L'aide : les scénarios (onglet « Jouer », une question par scénario), le +// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans +// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié +// dans le code. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013, +// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui +// est ouvert (show). import { type GlossaryEntry, type HelpFeature, + type HelpScenario, parseFeature, parseGlossaryEntry, parseGlossaryIndex, } from "./help-text.js"; +import { type HelpRoute, routeHash, slug } from "./router.js"; import { TEXTS } from "./texts.js"; /** Les scénarios montrés dans l'aide, dans l'ordre. */ const FEATURES = [ "docs/scenarios/jouer-une-partie.feature", "docs/scenarios/mes-parties.feature", + "docs/scenarios/partager-une-position.feature", "docs/scenarios/regler-l-application.feature", ]; const GLOSSARY = "docs/glossaire/"; @@ -30,10 +38,19 @@ async function fetchText(url: string): Promise { return response.text(); } -async function loadGlossary(): Promise { +/** Une fiche du glossaire et son nom de fichier, sans .md. */ +interface GlossaryCard extends GlossaryEntry { + readonly name: string; +} + +async function loadGlossary(): Promise { const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`)); const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file))); - return texts.map(parseGlossaryEntry).filter((entry) => entry !== null); + return texts.flatMap((text, i) => { + const entry = parseGlossaryEntry(text); + const name = (files[i] ?? "").replace(/\.md$/, ""); + return entry === null ? [] : [{ ...entry, name }]; + }); } /** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la @@ -55,56 +72,175 @@ function element( return child; } -function renderFeature(parent: HTMLElement, feature: HelpFeature): void { - element(parent, "h2", feature.title); - for (const paragraph of feature.description) { - element(parent, "p", paragraph); - } - for (const scenario of feature.scenarios) { - element(parent, "h3", scenario.title); - for (const paragraph of scenario.description) { - element(parent, "p", paragraph); +/** Une question de l'onglet « Jouer » : son lien et sa réponse. */ +interface Question { + readonly id: string; + readonly section: HTMLElement; + readonly link: HTMLAnchorElement; + readonly answer: HTMLElement; +} + +export interface HelpView { + /** Montre l'onglet, la question ou le terme que désigne l'adresse + * (l'aide est lue au premier appel). */ + show(route: HelpRoute): Promise; +} + +/** + * L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À + * propos). Les liens des onglets sont dans tabs, déjà dans la page. + */ +export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView { + const play = document.createElement("div"); + play.id = "help-play"; + const glossary = document.createElement("div"); + glossary.id = "help-glossary"; + const about = document.createElement("div"); + about.id = "help-about"; + const notice = document.createElement("p"); + notice.className = "help-notice"; + notice.setAttribute("role", "status"); + const questions: Question[] = []; + const terms = new Map(); + let loading: Promise | null = null; + // La question ouverte au dernier affichage : refermée, elle reste en vue. + let lastOpen: Question | null = null; + + function renderScenario(parent: HTMLElement, scenario: HelpScenario): void { + let id = slug(scenario.title); + for (let n = 2; questions.some((q) => q.id === id); n++) { + id = `${slug(scenario.title)}-${n}`; } - const steps = element(parent, "ul"); + const section = element(parent, "section"); + section.className = "question"; + section.id = `aide-${id}`; + const heading = element(section, "h3"); + const link = element(heading, "a", scenario.title); + link.href = routeHash({ screen: "help", question: id }); + link.setAttribute("aria-controls", `aide-${id}-reponse`); + link.setAttribute("aria-expanded", "false"); + const answer = element(section, "div"); + answer.id = `aide-${id}-reponse`; + answer.className = "answer"; + answer.hidden = true; + for (const paragraph of scenario.description) { + element(answer, "p", paragraph); + } + const steps = element(answer, "ul"); steps.className = "steps"; for (const step of scenario.steps) { element(steps, "li", step); } + questions.push({ id, section, link, answer }); } -} -function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void { - element(parent, "h2", TEXTS.glossaryTitle); - const list = element(parent, "dl"); - list.className = "glossary"; - for (const entry of entries) { - element(list, "dt", entry.term); - element(list, "dd", entry.brief); - } -} - -/** Remplit container avec l'aide (une seule fois ; les appels suivants ne - * font rien). */ -export async function showHelp(container: HTMLElement): Promise { - if (container.dataset.loaded === "true") { - return; - } - container.replaceChildren(); - const status = element(container, "p", TEXTS.helpLoading); - try { - const [features, glossary] = await Promise.all([ - Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))), - loadGlossary(), - ]); - status.remove(); - for (const feature of features) { - renderFeature(container, feature); + function renderFeature(feature: HelpFeature): void { + element(play, "h2", feature.title); + for (const paragraph of feature.description) { + element(play, "p", paragraph); + } + for (const scenario of feature.scenarios) { + renderScenario(play, scenario); } - renderGlossary(container, glossary); - const about = element(container, "p", TEXTS.about); - about.className = "about"; - container.dataset.loaded = "true"; - } catch { - status.textContent = TEXTS.helpFailed; } + + function renderGlossary(entries: readonly GlossaryCard[]): void { + element(glossary, "h2", TEXTS.glossaryTitle); + const list = element(glossary, "dl"); + list.className = "glossary"; + for (const entry of entries) { + const term = element(list, "dt"); + term.id = `glossaire-${entry.name}`; + const link = element(term, "a", entry.term); + link.href = routeHash({ screen: "glossary", term: entry.name }); + element(list, "dd", entry.brief); + terms.set(entry.name, term); + } + } + + function renderAbout(): void { + element(about, "h2", TEXTS.aboutTitle); + const text = element(about, "p", TEXTS.about); + text.className = "about"; + } + + async function load(): Promise { + container.replaceChildren(notice); + notice.textContent = TEXTS.helpLoading; + try { + const [features, entries] = await Promise.all([ + Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))), + loadGlossary(), + ]); + for (const feature of features) { + renderFeature(feature); + } + renderGlossary(entries); + renderAbout(); + notice.textContent = ""; + container.append(play, glossary, about); + container.dataset.loaded = "true"; + return true; + } catch { + notice.textContent = TEXTS.helpFailed; + loading = null; // on réessaiera à la prochaine ouverture + return false; + } + } + + return { + async show(route) { + for (const tab of tabs.querySelectorAll("a[data-screen]")) { + if (tab.dataset.screen === route.screen) { + tab.setAttribute("aria-current", "page"); + } else { + tab.removeAttribute("aria-current"); + } + } + loading ??= load(); + if (!(await loading)) { + return; + } + play.hidden = route.screen !== "help"; + glossary.hidden = route.screen !== "glossary"; + about.hidden = route.screen !== "about"; + + const question = route.screen === "help" ? route.question : null; + for (const q of questions) { + const open = q.id === question; + q.answer.hidden = !open; + q.link.setAttribute("aria-expanded", String(open)); + // Le lien d'une question ouverte la referme. + q.link.href = routeHash({ screen: "help", question: open ? null : q.id }); + q.section.classList.toggle("open", open); + } + const term = route.screen === "glossary" ? route.term : null; + for (const [name, dt] of terms) { + dt.classList.toggle("current", name === term); + if (name === term) { + dt.setAttribute("aria-current", "true"); + } else { + dt.removeAttribute("aria-current"); + } + } + const missing = + (question !== null && !questions.some((q) => q.id === question)) || + (term !== null && !terms.has(term)); + notice.textContent = missing ? TEXTS.helpNotFound : ""; + + const opened = questions.find((q) => q.id === question) ?? null; + const closed = route.screen === "help" && opened === null ? lastOpen : null; + lastOpen = opened; + // La barre haute de l'aide reste en vue (app.css) : ce qu'on + // amène en haut s'arrête 12 px sous elle (scroll-margin-top). + const target = opened?.section ?? (term === null ? undefined : terms.get(term)); + if (target !== undefined) { + target.scrollIntoView({ block: "start" }); + } else if (closed !== null) { + closed.section.scrollIntoView({ block: "nearest" }); + } else { + window.scrollTo(0, 0); + } + }, + }; } diff --git a/app/src/ui/navigation.ts b/app/src/ui/navigation.ts new file mode 100644 index 0000000..c47d913 --- /dev/null +++ b/app/src/ui/navigation.ts @@ -0,0 +1,166 @@ +// La navigation entre les écrans : l'adresse de la page est la source de +// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de +// vrais liens () ; l'application écoute hashchange et +// dessine l'écran que désigne l'adresse. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran +// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas +// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de +// l'onglet garde, dans history.state, la suite des adresses visitées +// jusqu'à elle. Elle survit au rechargement de la page. + +import { parseRoute, type Route, stepsBack } from "./router.js"; + +/** Au plus autant d'adresses gardées par entrée de l'historique. */ +const TRAIL_MAX = 50; + +export interface Navigation { + /** L'écran que désigne l'adresse actuelle. */ + readonly route: Route; + /** Va à une adresse, comme un lien (empile une entrée). */ + go(hash: string): void; + /** Remplace l'adresse actuelle, sans empiler. */ + replace(hash: string): void; + /** Quitte l'écran courant : revient à la dernière adresse de l'onglet + * qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un + * nouvel onglet), remplace l'adresse par celle de la partie. */ + leave(stay: (route: Route) => boolean): void; +} + +interface TrailState { + readonly ustagammonTrail: readonly string[]; +} + +function trailOf(state: unknown): readonly string[] | null { + const trail = (state as Partial | null)?.ustagammonTrail; + return Array.isArray(trail) ? trail : null; +} + +/** Le fragment actuel, « #/ » s'il est vide. */ +function currentHash(): string { + return location.hash === "" || location.hash === "#" ? "#/" : location.hash; +} + +/** + * Branche la navigation sur la page : onChange est appelé à chaque + * changement d'adresse (pas au démarrage : l'appelant lit route). + */ +export function startNavigation(onChange: (route: Route) => void): Navigation { + let trail: readonly string[] = []; + // Un retour dans l'historique est en cours (history.go est asynchrone) : + // une adresse demandée entre-temps attend son arrivée. + let leaving: ReturnType | null = null; + let queued: string | null = null; + + const remember = (next: readonly string[]) => { + trail = next.slice(-TRAIL_MAX); + try { + history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, ""); + } catch { + // Historique indisponible (page dans un cadre) : la navigation + // marche quand même, « Retour » ramène alors à la partie. + } + }; + + // L'entrée actuelle : déjà connue (rechargement), ou la première. + const known = trailOf(history.state); + remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]); + + window.addEventListener("hashchange", () => { + const hash = currentHash(); + const saved = trailOf(history.state); + if (saved !== null && saved.at(-1) === hash) { + // Retour ou avance dans l'historique : une entrée déjà connue. + trail = saved; + } else { + // Un lien suivi : une nouvelle entrée. + remember([...trail, hash]); + } + onChange(parseRoute(hash)); + if (leaving !== null) { + clearTimeout(leaving); + leaving = null; + const next = queued; + queued = null; + if (next !== null) { + go(next); + } + } + }); + + function go(hash: string): void { + if (leaving !== null) { + queued = hash; + } else if (hash !== currentHash()) { + location.hash = hash; + } + } + + function replace(hash: string): void { + if (hash === currentHash()) { + return; + } + const url = new URL(location.href); + url.hash = hash; + const next = [...trail.slice(0, -1), hash]; + try { + history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url); + } catch { + location.replace(url); + return; + } + trail = next; + // replaceState ne déclenche pas hashchange. + onChange(parseRoute(hash)); + } + + return { + get route() { + return parseRoute(currentHash()); + }, + go, + replace, + leave(stay) { + if (leaving !== null) { + return; // déjà en route + } + const steps = stepsBack(trail, stay); + if (steps === null) { + replace("#/"); + return; + } + // Au cas où le navigateur ne reviendrait pas (historique + // indisponible), on n'attend pas plus d'une seconde. + leaving = setTimeout(() => { + leaving = null; + queued = null; + }, 1000); + history.go(steps); + }, + }; +} + +/** + * Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une + * feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille + * fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton + * du milieu ou une touche, il reste un lien ordinaire. + */ +export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void { + link.addEventListener("click", (event) => { + if ( + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + navigation.replace(new URL(link.href).hash); + }); +} diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts new file mode 100644 index 0000000..1ddd254 --- /dev/null +++ b/app/src/ui/router.ts @@ -0,0 +1,191 @@ +// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque +// écran a la sienne, dans le fragment de l'adresse (après #). Ce module +// traduit un fragment en écran et un écran en fragment ; fonctions pures, +// testées sous Node (app/tests/router.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** Un écran de l'application, tel que le désigne son adresse. */ +export type Route = + /** #/ : la partie en cours. */ + | { readonly screen: "game" } + /** #/accueil (écran à venir, étape 0e.5). */ + | { readonly screen: "home" } + /** #/aide, et une question ouverte : #/aide/. */ + | { readonly screen: "help"; readonly question: string | null } + /** #/glossaire, et un terme : #/glossaire/. */ + | { readonly screen: "glossary"; readonly term: string | null } + /** #/a-propos. */ + | { readonly screen: "about" } + /** #/tutoriel, et une étape : #/tutoriel/ (écran à venir). */ + | { readonly screen: "tutorial"; readonly step: number | null } + /** #/parties, et une partie : #/parties/. */ + | { readonly screen: "games"; readonly game: string | null } + /** #/menu. */ + | { readonly screen: "menu" } + /** #/position// (Match ID facultatif). */ + | { readonly screen: "position"; readonly position: string; readonly match: string | null } + /** Une adresse que l'application ne connaît pas. */ + | { readonly screen: "unknown"; readonly hash: string }; + +export type Screen = Route["screen"]; + +export const GAME_ROUTE: Route = { screen: "game" }; + +/** Longueur d'un Position ID de gnubg. */ +const POSITION_ID_LENGTH = 14; + +/** Un identifiant d'écran (question, terme, partie) : lettres minuscules, + * chiffres et tirets. */ +const NAME = /^[a-z0-9][a-z0-9-]*$/; + +/** L'écran que désigne un fragment (avec ou sans # au début). */ +export function parseRoute(hash: string): Route { + const fragment = hash.startsWith("#") ? hash.slice(1) : hash; + if (fragment === "" || fragment === "/") { + return GAME_ROUTE; + } + const unknown: Route = { screen: "unknown", hash: `#${fragment}` }; + if (!fragment.startsWith("/")) { + return unknown; + } + const [head = "", ...rest] = fragment.slice(1).split("/"); + // Une barre finale (#/aide/) ne change rien. + if (rest.at(-1) === "") { + rest.pop(); + } + if (head === "position") { + return positionRoute(rest.join("/")) ?? unknown; + } + if (rest.length > 1) { + return unknown; + } + const name = rest[0] ?? null; + if (name !== null && !NAME.test(name)) { + return unknown; + } + switch (head) { + case "": + return name === null ? GAME_ROUTE : unknown; + case "accueil": + return name === null ? { screen: "home" } : unknown; + case "aide": + return { screen: "help", question: name }; + case "glossaire": + return { screen: "glossary", term: name }; + case "a-propos": + return name === null ? { screen: "about" } : unknown; + case "menu": + return name === null ? { screen: "menu" } : unknown; + case "parties": + return { screen: "games", game: name }; + case "tutoriel": { + if (name === null) { + return { screen: "tutorial", step: null }; + } + const step = Number(name); + return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown; + } + default: + return unknown; + } +} + +/** + * #/position/... : le Position ID (14 caractères), puis, après une barre, le + * Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une + * adresse collée telle quelle (le base64 de gnubg contient + et /) est + * comprise aussi : le texte est décodé en entier, puis découpé à la + * longueur du Position ID. Leur validité est l'affaire du moteur. + */ +function positionRoute(text: string): Route | null { + let decoded: string; + try { + decoded = decodeURIComponent(text); + } catch { + return null; + } + if (decoded === "") { + return null; + } + if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") { + const match = decoded.slice(POSITION_ID_LENGTH + 1); + return { + screen: "position", + position: decoded.slice(0, POSITION_ID_LENGTH), + match: match === "" ? null : match, + }; + } + return { screen: "position", position: decoded, match: null }; +} + +/** Le fragment d'un écran, # compris. */ +export function routeHash(route: Route): string { + switch (route.screen) { + case "game": + return "#/"; + case "home": + return "#/accueil"; + case "help": + return route.question === null ? "#/aide" : `#/aide/${route.question}`; + case "glossary": + return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`; + case "about": + return "#/a-propos"; + case "tutorial": + return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`; + case "games": + return route.game === null ? "#/parties" : `#/parties/${route.game}`; + case "menu": + return "#/menu"; + case "position": { + const position = `#/position/${encodeURIComponent(route.position)}`; + return route.match === null + ? position + : `${position}/${encodeURIComponent(route.match)}`; + } + case "unknown": + return route.hash; + } +} + +/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */ +export type HelpRoute = Extract; + +/** L'adresse est-elle celle d'un écran de l'aide ? */ +export function isHelpScreen(route: Route): route is HelpRoute { + return route.screen === "help" || route.screen === "glossary" || route.screen === "about"; +} + +/** L'identifiant d'une question de l'aide : son titre, en minuscules sans + * accents, les mots séparés par des tirets. */ +export function slug(title: string): string { + return title + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Où revenir en quittant un écran (« Retour », Échap, la croix d'une + * feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas + * de cet écran. trail est la suite des adresses de l'onglet jusqu'à + * l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou + * null si aucune adresse ne convient : il faut alors remplacer l'adresse + * par celle de la partie, sans rien empiler. + */ +export function stepsBack( + trail: readonly string[], + stay: (route: Route) => boolean, +): number | null { + for (let i = trail.length - 2; i >= 0; i--) { + const hash = trail[i]; + if (hash !== undefined && !stay(parseRoute(hash))) { + return i - (trail.length - 1); + } + } + return null; +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index 182ca99..67b73ef 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -153,8 +153,57 @@ export const TEXTS = { // Aide helpTitle: "Aide", - glossaryTitle: "Glossaire", + helpTabs: "Rubriques de l'aide", + helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" }, + glossaryTitle: "Les mots du jeu", + aboutTitle: "À propos", helpLoading: "Chargement de l'aide…", helpFailed: "L'aide n'a pas pu se charger.", + helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.", about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.", + + // Adresses (ADR 0013) + unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.", + screenNotYet: "Cet écran n'existe pas encore : voici votre partie.", + + // Lien vers une position (ADR 0013) + copyPositionLink: "Copier le lien de la position", + linkCopied: "Lien de la position copié.", + linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`, + positionTitle: "Position partagée", + unreadablePositionTitle: "Lien illisible", + unreadablePosition: + "Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.", + backToGame: "Retour à ma partie", + playFromHere: "Jouer à partir d'ici", + startedFromPosition: "Nouvelle partie à partir de la position du lien.", + /** Ce que montre la vue d'une position, sous le plateau. */ + positionDetail: ( + pips: Readonly>, + cube: { value: number; owner: "player" | "computer" | null }, + match: { length: number; score: Readonly> } | null, + winner: "player" | "computer" | null, + ) => + [ + winner === null + ? "Vous avez la main : vos pions ivoire, en bas." + : `La partie est finie dans cette position : ${ + winner === "player" ? "vous gagnez" : "l'ordinateur gagne" + }.`, + `Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`, + cube.value > 1 || cube.owner !== null + ? `Videau à ${cube.value}, ${ + cube.owner === null + ? "au centre" + : cube.owner === "player" + ? "chez vous" + : "chez l'ordinateur" + }.` + : "", + match === null + ? "" + : `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`, + ] + .filter((part) => part !== "") + .join(" "), } as const; diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 00c8df4..7981047 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial = {}): Engine { skill: "none", }), luck: async () => ({ value: 0, rating: "none" }), + encodeMatch: async () => "MATCH", + decodeMatch: async () => ({ + matchLength: 0, + score: [0, 0], + cubeValue: 1, + cubeOwner: -1, + playerOnRoll: 0, + crawford: false, + jacoby: true, + }), ...overrides, }; } diff --git a/app/tests/position-link.test.ts b/app/tests/position-link.test.ts new file mode 100644 index 0000000..632bddb --- /dev/null +++ b/app/tests/position-link.test.ts @@ -0,0 +1,99 @@ +// Tests des liens de position (app/src/domain/game/position-link.ts, ADR +// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau +// vu du joueur au trait, identifiants illisibles. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { EngineCube } from "../src/domain/game/engine.js"; +import { Game } from "../src/domain/game/game.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { + linkOf, + readPosition, + setupOf, + UnreadableLinkError, +} from "../src/domain/game/position-link.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { parseRoute, routeHash } from "../src/ui/router.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => { + const engine = await loadNodeEngine(); + const cases: [EngineCube, -1 | 0 | 1][] = [ + [{ value: 1, owner: "centered" }, -1], + [{ value: 2, owner: "onRoll" }, 0], + [{ value: 4, owner: "opponent" }, 1], + ]; + for (const [cube, owner] of cases) { + const id = await engine.encodeMatch(cube); + assert.equal(id.length, 12); + const match = await engine.decodeMatch(id); + assert.equal(match.matchLength, 0); + assert.equal(match.playerOnRoll, 0); + assert.equal(match.cubeValue, cube.value); + assert.equal(match.cubeOwner, owner); + } +}); + +test("sans Match ID : partie libre, videau au centre", async () => { + const engine = await loadNodeEngine(); + const shared = await readPosition(engine, { position: START_POSITION, matchId: null }); + assert.deepEqual(shared.cube, { value: 1, owner: null }); + assert.equal(shared.match, null); + assert.equal(shared.result, null); + assert.equal(shared.sides.player.pipCount, 167); + assert.equal(shared.sides.computer.pipCount, 167); + assert.deepEqual(setupOf(shared), { + position: START_POSITION, + firstTurn: "player", + cube: { value: 1, owner: null }, + }); +}); + +test("des identifiants illisibles donnent une erreur claire", async () => { + const engine = await loadNodeEngine(); + for (const link of [ + { position: "abc", matchId: null }, + { position: "4HPwATDgc/ABM*", matchId: null }, + { position: START_POSITION, matchId: "pas un id" }, + { position: START_POSITION, matchId: "" }, + ]) { + await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link)); + } +}); + +test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => { + const engine = await loadNodeEngine(); + // Videau chez l'ordinateur, à 2 : une partie commencée ainsi. + const game = Game.start(engine, { + dice: fixedDice([3, 1]), + setup: { + position: "jGfkASjg8wcBMA", + firstTurn: "player", + cube: { value: 2, owner: "computer" }, + }, + }); + const link = await linkOf(engine, game.state); + const route = parseRoute( + routeHash({ screen: "position", position: link.position, match: link.matchId }), + ); + assert.ok(route.screen === "position"); + const shared = await readPosition(engine, { position: route.position, matchId: route.match }); + assert.deepEqual(shared.sides, await game.displayedBoard()); + assert.deepEqual(shared.cube, { value: 2, owner: "computer" }); +}); + +test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => { + const engine = await loadNodeEngine(); + // Jet d'ouverture 1-6 : l'ordinateur commence. + const game = Game.start(engine, { dice: fixedDice([1, 6]) }); + assert.equal(game.state.turn, "computer"); + const link = await linkOf(engine, game.state); + assert.equal(link.position, game.state.position); + const shared = await readPosition(engine, link); + const board = await engine.board(game.state.position, { value: 1, owner: "centered" }); + assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent }); +}); diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts new file mode 100644 index 0000000..0894e9c --- /dev/null +++ b/app/tests/router.test.ts @@ -0,0 +1,138 @@ +// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de +// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran +// redonne son adresse. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + isHelpScreen, + parseRoute, + type Route, + routeHash, + slug, + stepsBack, +} from "../src/ui/router.js"; + +const START = "4HPwATDgc/ABMA"; +const MATCH = "cAkAAAAAAAAA"; + +/** Les adresses du tableau de l'ADR, et l'écran de chacune. */ +const TABLE: [string, Route][] = [ + ["#/", { screen: "game" }], + ["#/accueil", { screen: "home" }], + ["#/aide", { screen: "help", question: null }], + ["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }], + ["#/glossaire", { screen: "glossary", term: null }], + ["#/glossaire/videau", { screen: "glossary", term: "videau" }], + ["#/a-propos", { screen: "about" }], + ["#/tutoriel", { screen: "tutorial", step: null }], + ["#/tutoriel/3", { screen: "tutorial", step: 3 }], + ["#/parties", { screen: "games", game: null }], + ["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }], + ["#/menu", { screen: "menu" }], + [ + "#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA", + { screen: "position", position: START, match: MATCH }, + ], + ["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }], +]; + +test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => { + for (const [hash, route] of TABLE) { + assert.deepEqual(parseRoute(hash), route, hash); + assert.equal(routeHash(route), hash, hash); + } +}); + +test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => { + assert.deepEqual(parseRoute(""), { screen: "game" }); + assert.deepEqual(parseRoute("#"), { screen: "game" }); + assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null }); + assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" }); +}); + +test("une adresse inconnue est reconnue comme telle", () => { + for (const hash of [ + "#/inconnu", + "#aide", + "#/aide/a/b", + "#/aide/Majuscules", + "#/menu/plus", + "#/tutoriel/0", + "#/tutoriel/x", + "#/a-propos/x", + "#/position", + "#/position/%E0%A4%A", + ]) { + assert.equal(parseRoute(hash).screen, "unknown", hash); + } + assert.equal(routeHash(parseRoute("#/inconnu")), "#/inconnu"); +}); + +test("un lien de position collé sans encodage (+ et / du base64) est compris", () => { + assert.deepEqual(parseRoute(`#/position/${START}/${MATCH}`), { + screen: "position", + position: START, + match: MATCH, + }); + assert.deepEqual(parseRoute("#/position/jGfkASjg8wcBMA/cAkAAAAAAAAA"), { + screen: "position", + position: "jGfkASjg8wcBMA", + match: MATCH, + }); + // + reste un + (pas une espace), encodé ou non. + const plus = "AAAA+AAAAAAAAA"; + assert.equal( + routeHash({ screen: "position", position: plus, match: null }), + "#/position/AAAA%2BAAAAAAAAA", + ); + assert.deepEqual(parseRoute("#/position/AAAA%2BAAAAAAAAA"), { + screen: "position", + position: plus, + match: null, + }); + assert.deepEqual(parseRoute(`#/position/${plus}`), { + screen: "position", + position: plus, + match: null, + }); +}); + +test("un identifiant de position de mauvaise longueur arrive tel quel au moteur, qui jugera", () => { + assert.deepEqual(parseRoute("#/position/abc"), { + screen: "position", + position: "abc", + match: null, + }); +}); + +test("l'identifiant d'une question : le titre sans accents ni ponctuation", () => { + assert.equal(slug("Jouer un coup, puis l'annuler"), "jouer-un-coup-puis-l-annuler"); + assert.equal(slug(" Été : déjà vu ? "), "ete-deja-vu"); + assert.equal( + slug("Rejouer d'ici sans perdre l'original"), + "rejouer-d-ici-sans-perdre-l-original", + ); +}); + +test("les écrans de l'aide : jouer, les mots du jeu, à propos", () => { + assert.ok(isHelpScreen(parseRoute("#/aide/x"))); + assert.ok(isHelpScreen(parseRoute("#/glossaire"))); + assert.ok(isHelpScreen(parseRoute("#/a-propos"))); + assert.ok(!isHelpScreen(parseRoute("#/menu"))); +}); + +test("quitter un écran revient à la dernière adresse qui n'en est pas", () => { + const help = (route: Route) => isHelpScreen(route); + // Partie, aide, une question : « Retour » remonte de deux pas. + assert.equal(stepsBack(["#/", "#/aide", "#/aide/q"], help), -2); + // Le menu ouvert depuis l'aide : on revient à l'aide. + const menu = (route: Route) => route.screen === "menu"; + assert.equal(stepsBack(["#/", "#/aide", "#/menu"], menu), -1); + // Ouvert directement dans un nouvel onglet : rien avant, on remplace. + assert.equal(stepsBack(["#/aide/q"], help), null); + assert.equal(stepsBack(["#/aide", "#/glossaire/blot"], help), null); +}); diff --git a/app/tests/steps/partager-une-position.steps.ts b/app/tests/steps/partager-une-position.steps.ts new file mode 100644 index 0000000..267ac5c --- /dev/null +++ b/app/tests/steps/partager-une-position.steps.ts @@ -0,0 +1,171 @@ +// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008, +// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite +// puis relue), la position par le domaine et le vrai moteur WebAssembly, et +// les parties par le même enregistrement que l'application. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, BeforeAll, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import type { GameJournal } from "../../src/domain/game/events.js"; +import { Game } from "../../src/domain/game/game.js"; +import { + linkOf, + readPosition, + type SharedPosition, + setupOf, + UnreadableLinkError, +} from "../../src/domain/game/position-link.js"; +import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js"; +import { createGameSession, type GameSession } from "../../src/storage/game-session.js"; +import { createKeyValueBackend } from "../../src/storage/key-value-backend.js"; +import { memoryStorage } from "../../src/storage/open-library.js"; +import { parseRoute, routeHash } from "../../src/ui/router.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { playMoves, seededDice, step } from "../support/play.js"; + +let engine: Engine; +let library: GameLibrary; +let session: GameSession; +let game: Game; +let gameId: string | null; +let journalBefore: GameJournal; +let address: string; +let shared: SharedPosition | null; +let failure: unknown; + +/** Une position de milieu de partie (gnubg), vue du joueur au trait. */ +const MIDDLE_GAME = "jGfkASjg8wcBMA"; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +Before(() => { + library = createGameLibrary(createKeyValueBackend(memoryStorage())); + session = createGameSession( + library, + { + start: (setup) => + Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }), + resume: (journal) => Game.resume(engine, journal, { ply: 0 }), + }, + "0ply", + ); + shared = null; + failure = null; +}); + +/** Ouvre une adresse comme l'application : le routeur la lit, le moteur + * lit la position. */ +async function open(hash: string): Promise { + const route = parseRoute(hash); + assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`); + if (route.screen !== "position") { + return; + } + try { + shared = await readPosition(engine, { position: route.position, matchId: route.match }); + } catch (error) { + failure = error; + } +} + +Given("votre partie en cours compte quelques coups", async () => { + game = session.newGame(); + // Des coups jusqu'à ce que ce soit à vous de lancer les dés. + await playMoves(game, engine, 4); + const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling"; + while (!yourRoll() && game.state.phase !== "over") { + await step(game, engine); + } + assert.ok(yourRoll(), "partie finie trop tôt"); + session.save(game.journal); + gameId = session.shownId; + journalBefore = game.journal; +}); + +When("vous copiez le lien de la position", async () => { + const link = await linkOf(engine, game.state); + address = routeHash({ screen: "position", position: link.position, match: link.matchId }); +}); + +Then("le lien contient l'identifiant de la position et celui du match", () => { + const route = parseRoute(address); + assert.ok(route.screen === "position"); + assert.equal(route.position, game.state.position); + assert.equal(route.match?.length, 12); + assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/); +}); + +When("vous ouvrez ce lien", async () => { + await open(address); +}); + +Then("vous voyez exactement la même position, avec le même videau", async () => { + assert.ok(shared !== null, `lien illisible : ${failure}`); + assert.deepEqual(shared.sides, await game.displayedBoard()); + assert.deepEqual(shared.cube, game.state.cube); + assert.equal(shared.match, null); +}); + +When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => { + const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" }); + address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId }); + await open(address); +}); + +Then("vous voyez cette position, avec la main", async () => { + assert.ok(shared !== null, `lien illisible : ${failure}`); + const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" }); + assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent }); + assert.equal(shared.result, null); +}); + +Then("votre partie n'a pas changé", () => { + assert.equal(session.shownId, gameId); + assert.deepEqual(game.journal, journalBefore); +}); + +When("vous choisissez « Jouer à partir d'ici »", () => { + assert.ok(shared !== null); + game = session.newGame(setupOf(shared)); + session.save(game.journal); +}); + +Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => { + assert.notEqual(session.shownId, gameId); + assert.equal(game.state.position, MIDDLE_GAME); + assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "rolling"); +}); + +Then("le videau est à 2, chez l'ordinateur", () => { + assert.deepEqual(game.state.cube, { value: 2, owner: "computer" }); +}); + +Then("votre partie précédente est toujours enregistrée", async () => { + assert.ok(gameId !== null); + const saved = await library.load(gameId); + assert.deepEqual(saved?.journal, journalBefore); +}); + +When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => { + // Coupé d'un caractère, puis un caractère hors du base64. + shared = null; + failure = null; + await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA"); +}); + +When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => { + shared = null; + failure = null; + await open("#/position/4HPwATDgc%2FABMA/cAk*"); +}); + +Then("le lien est déclaré illisible", () => { + assert.equal(shared, null); + assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`); +}); diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index d6838c0..182d163 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -23,7 +23,6 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` | | 0e.5 | Page d'accueil, mise à jour de l'app en un toucher, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` | ## Étapes suivantes diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 2036fc7..886ef6a 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -52,6 +52,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Identifiant de match](identifiant-de-match.md) | Match ID | | [Identifiant de position](identifiant-de-position.md) | Position ID | | [Jet](jet.md) | Roll | +| [Lien de position](lien-de-position.md) | Position link | | [Match](match.md) | Match | | [Moteur](moteur.md) | Engine | | [Niveau de jeu](niveau-de-jeu.md) | Level | diff --git a/docs/glossaire/identifiant-de-match.md b/docs/glossaire/identifiant-de-match.md index e97efc9..4960885 100644 --- a/docs/glossaire/identifiant-de-match.md +++ b/docs/glossaire/identifiant-de-match.md @@ -1,9 +1,10 @@ --- term: Identifiant de match english: Match ID +code: MatchId --- # Identifiant de match **En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon. -Voir aussi : [Identifiant de position](identifiant-de-position.md) +Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md) diff --git a/docs/glossaire/identifiant-de-position.md b/docs/glossaire/identifiant-de-position.md index e002614..bb1aa17 100644 --- a/docs/glossaire/identifiant-de-position.md +++ b/docs/glossaire/identifiant-de-position.md @@ -7,4 +7,4 @@ code: PositionId **En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon. -Voir aussi : [Identifiant de match](identifiant-de-match.md) +Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md) diff --git a/docs/glossaire/lien-de-position.md b/docs/glossaire/lien-de-position.md new file mode 100644 index 0000000..afb6da8 --- /dev/null +++ b/docs/glossaire/lien-de-position.md @@ -0,0 +1,15 @@ +--- +term: Lien de position +english: Position link +code: PositionLink +--- +# Lien de position + +**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match. + +Le lien montre la position du point de vue du joueur qui a la main : c'est +vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis +cette position, sans toucher à votre partie en cours. Le lien ne contient que +la position : aucune donnée personnelle. + +Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md) diff --git a/docs/plans/2026-11-03-position-dans-l-adresse.md b/docs/plans/2026-11-03-position-dans-l-adresse.md deleted file mode 100644 index 53e7431..0000000 --- a/docs/plans/2026-11-03-position-dans-l-adresse.md +++ /dev/null @@ -1,50 +0,0 @@ -# Une adresse pour chaque écran, et pour chaque position (0e.4) - -- **Date** : 2026-11-03 -- **Étape** : 0e.4 (feuille de route) -- **Statut** : prévu -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Appliquer l'ADR 0013 et son amendement du 2026-11-03 : chaque écran (aide, -glossaire, menu, mes parties…) a son adresse et s'ouvre par un vrai lien ; -chaque position du plateau a un lien direct. - -## Consignes - -ADR 0007, 0008 et 0013. Aucune nouvelle dépendance. - -0. **Routeur** : un petit module pur (sans dépendance) qui traduit le - fragment en écran et inversement, testé unitairement ; l'app dessine - l'écran demandé par l'adresse, sur `hashchange`. Tous les écrans existants - (aide et ses questions, glossaire et ses termes, à propos, menu, mes - parties de 1b) passent par lui, avec de vrais liens `` ; le bouton - retour du navigateur ramène à l'écran précédent. Les actions de jeu restent - des boutons. -1. Lecture de `#/position//` ; validation par le - moteur ; message clair si illisible. -2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle - partie depuis la position, la partie en cours restant reprenable) et - « Retour à ma partie ». -3. Domaine : démarrer une partie depuis une position et un Match ID donnés - (événement GameStarted étendu de façon rétrocompatible, ou équivalent - justifié), sans règle réimplémentée. -4. « Copier le lien de la position » depuis le menu de la partie et depuis la - vue du coup de l'ordinateur ; le lien copié rouvre exactement la même - position (test aller-retour). -5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la - position »), fiches du glossaire (Position ID, Match ID si absentes). -6. `make lint`, `make test`, `make test-browser` : chaque adresse du tableau - de l'ADR ouverte directement dans un nouvel onglet montre le bon écran ; - « retour » du navigateur ; liens reconnus comme liens (rôle `link`) ; - position : ouvrir un lien, jouer à partir d'ici, revenir, lien invalide. - -## Critères de réussite - -Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même -position ; les trois commandes passent. - -## Résultat - -À remplir à la clôture. diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 56305f2..0c16079 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -48,4 +48,5 @@ Règles d'écriture : | --- | --- | --- | | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | | [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b | +| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 | | [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e | diff --git a/docs/scenarios/partager-une-position.feature b/docs/scenarios/partager-une-position.feature new file mode 100644 index 0000000..7155076 --- /dev/null +++ b/docs/scenarios/partager-une-position.feature @@ -0,0 +1,43 @@ +# language: fr +Fonctionnalité: Partager une position + + Chaque position du plateau a son lien : une adresse qui la rouvre telle + quelle, à envoyer à un ami, à garder pour plus tard ou à coller dans une + question. Le lien contient les deux identifiants de GNU Backgammon, celui + de la position et celui du match, et rien d'autre. + + Scénario: Partager la position + Pendant la partie, « Copier le lien de la position », dans le menu ou à + côté du dernier coup de l'ordinateur, copie l'adresse de la position à + l'écran. Ouvert ailleurs, le lien montre la même position. + + Étant donné que votre partie en cours compte quelques coups + Quand vous copiez le lien de la position + Alors le lien contient l'identifiant de la position et celui du match + Quand vous ouvrez ce lien + Alors vous voyez exactement la même position, avec le même videau + + Scénario: Ouvrir un lien vers une position + Un lien vers une position montre le plateau, sans le rendre jouable : + vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une + nouvelle partie contre l'ordinateur depuis cette position, et votre + partie en cours reste dans « Mes parties ». « Retour à ma partie » vous + ramène à votre partie, intacte. + + Étant donné que votre partie en cours compte quelques coups + Quand vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur + Alors vous voyez cette position, avec la main + Et votre partie n'a pas changé + Quand vous choisissez « Jouer à partir d'ici » + Alors une nouvelle partie commence à cette position, à vous de lancer les dés + Et le videau est à 2, chez l'ordinateur + Et votre partie précédente est toujours enregistrée + + Scénario: Ouvrir un lien abîmé + Un lien coupé ou mal recopié ne casse rien : un message le dit, et + « Retour à ma partie » reste là. + + Quand vous ouvrez un lien dont l'identifiant de position est abîmé + Alors le lien est déclaré illisible + Quand vous ouvrez un lien dont l'identifiant de match est abîmé + Alors le lien est déclaré illisible diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index f0973ea..c43392b 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -76,6 +76,15 @@ await context.addInitScript(() => { return null; }; } + // Les liens copiés (« Copier le lien de la position ») : le + // presse-papiers du navigateur sans écran n'est pas lisible partout. + window.__copied = []; + if (window.Clipboard) { + Clipboard.prototype.writeText = function (text) { + window.__copied.push(String(text)); + return Promise.resolve(); + }; + } window.__vibrations = []; Object.defineProperty(Navigator.prototype, "vibrate", { configurable: true, @@ -291,10 +300,49 @@ async function checkStable(when) { stableChecks++; } +/** Les boutons visibles d'une page dont le texte passe à la ligne : un + * bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus + * haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ; + * un texte coupé par le bord (white-space: nowrap) est refusé aussi. */ +async function wrappedButtons(target) { + return target.evaluate(() => { + const wrapped = []; + for (const el of document.querySelectorAll("button, a.button")) { + const r = el.getBoundingClientRect(); + if (el.closest("[hidden]") || r.width === 0 || r.height === 0) { + continue; + } + const name = el.id ? `#${el.id}` : el.textContent.trim(); + const tops = new Set(); + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + if (node.textContent.trim() === "") { + continue; + } + const range = document.createRange(); + range.selectNodeContents(node); + for (const line of range.getClientRects()) { + if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) { + tops.add(line.top); + } + } + } + if (r.height > 48.5 && tops.size > 1) { + wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`); + } else if (el.scrollWidth > el.clientWidth + 1) { + wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`); + } + } + return wrapped; + }); +} + /** Les critères de la recette vérifiables sur la vue courante. Renvoie des * mesures pour le compte rendu. */ async function checkView(width, height, when) { const where = `${width}x${height} (${when})`; + const wrapped = await wrappedButtons(page); + check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`); const seen = await page.evaluate(() => { const visible = (el) => { if (el.closest("[hidden]")) { @@ -312,7 +360,7 @@ async function checkView(width, height, when) { el.className?.baseVal ?? el.className; // Cibles tactiles : boutons et zones d'action du plateau. const small = []; - for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) { + for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action], a.icon-button, a.item, a.button")) { if (!visible(el)) { continue; } @@ -528,7 +576,7 @@ async function touchTarget(action, label, count = 1, index = 0) { check((await zones.count()) === count, `zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`); for (const zone of await zones.all()) { - check((await zone.getAttribute("role")) === "button" && + check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") && (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); const box = await zone.boundingBox(); check(box.width >= 44 && box.height >= 44, @@ -588,10 +636,329 @@ async function checkReplay(traces, reducedMotion) { await page.emulateMedia({ reducedMotion: "no-preference" }); } +// ----------------------------------------------------------- Adresses + +/** Un nouvel onglet ouvert directement sur une adresse (fragment ou URL + * complète), ses erreurs comptées comme celles de la page principale. */ +async function openTab(address, viewport = PORTRAIT) { + const tab = await context.newPage(); + await tab.setViewportSize(viewport); + tab.on("pageerror", (error) => problems.push(`erreur de la page (${address}) : ${error.message}`)); + tab.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console (${address}) : ${message.text()}`); + } + }); + tab.on("dialog", (dialog) => { + problems.push(`boîte native ouverte (${address}) : ${dialog.type()}`); + void dialog.dismiss(); + }); + await tab.goto(new URL(address, base).href); + return tab; +} + +/** Ce que montre un onglet : écran, question, terme, feuille, message. */ +async function screenOf(tab) { + return tab.evaluate(() => { + const shown = (id) => { + const el = document.getElementById(id); + return el !== null && !el.hasAttribute("hidden") && el.getClientRects().length > 0; + }; + const text = (id) => document.getElementById(id)?.textContent ?? ""; + return { + hash: location.hash, + busy: document.body.classList.contains("busy"), + game: shown("game"), + help: shown("help"), + play: shown("help-play"), + glossary: shown("help-glossary"), + about: shown("help-about"), + question: document.querySelector("#help-play .question.open")?.id ?? null, + term: document.querySelector("#help-glossary dt.current")?.id ?? null, + menu: shown("menu"), + games: shown("games"), + listed: document.querySelectorAll("#games .games-item").length, + expanded: + document + .querySelector('#games [data-action="branches"][aria-expanded="true"]') + ?.closest(".games-item")?.dataset.id ?? null, + position: shown("position-view"), + positionBoard: shown("position-board"), + positionTitle: text("position-title"), + positionMessage: text("position-message"), + positionPlay: shown("position-play"), + info: text("info"), + }; + }); +} + +/** Attend que l'onglet montre l'écran attendu (expected : test sur + * screenOf), au plus 30 s ; sinon, échoue avec ce qu'il montre. */ +async function expectScreen(tab, what, expected) { + let state = null; + for (let i = 0; i < 300; i++) { + state = await screenOf(tab); + if (expected(state)) { + return state; + } + await sleep(100); + } + await tab.screenshot({ path: `${shots}/${browserName}-adresse-echec.png` }).catch(() => {}); + throw new Error(`${what} : écran inattendu ${JSON.stringify(state)}`); +} + +/** Le dernier lien copié par l'application dans cet onglet. */ +async function lastCopied(tab) { + for (let i = 0; i < 50; i++) { + const copied = await tab.evaluate(() => window.__copied.at(-1) ?? null); + if (copied !== null) { + return copied; + } + await sleep(100); + } + throw new Error("aucun lien copié"); +} + +/** L'élément est-il exposé comme un lien (rôle link) ? */ +async function isLink(tab, selector) { + const all = tab.locator(selector); + const links = all.and(tab.getByRole("link")); + return (await all.count()) > 0 && (await links.count()) === (await all.count()); +} + +async function checkAddresses() { + const dir = `${shots}/adresses`; + mkdirSync(dir, { recursive: true }); + const playing = (s) => s.hash === "#/" && s.game && !s.help && !s.position && !s.menu && !s.games; + + // Le lien de la position en cours, copié depuis le menu de la partie. + await openMenu(); + await page.evaluate(() => { + window.__copied = []; + }); + await page.click("#menu-copy-link"); + const positionUrl = await lastCopied(page); + const positionHash = new URL(positionUrl).hash; + check(/^#\/position\/[^/]{14,}\/[^/]+$/.test(positionHash), `lien de position copié : ${positionUrl}`); + check(!(await page.isVisible("#menu")), "le menu reste ouvert après la copie du lien"); + + // Chaque adresse du tableau de l'ADR, dans un nouvel onglet. Les + // identifiants d'une question et d'une partie sont lus en chemin. + let question = null; + let gameId = null; + let gamesBefore = 0; + const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore"); + const addresses = [ + ["#/", playing], + ["#/accueil", notYet], + ["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null], + [() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`], + ["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null], + ["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"], + ["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary], + ["#/tutoriel", notYet], + ["#/tutoriel/2", notYet], + ["#/parties", (s) => s.games && s.listed >= 2 && !s.menu], + [() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId], + ["#/menu", (s) => s.menu && !s.games], + [positionHash, (s) => s.position && s.positionBoard && !s.game && s.positionPlay], + [`#/position/${positionHash.split("/")[2]}`, (s) => s.position && s.positionBoard], + ["#/position/pas-une-position", (s) => + s.position && !s.positionBoard && s.positionMessage.includes("ne décrit pas une position")], + ["#/nulle-part", (s) => s.hash === "#/" && s.game && s.info.includes("ne mène à aucun écran")], + ]; + for (const [address, expected] of addresses) { + const hash = typeof address === "function" ? address() : address; + const tab = await openTab(hash); + await expectScreen(tab, `adresse ${hash}`, expected); + if (hash === "#/aide") { + question = await tab.evaluate(() => + document.querySelector("#help-play .question")?.id.replace(/^aide-/, "")); + check(typeof question === "string" && question !== "", "aide : aucune question"); + } + if (hash === "#/parties") { + await tab.waitForSelector("#games .games-item"); + gameId = await tab.evaluate(() => document.querySelector("#games .games-item")?.dataset.id); + gamesBefore = (await screenOf(tab)).listed; + } + await tab.close(); + } + log(`adresses : ${addresses.length} adresses ouvertes directement, chacune sur son écran`); + + // Captures : l'aide ouverte par son adresse, une question, un terme, la + // vue d'une position, un lien illisible ; en portrait et en paysage. + const captures = [ + ["aide", "#/aide", (s) => s.help && s.play], + ["question", `#/aide/${question}`, (s) => s.question === `aide-${question}`], + ["terme", "#/glossaire/lien-de-position", (s) => s.term === "glossaire-lien-de-position"], + ["position", positionHash, (s) => s.position && s.positionBoard], + ["lien-invalide", "#/position/pas-une-position", (s) => s.position && + s.positionMessage.includes("ne décrit pas une position")], + ]; + for (const [width, height] of [[412, 915], [915, 412]]) { + for (const [name, hash, expected] of captures) { + const tab = await openTab(hash, { width, height }); + await expectScreen(tab, `capture ${name}`, expected); + await sleep(300); + await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` }); + const where = `${name} ${width}x${height}`; + const wrapped = await wrappedButtons(tab); + check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`); + if (name === "question" || name === "terme") { + // La barre haute de l'aide reste en vue, et ce qu'on a + // ouvert s'arrête juste sous elle. + const seen = await tab.evaluate(() => { + const bar = document.getElementById("help-bar").getBoundingClientRect(); + const target = document.querySelector( + "#help-play .question.open, #help-glossary dt.current", + ).getBoundingClientRect(); + return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top }; + }); + check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1, + `${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`); + check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2, + `${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`); + } + if (name === "position") { + // Les trois actions, dans l'ordre, visibles sans défiler. + const seen = await tab.evaluate(() => ({ + scrolled: window.scrollY, + order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id), + boxes: ["position-play", "position-copy", "position-back"].map((id) => { + const r = document.getElementById(id).getBoundingClientRect(); + return [r.left, r.top, r.right, r.bottom]; + }), + board: (() => { + const r = document.getElementById("position-board").getBoundingClientRect(); + return [r.left, r.top, r.width, r.height]; + })(), + cube: (() => { + const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect(); + return r === undefined ? null : [r.left, r.top, r.right, r.bottom]; + })(), + controls: document.querySelectorAll("#position-board [data-action]").length, + })); + check(seen.order.join() === "position-play,position-copy,position-back", + `${where} : actions dans l'ordre ${seen.order}`); + const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height); + if (width > height) { + check(seen.scrolled === 0 && inView, + `${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`); + check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1, + `${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`); + // Le videau, dans la colonne gauche du cadre, entier à + // l'écran ; ni menu ni annuler : la position ne se joue pas. + const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1]; + check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height, + `${where} : videau coupé : ${JSON.stringify(seen.cube)}`); + check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`); + } else { + // Empilées sur toute la largeur. + const [first] = seen.boxes; + check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 && + Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])), + `${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`); + } + } + await tab.close(); + } + } + + // Retour du navigateur et rôle des liens, dans un même onglet. + { + const tab = await openTab("#/"); + await expectScreen(tab, "partie", playing); + for (const selector of ["#help-button", "#menu-button"]) { + check(await isLink(tab, selector), `${selector} n'est pas un lien`); + } + for (const selector of ["#roll, #confirm, #again, #double", "#fullscreen-button"]) { + check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0, + `${selector} : une action de jeu exposée comme un lien`); + } + await tab.click("#help-button"); + await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play); + for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) { + check(await isLink(tab, selector), `${selector} : pas des liens`); + } + await tab.click(`#aide-${question} h3 a`); + await expectScreen(tab, "question par son lien", (s) => s.hash === `#/aide/${question}` && + s.question === `aide-${question}`); + await tab.goBack(); + await expectScreen(tab, "retour : question refermée", (s) => s.hash === "#/aide" && s.help && + s.question === null); + await tab.click('#help-tabs a[data-screen="glossary"]'); + await expectScreen(tab, "onglet glossaire", (s) => s.hash === "#/glossaire" && s.glossary); + check(await isLink(tab, "#help-glossary dt a"), "les termes du glossaire ne sont pas des liens"); + await tab.goBack(); + await expectScreen(tab, "retour : onglet Jouer", (s) => s.hash === "#/aide" && s.play); + await tab.goBack(); + await expectScreen(tab, "retour : la partie", playing); + // Le menu, puis « Mes parties » depuis le menu : le retour ferme la + // feuille et ramène à la partie, sans rouvrir le menu. + await tab.click("#menu-button"); + await expectScreen(tab, "menu par son lien", (s) => s.hash === "#/menu" && s.menu); + for (const selector of ["#menu-games", "#menu-help"]) { + check(await isLink(tab, selector), `${selector} n'est pas un lien`); + } + check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1, + "« Nouvelle partie » n'est plus un bouton"); + await tab.goBack(); + await expectScreen(tab, "retour : menu fermé", playing); + await tab.click("#menu-button"); + await expectScreen(tab, "menu", (s) => s.menu); + await tab.click("#menu-games"); + await expectScreen(tab, "mes parties depuis le menu", (s) => s.hash === "#/parties" && s.games && + !s.menu); + await tab.goBack(); + await expectScreen(tab, "retour : mes parties fermé", playing); + // Échap quitte l'aide comme « Retour ». + await tab.click("#help-button"); + await expectScreen(tab, "aide", (s) => s.help); + await tab.keyboard.press("Escape"); + await expectScreen(tab, "Échap : la partie", playing); + await tab.close(); + } + log("adresses : retour du navigateur (question, onglet, menu, mes parties), Échap, rôle link des liens"); + + // Aller-retour d'un lien de position : le lien copié depuis la vue + // ouverte par ce lien est le même ; « Retour à ma partie » ; « Jouer à + // partir d'ici » commence une partie de plus, l'ancienne restant + // reprenable. + { + const tab = await openTab(positionUrl); + await expectScreen(tab, "lien de position", (s) => s.position && s.positionBoard && s.positionPlay); + await tab.click("#position-copy"); + const again = await lastCopied(tab); + check(again === positionUrl, `aller-retour du lien : ${again} au lieu de ${positionUrl}`); + check(await isLink(tab, "#position-back"), "« Retour à ma partie » n'est pas un lien"); + await tab.click("#position-back"); + await expectScreen(tab, "retour à ma partie (nouvel onglet)", playing); + // Depuis la partie : le lien suivi, puis « Retour à ma partie ». + await tab.evaluate((hash) => { + location.hash = hash; + }, positionHash); + await expectScreen(tab, "lien suivi depuis la partie", (s) => s.position && s.positionBoard); + await tab.click("#position-back"); + await expectScreen(tab, "retour à ma partie", playing); + await tab.goForward(); + await expectScreen(tab, "avance : la position", (s) => s.position && s.positionPlay); + await tab.click("#position-play"); + await expectScreen(tab, "jouer à partir d'ici", (s) => playing(s) && !s.busy); + await tab.goto(new URL("#/parties", base).href); + const after = await expectScreen(tab, "mes parties", (s) => s.games && s.listed > 0); + check(after.listed === gamesBefore + 1, + `jouer à partir d'ici : ${after.listed} parties, ${gamesBefore + 1} attendues`); + await tab.close(); + } + log("adresses : lien de position copié, rouvert à l'identique, retour à la partie, partie jouée depuis la position"); +} + // ------------------------------------------------------------- Menu async function openMenu() { await page.click("#menu-button"); + // Le menu a son adresse (#/menu) : il s'ouvre au changement d'adresse. + await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {}); check(await page.isVisible("#menu"), "le menu ne s'ouvre pas"); } @@ -954,10 +1321,19 @@ for (let actions = 0; ; actions++) { } else { await (await touchTarget("confirm", "Jouer ce coup", 2, confirms % 2)).click(); } - await waitForPlayer(); - const played = await savedJournal(); - check(countMoves(played, "player") === countMoves(journal, "player") + 1, - "le coup joué n'est pas dans le journal"); + const next = await waitForPlayer(); + // L'application enregistre sans attendre ; sur un disque lent, + // l'écriture d'IndexedDB peut suivre de loin : on lui laisse 10 s. + const expected = countMoves(journal, "player") + 1; + let played = await savedJournal(); + for (let i = 0; i < 100 && countMoves(played, "player") < expected; i++) { + await sleep(100); + played = await savedJournal(); + } + check(countMoves(played, "player") === expected, + `le coup joué n'est pas dans le journal : ${countMoves(played, "player")} coups ` + + `enregistrés, ${expected} attendus ; à l'écran : ${next}, ` + + `« ${await page.textContent("#info")} »`); halfMoves = 0; touched = false; } else { @@ -1074,6 +1450,7 @@ for (let actions = 0; ; actions++) { // Le clavier ouvre aussi le menu (M) et Échap le ferme. await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press("m"); + await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {}); check(await page.isVisible("#menu"), "M n'ouvre pas le menu"); await page.keyboard.press("Escape"); const pips = await page.textContent("#strip-player .count"); @@ -1214,7 +1591,7 @@ step("mes parties"); step("aide"); // Aide : scénarios et glossaire lus dans les copies de docs/. await page.click("#help-button"); -await page.waitForSelector(".glossary dt"); +await page.waitForSelector(".glossary dt", { state: "attached" }); const terms = await page.locator(".glossary dt").count(); const scenarios = await page.locator("#help-content h3").count(); check(terms >= 20 && scenarios >= 9, `aide incomplète : ${terms} termes, ${scenarios} scénarios`); @@ -1281,7 +1658,7 @@ if (offline === "roll") { await waitForPlayer(); } await page.click("#help-button"); -await page.waitForSelector(".glossary dt"); +await page.waitForSelector(".glossary dt", { state: "attached" }); await page.click("#help-back"); log( offlineMode @@ -1313,6 +1690,16 @@ check( ); log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native"); +step("adresses"); +// Chaque écran a son adresse (ADR 0013, amendement du 2026-11-03) : ouverte +// directement dans un nouvel onglet, elle montre le bon écran ; le retour du +// navigateur referme d'abord la question ou la feuille ouverte ; les liens +// sont des liens (rôle link) ; un lien de position fait l'aller-retour. +if (offlineMode) { + await context.setOffline(false); +} +await checkAddresses(); + log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` + `portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` + `${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` + diff --git a/web/app.css b/web/app.css index 8b5c697..bebd591 100644 --- a/web/app.css +++ b/web/app.css @@ -363,6 +363,8 @@ button { border: 2px solid var(--accent); border-radius: var(--radius-pill); cursor: pointer; + /* Un bouton tient sur une ligne (charte). */ + white-space: nowrap; /* Pas de délai ni de zoom sur un double toucher. */ touch-action: manipulation; -webkit-tap-highlight-color: transparent; @@ -1165,14 +1167,14 @@ html.landscape .banner { bottom: var(--space-2); } -/* L'aide reste une page qu'on fait défiler. */ -html.landscape.help-open, -html.landscape.help-open body { +/* L'aide et la vue d'une position restent des pages qu'on fait défiler. */ +html.landscape.page-open, +html.landscape.page-open body { height: auto; overflow: visible; } -html.landscape.help-open body { +html.landscape.page-open body { padding: 0 var(--space-4); background: var(--background); } @@ -1306,7 +1308,7 @@ html.rotated .banner { } .about { - margin-top: var(--space-8); + margin-top: var(--space-2); font-size: 15px; line-height: 20px; color: var(--muted); @@ -1316,3 +1318,271 @@ html.rotated .banner { user-select: text; -webkit-user-select: text; } + +/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la + * page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une + * question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */ +.help { + --help-bar-height: 113px; +} + +.help-bar { + position: sticky; + top: env(safe-area-inset-top); + z-index: 3; + height: var(--help-bar-height); + margin-inline: calc(-1 * var(--space-4)); + padding: var(--space-3) var(--space-4) 0; + background: var(--background); +} + +.help .question, +.glossary dt { + scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top)); +} + +/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ; + * sur une ligne, qui défile si l'écran est trop étroit. */ +.help-tabs { + display: flex; + gap: var(--space-1); + overflow-x: auto; + white-space: nowrap; + margin-top: var(--space-2); + border-bottom: 1px solid var(--border); +} + +.help-tabs a { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 var(--space-3); + font-weight: 500; + color: var(--muted); + text-decoration: none; + border-bottom: 3px solid transparent; +} + +.help-tabs a[aria-current="page"] { + font-weight: 700; + color: var(--text); + border-bottom-color: var(--accent); +} + +.help-notice:empty { + display: none; +} + +/* Une question de l'aide : son titre est un lien qui l'ouvre (son adresse), + * ou la referme quand elle est ouverte. */ +.question { + border-bottom: 1px solid var(--border); +} + +.help .question h3 { + margin: 0; +} + +.question h3 a { + display: flex; + gap: var(--space-2); + align-items: center; + min-height: 52px; + padding: var(--space-2) 0; + color: var(--text); + text-decoration: none; +} + +.question h3 a::after { + content: ""; + flex: none; + width: 9px; + height: 9px; + margin: 0 6px 4px auto; + border-right: 2px solid var(--muted); + border-bottom: 2px solid var(--muted); + transform: rotate(45deg); +} + +.question.open h3 a::after { + margin-bottom: -4px; + transform: rotate(-135deg); +} + +.answer { + padding-bottom: var(--space-3); +} + +/* Le terme ouvert par son adresse (#/glossaire/). */ +.glossary dt a { + color: var(--text); + text-decoration: none; +} + +.glossary dt.current, +.glossary dt.current + dd { + margin-inline: calc(-1 * var(--space-4)); + padding-inline: var(--space-4); + background: var(--accent-soft); + /* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */ + animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards; +} + +@keyframes term-fade { + to { background-color: transparent; } +} + +/* -------------------------------------------------- Liens et boutons */ + +/* Un lien qui mène à un écran, avec l'allure d'un bouton. */ +a.button { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; + min-height: var(--button-height); + padding: 0 20px; + font-weight: 700; + color: var(--on-accent); + text-decoration: none; + background: var(--accent); + border: 2px solid var(--accent); + border-radius: var(--radius-pill); + white-space: nowrap; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +a.button.secondary { + color: var(--text); + background: transparent; + border-color: var(--border-strong); +} + +a.button:active { + transform: translateY(1px); + filter: brightness(0.94); +} + +a.icon-button, +a.item, +a.link-button { + text-decoration: none; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +a.link-button { + display: inline-flex; + align-items: center; + text-decoration: underline; +} + +.infoline .copy-link { + color: var(--accent); +} + +/* Zone de texte de la copie de repli (presse-papiers), hors de l'écran. */ +.offscreen { + position: fixed; + top: 0; + left: -10000px; + opacity: 0; +} + +/* ------------------------------------------------ Vue d'une position */ + +.position-view { + max-width: var(--page-width); + margin: 0 auto; + padding-bottom: var(--space-6); +} + +.position-title { + margin: var(--space-6) 0 var(--space-2); + font-size: 24px; + line-height: 30px; +} + +.position-message:empty { + display: none; +} + +.position-board { + margin: var(--space-2) 0; +} + +.position-detail { + margin: var(--space-2) 0 var(--space-4); + font-size: 15px; + line-height: 20px; + color: var(--muted); +} + +/* Les actions empilées sur toute la largeur, la principale en premier. */ +.position-actions { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.position-actions > * { + width: 100%; +} + +#position-copy { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; +} + +#position-copy .icon { + width: 20px; + height: 20px; +} + +/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la + * hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions + * dans une colonne à droite. */ +html.landscape .position-view.with-board { + position: fixed; + inset: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 288px; + grid-template-rows: auto auto auto auto 1fr; + max-width: none; + margin: 0; + padding: 0; + overflow-y: auto; + background: var(--background); +} + +html.landscape .position-view.with-board > * { + grid-column: 2; + margin-inline: 0; + padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right)); +} + +html.landscape .position-view.with-board .position-title { + margin-top: var(--space-4); +} + +html.landscape .position-view.with-board .position-board { + grid-row: 1 / -1; + grid-column: 1; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + aspect-ratio: auto; + background: var(--frame); +} + +html.landscape .position-view.with-board .position-actions { + padding-bottom: var(--space-4); +} + +html.landscape .position-view.with-board .position-actions > * { + padding-inline: 16px; +} diff --git a/web/index.html b/web/index.html index 0005e3c..90d97db 100644 --- a/web/index.html +++ b/web/index.html @@ -29,9 +29,11 @@ Ustagammon Ustagammon - + + - +
@@ -46,7 +48,7 @@
@@ -58,6 +60,7 @@

+

@@ -79,11 +82,35 @@
-