Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre
# Conflicts: # app/src/ui/board-view.ts # tests/browser.sh
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
type NewGameEvent,
|
||||
withAdvice,
|
||||
} from "./events.js";
|
||||
import { type Match, nextGameOf } from "./match.js";
|
||||
import { firstGameOf, type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
@@ -168,6 +168,17 @@ export class Game {
|
||||
return match === null ? null : { level: state.level, match };
|
||||
}
|
||||
|
||||
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
|
||||
* au même niveau ; pour un match, un nouveau match de même longueur,
|
||||
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
|
||||
* la position de départ. */
|
||||
get rematch(): NewGame {
|
||||
const state = this.state;
|
||||
return state.match === null
|
||||
? { level: state.level }
|
||||
: { level: state.level, match: firstGameOf(state.match.length) };
|
||||
}
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
|
||||
+28
-11
@@ -265,6 +265,7 @@ export function startApp(options: AppOptions): void {
|
||||
panelVeil: byId("panel-veil"),
|
||||
panelText: byId("panel-text"),
|
||||
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
|
||||
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
|
||||
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
|
||||
banner: byId("banner"),
|
||||
bannerText: byId("banner-text"),
|
||||
@@ -338,7 +339,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.again.textContent = TEXTS.newGame;
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -588,7 +589,11 @@ export function startApp(options: AppOptions): void {
|
||||
diceAction: action,
|
||||
canDouble: !busy && canDouble,
|
||||
band:
|
||||
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
|
||||
landscape && !panelShown
|
||||
? text === null
|
||||
? TEXTS.gameOver
|
||||
: bandText(text.short, state.match?.crawford === true)
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
});
|
||||
@@ -848,6 +853,7 @@ export function startApp(options: AppOptions): void {
|
||||
detail.textContent = end.detail;
|
||||
ui.panelSecondary.textContent = TEXTS.reviewGame;
|
||||
ui.panelPrimary.textContent = afterGameLabel(g);
|
||||
ui.panelSettings.hidden = false;
|
||||
ui.panelVeil.dataset.kind = "over";
|
||||
} else if (ask) {
|
||||
overline.textContent = "";
|
||||
@@ -855,6 +861,7 @@ export function startApp(options: AppOptions): void {
|
||||
detail.textContent = "";
|
||||
ui.panelSecondary.textContent = TEXTS.drop;
|
||||
ui.panelPrimary.textContent = TEXTS.take;
|
||||
ui.panelSettings.hidden = true;
|
||||
ui.panelVeil.dataset.kind = "doubled";
|
||||
}
|
||||
ui.panelPrimary.disabled = busy;
|
||||
@@ -1146,14 +1153,11 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
|
||||
/** Après une partie finie : la partie suivante du match s'il continue,
|
||||
* sinon l'écran « Nouvelle partie ». */
|
||||
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
|
||||
* même longueur après un match), tout de suite. */
|
||||
function afterGame(): void {
|
||||
const next = game?.nextGame ?? null;
|
||||
if (next !== null) {
|
||||
newGame(next);
|
||||
} else {
|
||||
closeOverlays();
|
||||
navigation.go(routeHash({ screen: "newGame" }));
|
||||
if (game !== null) {
|
||||
newGame(game.nextGame ?? game.rematch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1444,6 +1448,13 @@ export function startApp(options: AppOptions): void {
|
||||
afterGame();
|
||||
}
|
||||
};
|
||||
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
|
||||
// le panneau se ferme, le résultat reste sur l'écran de jeu.
|
||||
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
|
||||
ui.panelSettings.addEventListener("click", () => {
|
||||
panelDismissed = true;
|
||||
render();
|
||||
});
|
||||
ui.panelSecondary.onclick = () => {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.drop();
|
||||
@@ -2313,10 +2324,16 @@ function matchScore(state: GameState): MatchScore | null {
|
||||
return { length: state.match.length, computer: score.computer, player: score.player };
|
||||
}
|
||||
|
||||
/** Paysage : la consigne de la bande centrale ; pendant la partie
|
||||
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
|
||||
function bandText(prompt: string, crawford: boolean): string {
|
||||
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
|
||||
}
|
||||
|
||||
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
|
||||
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
|
||||
* qui continue, sinon « Rejouer » (mêmes réglages). */
|
||||
function afterGameLabel(game: Game): string {
|
||||
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
|
||||
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
|
||||
|
||||
@@ -523,6 +523,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
|
||||
const width = 6 * g.pointWidth - 24 - before;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12 + before;
|
||||
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
|
||||
// la police se resserre, sans descendre sous 13 px (charte). Largeur
|
||||
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
|
||||
// 0,45 em.
|
||||
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
@@ -538,8 +543,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
y: middle + fontSize * 0.35,
|
||||
"font-size": fontSize,
|
||||
},
|
||||
text,
|
||||
);
|
||||
|
||||
@@ -64,6 +64,8 @@ export const MATCH_TEXTS = {
|
||||
/** « Match en 5 points », et « · partie Crawford ». */
|
||||
matchLine: (length: number, crawford: boolean) =>
|
||||
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
|
||||
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
|
||||
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
@@ -72,7 +74,11 @@ export const MATCH_TEXTS = {
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie de match
|
||||
// Fin d'une partie
|
||||
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
|
||||
* après un match). */
|
||||
replay: "Rejouer",
|
||||
changeSettings: "Changer les réglages",
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
|
||||
@@ -87,10 +87,14 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
closeLink.append(icon("close"));
|
||||
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
|
||||
|
||||
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
|
||||
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
const kindColumn = element(body, "div", { className: "new-game-column" });
|
||||
const levelColumn = element(body, "div", { className: "new-game-column" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(body, "div", { className: "segmented" });
|
||||
const kinds = element(kindColumn, "div", { className: "segmented" });
|
||||
kinds.setAttribute("role", "radiogroup");
|
||||
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
|
||||
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
|
||||
@@ -98,7 +102,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(body, "div", {
|
||||
const matchOptions = element(kindColumn, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
@@ -119,7 +123,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(body, "div", {
|
||||
const freeOptions = element(kindColumn, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
@@ -132,8 +136,8 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
);
|
||||
|
||||
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(body, "div", { className: "level-bars" });
|
||||
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(levelColumn, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
@@ -144,12 +148,21 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
button.addEventListener("click", () => options.change({ level }));
|
||||
return button;
|
||||
});
|
||||
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
|
||||
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
|
||||
const levelName = element(levelColumn, "p", {
|
||||
id: "new-game-level-name",
|
||||
className: "level-name",
|
||||
});
|
||||
const levelText = element(levelColumn, "p", {
|
||||
id: "new-game-level-text",
|
||||
className: "level-text",
|
||||
});
|
||||
|
||||
// 3. Pendant la partie.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
|
||||
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
|
||||
element(levelColumn, "h3", {
|
||||
className: "overline during-game",
|
||||
text: MATCH_TEXTS.duringGame,
|
||||
});
|
||||
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
|
||||
@@ -506,6 +506,12 @@ test("une partie de match finie : le moteur dit le score, la fin du match et la
|
||||
crawford: false,
|
||||
});
|
||||
assert.equal(crawford.nextGame, null);
|
||||
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
|
||||
// 0, au même niveau.
|
||||
assert.deepEqual(crawford.rematch, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("passer un double dans un match : le moteur compte le match", async () => {
|
||||
@@ -538,6 +544,11 @@ test("une partie libre n'a ni match ni partie suivante", () => {
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
|
||||
assert.deepEqual(game.rematch, { level: "0ply" });
|
||||
});
|
||||
|
||||
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
|
||||
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
|
||||
// sans level ni match, un coup joué, une branche.
|
||||
|
||||
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
|
||||
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
@@ -17,6 +18,7 @@ import { createGameLibrary } 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 { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
@@ -167,6 +169,63 @@ Then("la partie Crawford s'est jouée sans videau", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Rejouer
|
||||
|
||||
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
|
||||
function engineLevelNamed(name: string) {
|
||||
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
|
||||
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
|
||||
return ENGINE_LEVELS[level];
|
||||
}
|
||||
|
||||
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.phase, "over");
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
Given(
|
||||
"un match en {int} points contre un ordinateur {word} vient de se terminer",
|
||||
async (n: number, name: string) => {
|
||||
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
|
||||
// fini.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.matchAfter?.over, true);
|
||||
assert.equal(game.nextGame, null);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous rejouez", () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
|
||||
});
|
||||
|
||||
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
});
|
||||
|
||||
Then(
|
||||
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
|
||||
(n: number, name: string) => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.deepEqual(game.state.match, firstGameOf(n));
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- La partie Crawford
|
||||
|
||||
let crawfordGame: Game;
|
||||
|
||||
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
|
||||
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
|
||||
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
|
||||
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
|
||||
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
|
||||
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
|
||||
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
|
||||
|
||||
## Comportement
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Choisir sa partie et jouer un match
|
||||
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin
|
||||
d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
|
||||
@@ -25,7 +25,8 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
|
||||
valeur du videau), et le premier qui atteint le nombre de points du
|
||||
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
|
||||
suivante, avec le score du match.
|
||||
suivante, avec le score du match. Le match fini, « Rejouer » en commence
|
||||
un autre de même longueur.
|
||||
|
||||
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
|
||||
Quand le match se joue jusqu'au bout, partie après partie
|
||||
@@ -33,6 +34,20 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
Et chaque partie commence avec le score laissé par la précédente
|
||||
Et la partie Crawford s'est jouée sans videau
|
||||
|
||||
Scénario: Comment rejouer avec les mêmes réglages ?
|
||||
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
|
||||
avec les mêmes réglages : le même niveau, et après un match, un nouveau
|
||||
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
|
||||
« Partie suivante » qui le continue. Pour changer de niveau ou de type
|
||||
de partie, touchez « Changer les réglages », sous les boutons.
|
||||
|
||||
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors une partie libre commence, contre un ordinateur Expert
|
||||
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
|
||||
|
||||
Scénario: Qu'est-ce que la partie Crawford ?
|
||||
Dans un match, quand un joueur arrive à un point de la victoire, la
|
||||
partie suivante se joue sans videau : personne ne peut doubler. C'est la
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/maitre.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
|
||||
+92
-12
@@ -13,10 +13,13 @@
|
||||
// joueur joue toujours le premier pion jouable vers sa première case
|
||||
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
|
||||
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
|
||||
// partie », score du match et partie Crawford, en 412×915 et 915×412.
|
||||
// partie » (aussi en 844×320), score du match et partie Crawford, en
|
||||
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
|
||||
// caché sous la barre d'action collée (recette.mjs).
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -29,6 +32,7 @@ const dir = `${shots}/match`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const SMALL_LANDSCAPE = { width: 844, height: 320 };
|
||||
// La graine des dés : un match qui passe par une partie Crawford.
|
||||
const SEED = 7;
|
||||
|
||||
@@ -83,14 +87,18 @@ async function until(what, fn, arg, ms = 60_000) {
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
/** Une capture de la recette, dans chaque taille (Chromium seulement),
|
||||
* après la règle commune : rien d'interactif sous la barre d'action. */
|
||||
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
for (const size of sizes) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
@@ -189,7 +197,45 @@ check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire
|
||||
"lien vers la règle de Crawford");
|
||||
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
|
||||
"description du niveau Débutant");
|
||||
await capture("1-nouvelle-partie");
|
||||
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
|
||||
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
|
||||
// conseil du maître à droite.
|
||||
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const where = `${size.width}x${size.height}`;
|
||||
const layout = await page.evaluate(() => {
|
||||
const box = (selector) => {
|
||||
const r = document.querySelector(selector).getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
|
||||
};
|
||||
return {
|
||||
scroll: document.scrollingElement.scrollHeight,
|
||||
height: innerHeight,
|
||||
bar: box(".new-game-actions"),
|
||||
kinds: box(".segmented"),
|
||||
lengths: box(".lengths"),
|
||||
rule: box("#new-game-crawford"),
|
||||
bars: box(".level-bars"),
|
||||
name: box("#new-game-level-name"),
|
||||
text: box("#new-game-level-text"),
|
||||
advice: box("#new-game-advice"),
|
||||
};
|
||||
});
|
||||
check(layout.scroll <= layout.height + 1,
|
||||
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
|
||||
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
|
||||
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
|
||||
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
for (const part of ["bars", "name", "text", "advice"]) {
|
||||
check(layout[part].left >= layout.kinds.right,
|
||||
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
|
||||
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
|
||||
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
|
||||
await page.click("#new-game-close");
|
||||
@@ -240,7 +286,13 @@ for (;;) {
|
||||
const detail = await page.textContent("#panel-detail");
|
||||
const primary = await page.textContent("#panel-primary");
|
||||
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
|
||||
if (primary === "Nouvelle partie") {
|
||||
const settingsLink = await page.evaluate(() => {
|
||||
const a = document.getElementById("panel-settings");
|
||||
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
|
||||
});
|
||||
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
|
||||
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
|
||||
if (primary === "Rejouer") {
|
||||
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
|
||||
`fin du match : ${title} ; ${detail}`);
|
||||
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
|
||||
@@ -254,20 +306,30 @@ for (;;) {
|
||||
`score en paysage : « ${box} »`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
// Après le match, « Nouvelle partie » mène à l'écran des choix.
|
||||
await page.click("#panel-primary");
|
||||
// Après le match, « Changer les réglages » mène à l'écran des
|
||||
// choix ; le fermer ramène au résultat, où « Rejouer » relance
|
||||
// tout de suite un match de même longueur.
|
||||
await page.click("#panel-settings");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie");
|
||||
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
|
||||
await page.click("#new-game-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
|
||||
`« Rejouer » après le match : ${JSON.stringify(again)}`);
|
||||
if (crawfordGames > 0 && scoreShot) {
|
||||
break;
|
||||
}
|
||||
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
|
||||
matches++;
|
||||
games = 1;
|
||||
await page.click("#new-game-start");
|
||||
await until("match suivant", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
continue;
|
||||
}
|
||||
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
|
||||
@@ -281,6 +343,24 @@ for (;;) {
|
||||
check(next.cube === 0, "partie Crawford : le videau est dessiné");
|
||||
if (!crawfordShot) {
|
||||
crawfordShot = true;
|
||||
check(next.line === "Match en 3 points · partie Crawford",
|
||||
`partie Crawford, portrait : « ${next.line} »`);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const band = await page.evaluate(() => {
|
||||
const text = document.getElementById("band-text");
|
||||
const pill = document.querySelector("#board .band-pill");
|
||||
return {
|
||||
text: text?.textContent ?? "",
|
||||
fits: text !== null && pill !== null &&
|
||||
text.getBBox().width <= pill.getBBox().width - 16,
|
||||
};
|
||||
});
|
||||
check(band.text.startsWith("Partie Crawford · "),
|
||||
`partie Crawford, paysage : « ${band.text} »`);
|
||||
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("3-partie-crawford");
|
||||
}
|
||||
} else if (!scoreShot) {
|
||||
|
||||
+25
-10
@@ -21,6 +21,7 @@
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -343,6 +344,8 @@ 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 hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -809,6 +812,8 @@ async function checkAddresses() {
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(tab);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.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.
|
||||
@@ -996,6 +1001,15 @@ async function startChosenGame() {
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
|
||||
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
|
||||
* partie ». */
|
||||
async function rematch() {
|
||||
await page.click("#again");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Rejouer » ouvre un autre écran que la partie");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
|
||||
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
@@ -1525,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
|
||||
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
|
||||
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
|
||||
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
|
||||
(await page.textContent("#panel-primary")) === "Nouvelle partie",
|
||||
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
|
||||
(await page.textContent("#panel-primary")) === "Rejouer" &&
|
||||
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
|
||||
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
|
||||
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
|
||||
await review("6-fin-de-partie");
|
||||
await page.setViewportSize({ width: 844, height: 320 });
|
||||
await sleep(150);
|
||||
@@ -1557,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
|
||||
log(`vibrations demandées : ${(await vibrations()).length}`);
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
|
||||
// barre d'actions.
|
||||
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
|
||||
// d'actions.
|
||||
await page.click("#panel-secondary");
|
||||
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
step("mes parties");
|
||||
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
|
||||
@@ -1569,8 +1586,7 @@ step("mes parties");
|
||||
// recharger la page.
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1651,8 +1667,7 @@ log("compte de points : bulle avec la fiche du glossaire");
|
||||
|
||||
step("thème sombre");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// recette.mjs -- règles de la recette design communes aux tests dans un
|
||||
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
|
||||
// capturé.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Les éléments interactifs visibles cachés, même en partie, sous une barre
|
||||
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
|
||||
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
|
||||
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
|
||||
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
|
||||
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
|
||||
* vraiment, au point du recouvrement.
|
||||
*
|
||||
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
|
||||
*/
|
||||
export async function hiddenUnderActionBar(target) {
|
||||
return target.evaluate(() => {
|
||||
const shown = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const bars = [...document.querySelectorAll("body *")].filter((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
|
||||
});
|
||||
const describe = (el) =>
|
||||
el.id
|
||||
? `#${el.id}`
|
||||
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
|
||||
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
|
||||
} »`;
|
||||
const hidden = [];
|
||||
const interactive = document.querySelectorAll(
|
||||
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
for (const bar of bars) {
|
||||
const b = bar.getBoundingClientRect();
|
||||
for (const el of interactive) {
|
||||
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top >= innerHeight) {
|
||||
continue;
|
||||
}
|
||||
const top = Math.max(r.top, b.top);
|
||||
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
|
||||
const left = Math.max(r.left, b.left);
|
||||
const right = Math.min(r.right, b.right);
|
||||
if (bottom - top <= 1 || right - left <= 1) {
|
||||
continue;
|
||||
}
|
||||
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
if (hit !== null && bar.contains(hit)) {
|
||||
hidden.push(
|
||||
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
});
|
||||
}
|
||||
+122
@@ -835,6 +835,17 @@ button:disabled {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Fin de partie : « Changer les réglages », lien secondaire sous les
|
||||
* boutons. */
|
||||
.panel-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
|
||||
* barre de temps. */
|
||||
.banner {
|
||||
@@ -2339,6 +2350,14 @@ html.landscape .welcome {
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
|
||||
.new-game-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-game-body .overline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
@@ -2505,6 +2524,109 @@ html.landscape .welcome {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* En paysage (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
|
||||
* deux colonnes au-dessus de la barre d'action, le type de partie à
|
||||
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
|
||||
* et la barre d'action tient sur une ligne, le bouton à droite sous le
|
||||
* pouce. */
|
||||
html.landscape .new-game {
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head h2 {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
align-content: start;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .overline {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .during-game {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 48px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 10px; }
|
||||
html.landscape .level-2 .level-bar { height: 17px; }
|
||||
html.landscape .level-3 .level-bar { height: 24px; }
|
||||
html.landscape .level-4 .level-bar { height: 30px; }
|
||||
html.landscape .level-5 .level-bar { height: 36px; }
|
||||
html.landscape .level-6 .level-bar { height: 42px; }
|
||||
|
||||
html.landscape .level-name {
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .level-text {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item-label {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item small {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-actions {
|
||||
flex-direction: row;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-kept {
|
||||
order: -1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
|
||||
@media (min-height: 380px) {
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 14px; }
|
||||
html.landscape .level-2 .level-bar { height: 22px; }
|
||||
html.landscape .level-3 .level-bar { height: 30px; }
|
||||
html.landscape .level-4 .level-bar { height: 39px; }
|
||||
html.landscape .level-5 .level-bar { height: 48px; }
|
||||
html.landscape .level-6 .level-bar { height: 58px; }
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
<button id="panel-secondary" type="button" class="secondary"></button>
|
||||
<button id="panel-primary" type="button"></button>
|
||||
</div>
|
||||
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user