Compare commits

...
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 2b8fa1a0b0 Recette design des adresses : barre de l'aide collée, boutons sur une ligne, position en paysage
Barre haute de l'aide en position sticky ; question ou terme ouvert par
son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s.
Boutons en white-space: nowrap ; actions de la position empilées
(Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la
hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler),
colonne de 288 px à droite. Test navigateur : aucun bouton sur deux
lignes, barre de l'aide en vue, actions visibles sans défiler, videau
entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:52:50 +01:00
Pierre MARTINandClaude Opus 5.5 d1db2b33c8 Adresses des écrans : plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:41:06 +01:00
Pierre MARTIN f74b03fa0d Fusion de main dans adresses-ecrans 2026-11-09 14:41:03 +01:00
Pierre MARTINandClaude Opus 5.5 d26033b7bb Plan 0e.4 : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:40:16 +01:00
Pierre MARTINandClaude Opus 5.5 2f1d36413e Test navigateur : le coup joué a 10 s pour arriver dans IndexedDB
L'application enregistre sans attendre ; sur le disque dur de la machine
de développement, l'écriture de Firefox peut suivre de loin. En cas
d'échec, le message dit ce que montre l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
Pierre MARTINandClaude Opus 5.5 9a5a0bb677 Fin de partie : la vibration attend la fin de l'animation du dernier coup
Le dessin lancé au début de l'animation annonçait la fin ; une prise de
l'ordinateur pendant ce dernier coup vibrait ensuite et la remplaçait (vu
dans Firefox par make test-browser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
Pierre MARTINandClaude Opus 5.5 b40b28d25b Fusion de la MR !39 : plan 0e.5, mise à jour de l'application en un toucher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:46:28 +01:00
Pierre MARTINandClaude Opus 5.5 97a098e98f Navigation : un retour en cours retient l'adresse suivante ; test des adresses
history.go est asynchrone : une adresse demandée pendant un retour attend
son arrivée. Le test dans le navigateur ouvre chaque adresse du tableau de
l'ADR 0013 dans un nouvel onglet, vérifie le retour, le rôle link des liens
et l'aller-retour d'un lien de position, et range ses captures dans
browser-shots/adresses/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:45:17 +01:00
Pierre MARTINandClaude Opus 5.5 f4914b30ab Une adresse pour chaque écran, de vrais liens pour y aller
Routeur pur (fragment vers écran et inversement) et navigation sur
hashchange : aide et ses questions, glossaire et ses termes, à propos,
menu, mes parties et chaque partie, vue d'une position avec « Jouer à
partir d'ici » et « Retour à ma partie », « Copier le lien de la
position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
Pierre MARTINandClaude Opus 5.5 43031d1ec6 Domaine : lire et écrire le lien d'une position (ADR 0013)
Le moteur écrit et décode le Match ID ; readPosition valide un lien,
linkOf donne celui d'une partie, setupOf la partie à jouer depuis la
position. Scénario « Partager une position », fiche « Lien de position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
27 changed files with 2490 additions and 232 deletions
+30
View File
@@ -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<Luck>;
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
* de lui, sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
}
/**
* 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;
}
/**
+126
View File
@@ -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<Record<Side, number>>;
} | 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<SharedPosition> {
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<PositionLink> {
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);
}
+37 -1
View File
@@ -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<MatchDescription> {
// 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,
+10 -2
View File
@@ -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),
+8 -7
View File
@@ -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<Game | null>;
/** 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) {
+419 -65
View File
@@ -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<unknown>;
/** 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<SharedPosition>;
/** Le lien de la position d'une partie. */
readonly linkOf: (game: Game) => Promise<PositionLink>;
}
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element === null) {
@@ -162,21 +182,32 @@ export function startApp(options: AppOptions): void {
again: byId<HTMLButtonElement>("again"),
moveHint: byId("move-hint"),
replay: byId<HTMLButtonElement>("replay"),
helpButton: byId<HTMLButtonElement>("help-button"),
copyLink: byId<HTMLButtonElement>("copy-link"),
helpButton: byId<HTMLAnchorElement>("help-button"),
fullscreenButton: byId<HTMLButtonElement>("fullscreen-button"),
menuButton: byId<HTMLButtonElement>("menu-button"),
helpBack: byId<HTMLButtonElement>("help-back"),
menuButton: byId<HTMLAnchorElement>("menu-button"),
helpBack: byId<HTMLAnchorElement>("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<HTMLAnchorElement>("position-back"),
positionPlay: byId<HTMLButtonElement>("position-play"),
positionCopy: byId<HTMLButtonElement>("position-copy"),
menuVeil: byId("menu-veil"),
menu: byId("menu"),
menuTitle: byId("menu-title"),
menuClose: byId<HTMLButtonElement>("menu-close"),
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
menuGames: byId<HTMLButtonElement>("menu-games"),
menuGames: byId<HTMLAnchorElement>("menu-games"),
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
menuHelp: byId<HTMLButtonElement>("menu-help"),
menuHelp: byId<HTMLAnchorElement>("menu-help"),
menuTheme: byId<HTMLButtonElement>("menu-theme"),
menuVibrations: byId<HTMLButtonElement>("menu-vibrations"),
menuScreenOn: byId<HTMLButtonElement>("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<typeof icon>[0]) => {
ui.positionBoard.setAttribute("aria-label", TEXTS.boardLabel);
const label = (button: HTMLElement, text: string, name?: Parameters<typeof icon>[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<HTMLElement>("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<HTMLAnchorElement>(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<Route, { screen: "position" }>): Promise<void> {
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<void> {
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<HTMLButtonElement>(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<boolean> {
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;
+20 -1
View File
@@ -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);
}
};
+63 -34
View File
@@ -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/<id>, 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<void>;
readonly remove: (id: string) => Promise<void>;
/** 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<void>;
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<void>;
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/<id>).
let expanded: string | null = null;
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => 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<void> {
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<void> {
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
const id = item.dataset.id ?? "";
const open = id === expanded;
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
link?.setAttribute("aria-expanded", String(open));
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
const branchList = item.querySelector<HTMLElement>(".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;
},
+184 -48
View File
@@ -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<string> {
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
/** Une fiche du glossaire et son nom de fichier, sans .md. */
interface GlossaryCard extends GlossaryEntry {
readonly name: string;
}
async function loadGlossary(): Promise<GlossaryCard[]> {
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<K extends keyof HTMLElementTagNameMap>(
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<void>;
}
/**
* 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<string, HTMLElement>();
let loading: Promise<boolean> | 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<void> {
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<boolean> {
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<HTMLAnchorElement>("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);
}
},
};
}
+166
View File
@@ -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 (<a href="#/aide">) ; 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<TrailState> | 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<typeof setTimeout> | 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);
});
}
+191
View File
@@ -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/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
| { readonly screen: "menu" }
/** #/position/<Position ID>/<Match ID> (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<Route, { screen: "help" | "glossary" | "about" }>;
/** 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;
}
+50 -1
View File
@@ -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<Record<"player" | "computer", number>>,
cube: { value: number; owner: "player" | "computer" | null },
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | 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;
+10
View File
@@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial<Engine> = {}): 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,
};
}
+99
View File
@@ -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 });
});
+138
View File
@@ -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);
});
@@ -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<void> {
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}`);
});
-1
View File
@@ -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
+1
View File
@@ -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 |
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+15
View File
@@ -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)
@@ -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 `<a href>` ; le bouton
retour du navigateur ramène à l'écran précédent. Les actions de jeu restent
des boutons.
1. Lecture de `#/position/<Position ID>/<Match ID>` ; 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.
+1
View File
@@ -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 |
@@ -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
+395 -8
View File
@@ -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 ` +
+275 -5
View File
@@ -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/<terme>). */
.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;
}
+35 -7
View File
@@ -29,9 +29,11 @@
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
</h1>
<button id="help-button" type="button" class="icon-button labelled"></button>
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
amendement) ; les actions sont des boutons. -->
<a id="help-button" href="#/aide" class="icon-button labelled"></a>
<button id="fullscreen-button" type="button" class="icon-button labelled"></button>
<button id="menu-button" type="button" class="icon-button labelled"></button>
<a id="menu-button" href="#/menu" class="icon-button labelled"></a>
</header>
<main id="game" class="game">
@@ -46,7 +48,7 @@
<div id="pip-bubble" class="bubble" role="dialog" aria-labelledby="pip-bubble-term" hidden>
<p id="pip-bubble-term" class="bubble-term"></p>
<p class="bubble-text"></p>
<button type="button" class="link-button"></button>
<a class="link-button" href="#/glossaire"></a>
</div>
</div>
<div id="strip-player" class="strip">
@@ -58,6 +60,7 @@
<div class="infoline">
<p id="info" class="info" aria-live="polite"></p>
<button id="replay" type="button" class="icon-button replay" hidden></button>
<button id="copy-link" type="button" class="icon-button copy-link" hidden></button>
</div>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="spacer"></div>
@@ -79,11 +82,35 @@
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
<section id="help" class="help" hidden>
<button id="help-back" type="button" class="secondary"></button>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
<div id="help-bar" class="help-bar">
<a id="help-back" href="#/" class="button secondary"></a>
<nav id="help-tabs" class="help-tabs">
<a href="#/aide" data-screen="help"></a>
<a href="#/glossaire" data-screen="glossary"></a>
<a href="#/a-propos" data-screen="about"></a>
</nav>
</div>
<div id="help-content"></div>
</section>
<!-- Une position partagée (#/position/<Position ID>/<Match ID>), non
jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
sur toute la hauteur, le reste dans une colonne à droite. -->
<section id="position-view" class="position-view page" aria-labelledby="position-title" hidden>
<h2 id="position-title" class="position-title"></h2>
<p id="position-message" class="position-message" role="status"></p>
<svg id="position-board" class="board position-board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="position-detail" class="position-detail"></p>
<div class="position-actions">
<button id="position-play" type="button"></button>
<button id="position-copy" type="button" class="secondary"></button>
<a id="position-back" href="#/" class="button secondary"></a>
</div>
</section>
<!-- Menu : une feuille qui monte du bas. -->
<div id="menu-veil" class="veil sheet-veil" hidden>
<div id="menu" class="sheet" role="dialog" aria-modal="true" aria-labelledby="menu-title">
@@ -93,9 +120,10 @@
<button id="menu-close" type="button" class="icon-button"></button>
</div>
<button id="menu-new-game" type="button" class="item"></button>
<button id="menu-games" type="button" class="item"></button>
<a id="menu-games" href="#/parties" class="item"></a>
<button id="menu-copy-link" type="button" class="item"></button>
<button id="menu-fullscreen" type="button" class="item"></button>
<button id="menu-help" type="button" class="item"></button>
<a id="menu-help" href="#/aide" class="item"></a>
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>