Fusion d'origin/main (MR !44) ; 1c faite, plan clos

Conflits : l'aide garde l'approche de main (défilement sur la carte du
terme) ; styles des deux côtés conservés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 00:14:58 +01:00
co-authored by Claude Opus 5.5
17 changed files with 378 additions and 190 deletions
+8 -8
View File
@@ -75,7 +75,7 @@ import {
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
import { type Updates, updateBannerShown } from "./updates.js";
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
* (interaction.md : on suit le coup sans l'attendre). */
@@ -766,18 +766,17 @@ export function startApp(options: AppOptions): void {
ui.helpButton.href = helpLink;
ui.menuHelp.href = helpLink;
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
const moveInProgress =
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
// Le bandeau de mise à jour attend la fin du tour, même hors de
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
const quiet =
!busy &&
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
if (updateMessage !== null) {
ui.updateBanner.hidden = false;
ui.updateText.textContent = updateMessage;
ui.updateApply.hidden = true;
} else {
const shown = updateBannerShown(
updates?.available ?? false,
moveInProgress,
!ui.banner.hidden,
);
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
ui.updateBanner.hidden = !shown;
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
ui.updateApply.hidden = false;
@@ -1542,6 +1541,7 @@ export function startApp(options: AppOptions): void {
},
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
replace: (hash) => navigation.replace(hash),
});
+1
View File
@@ -177,6 +177,7 @@ export function renderBoard(
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
pieces.setAttribute("class", "pieces");
for (const point of board.points) {
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
+1 -1
View File
@@ -325,7 +325,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
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)?.dt);
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
+5 -5
View File
@@ -68,7 +68,7 @@ export const HOME_TEXTS = {
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version disponible.",
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
@@ -80,8 +80,8 @@ export const HOME_TEXTS = {
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Passer",
tutorialSkipLabel: "Passer le tutoriel",
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
@@ -90,8 +90,8 @@ export const HOME_TEXTS = {
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Je prends",
drop: "Je passe",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
+38 -14
View File
@@ -95,6 +95,8 @@ export interface TutorialViewOptions {
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
@@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */
function drawPath(svg: SVGSVGElement, g: Geometry): void {
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
@@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const width = g.pointWidth * 0.22;
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", width * 2.4],
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
@@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = width * 2.2;
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(width * 1.2));
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les zones à toucher, toujours dessinées en dernier.
svg.insertBefore(group, svg.lastChild);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
}
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
@@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(section, "p", {
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
@@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const width = svg.clientWidth || Math.min(window.innerWidth, 640);
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
@@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g);
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
@@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
@@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
+20 -9
View File
@@ -1,8 +1,9 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle
// version disponible » ; « Mettre à jour » active la nouvelle version et
// recharge la page, qui reprend la partie enregistrée.
// l'accord du joueur. L'application affiche alors, entre deux tours, le
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
// active la nouvelle version et recharge la page, qui reprend la partie
// enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -25,17 +26,27 @@ export interface Updates {
export const SKIP_WAITING = "skip-waiting";
/**
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
* l'ancienne ». Fonction pure, testée sous Node.
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
* pure, testée sous Node.
*/
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
}
/**
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
moveInProgress: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && !moveInProgress && !otherBanner;
return available && betweenTwoTurns && !otherBanner;
}
/**