diff --git a/Makefile b/Makefile index 2252bc3..cabeeec 100644 --- a/Makefile +++ b/Makefile @@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone # docs/identite/logo/ (une seule source, ADR 0010) ; # - l'application compilée (build/web/src/) sous app/ ; # - le moteur sous wasm/ ; -# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de -# l'application (ADR 0008 : jamais commitées ailleurs) ; +# - les copies du glossaire, des scénarios et du tutoriel sous docs/, lues par +# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées +# ailleurs) ; # - version.json (commit publié) ; # - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh). site: engine-wasm web ## Assemble le site publié dans build/site/ @@ -140,7 +141,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/ cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \ $(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/ - cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/ + cp -r docs/glossaire docs/scenarios docs/tutoriel $(BUILD_DIR)/site/docs/ printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \ "$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json scripts/service-worker.sh $(BUILD_DIR)/site diff --git a/app/src/domain/learning/tutorial.ts b/app/src/domain/learning/tutorial.ts index 2a4b592..fb477aa 100644 --- a/app/src/domain/learning/tutorial.ts +++ b/app/src/domain/learning/tutorial.ts @@ -51,9 +51,10 @@ export async function tutorialBoard( board.result === null ? null : { - winner: (board.result.winner === "onRoll") === (onRoll === "player") - ? ("player" as const) - : ("computer" as const), + winner: + (board.result.winner === "onRoll") === (onRoll === "player") + ? ("player" as const) + : ("computer" as const), kind: board.result.kind, }; return { sides, result }; @@ -126,10 +127,7 @@ export async function illustratedMove( * d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa * position (verdict de MovePractice). */ -export function canStillLeadTo( - played: readonly HalfMove[], - expected: readonly Move[], -): boolean { +export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean { return expected.some((move) => { const left = [...move.halfMoves]; return played.every((h) => { diff --git a/app/src/main.ts b/app/src/main.ts index 64cf9f8..321ad31 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -8,12 +8,19 @@ import { Game } from "./domain/game/game.js"; import { linkOf, readPosition } from "./domain/game/position-link.js"; import { exportSgf } from "./domain/game/sgf.js"; +import { + illustratedMove, + MovePractice, + shouldTake, + tutorialBoard, +} from "./domain/learning/tutorial.js"; import { createWasmEngine } from "./engine/wasm-engine.js"; import { startEngineWorker } from "./engine/worker-run.js"; import { createGameSession } from "./storage/game-session.js"; import { memoryStorage, openGameLibrary } from "./storage/open-library.js"; import { createSettingsStore } from "./storage/settings-store.js"; import { startApp } from "./ui/app.js"; +import { startUpdates } from "./ui/updates.js"; // Ce fichier est servi sous app/ ; le Worker et le service worker sont à la // racine du site. @@ -96,20 +103,27 @@ startApp({ saveBoard: (board) => settings.saveBoard(board), loadSettings: () => settings.loadSettings(), saveSettings: (json) => settings.saveSettings(json), + // Le tutoriel : positions, coups illustrés et gestes jugés par le + // domaine, sur le moteur. + tutorial: { + board: (position, onRoll) => tutorialBoard(engine, position, onRoll), + illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move), + practice: (position, roll, expected) => + MovePractice.start(engine, position, roll, expected), + shouldTake: (position) => shouldTake(engine, position), + }, + // Hors ligne : le service worker met tout le site en cache. Le navigateur + // le refuse hors https (sauf localhost) : l'application marche alors sans. + // Il est enregistré une fois le moteur chargé : si le Worker et le service + // worker téléchargent les données du moteur (8,7 Mo) en même temps, + // Chromium fait échouer l'installation (« Cache.add() encountered a + // network error »), et l'application n'est pas disponible hors ligne. + // Cela évite aussi de télécharger deux fois ces données à la première + // visite. + startUpdates: (onAvailable) => + startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable), }); -// Hors ligne : le service worker met tout le site en cache. Le navigateur le -// refuse hors https (sauf localhost) : l'application marche alors sans. -// Il est enregistré une fois le moteur chargé : si le Worker et le service -// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium -// fait échouer l'installation (« Cache.add() encountered a network error »), -// et l'application n'est pas disponible hors ligne. Cela évite aussi de -// télécharger deux fois ces données à la première visite. -if ("serviceWorker" in navigator) { - const sw = new URL("../sw.js", import.meta.url); - worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {}); -} - /** La valeur, ou undefined si l'obtenir lève une exception. */ function attempt(get: () => T): T | undefined { try { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index b71bf6f..59f45a6 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -52,11 +52,19 @@ 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 { createHelpView, loadGlossaryEntry } from "./help.js"; +import { type HelpMoment, helpHash } from "./help-context.js"; +import { dismissInstall, homeModel, installMenuShown, launchHash, welcomed } from "./home-model.js"; +import { HOME_TEXTS } from "./home-texts.js"; +import { createHomeView } from "./home-view.js"; import { icon } from "./icons.js"; +import { installPlatform, startInstaller } from "./install.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 { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js"; 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"; /** Glissement d'un pion de l'ordinateur, et pause entre deux pions * (interaction.md : on suit le coup sans l'attendre). */ @@ -95,6 +103,11 @@ export interface AppOptions { /** Les réglages, en JSON (null : jamais enregistrés). */ readonly loadSettings: () => string | null; readonly saveSettings: (json: string) => void; + /** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */ + readonly tutorial: TutorialAccess; + /** Les mises à jour de l'application (service worker) : onAvailable est + * appelé quand une nouvelle version attend. */ + readonly startUpdates: (onAvailable: () => void) => Updates; } /** Ce que « Mes parties » demande au stockage. */ @@ -222,6 +235,21 @@ export function startApp(options: AppOptions): void { banner: byId("banner"), bannerText: byId("banner-text"), resumeOld: byId("resume-old"), + home: byId("home"), + tutorial: byId("tutorial"), + menuHome: byId("menu-home"), + menuTutorial: byId("menu-tutorial"), + menuInstall: byId("menu-install"), + menuUpdate: byId("menu-update"), + installVeil: byId("install-veil"), + installTitle: byId("install-title"), + installClose: byId("install-close"), + installLead: byId("install-lead"), + installSteps: byId("install-steps"), + installDone: byId("install-done"), + updateBanner: byId("update-banner"), + updateText: byId("update-text"), + updateApply: byId("update-apply"), }; // ------------------------------------------------------ Réglages @@ -304,6 +332,28 @@ export function startApp(options: AppOptions): void { child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn; ui.bannerText.textContent = TEXTS.newGameStarted; ui.resumeOld.textContent = TEXTS.resumeOld; + label(ui.menuHome, HOME_TEXTS.homeLink, "home"); + label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules"); + label(ui.menuInstall, HOME_TEXTS.installMenu, "install"); + label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay"); + ui.installTitle.textContent = HOME_TEXTS.installTitle; + ui.installClose.append(icon("close")); + ui.installClose.setAttribute("aria-label", TEXTS.close); + ui.installLead.textContent = HOME_TEXTS.installLead; + ui.installDone.textContent = HOME_TEXTS.installUnderstood; + ui.updateApply.textContent = HOME_TEXTS.updateApply; + for (const [i, text] of HOME_TEXTS.installSteps[ + installPlatform(navigator.userAgent) + ].entries()) { + const item = document.createElement("li"); + const number = document.createElement("span"); + number.className = "step-number"; + number.textContent = String(i + 1); + const words = document.createElement("span"); + words.textContent = text; + item.append(number, words); + ui.installSteps.append(item); + } const glossaryLink = child(ui.bubble, ".link-button"); glossaryLink.textContent = TEXTS.seeGlossary; glossaryLink.href = routeHash({ @@ -352,6 +402,23 @@ export function startApp(options: AppOptions): void { // La position partagée affichée (#/position/...), une fois lue. let shownPosition: SharedPosition | null = null; let positionRequest = 0; + // L'installation (invite du navigateur, application installée) et les + // mises à jour (service worker). + const installer = startInstaller((installedNow) => { + if (installedNow) { + saveSettings({ installed: true }); + } else { + render(); + } + }); + let updates: Updates | null = null; + // Un message éphémère dans le bandeau de mise à jour (« Vous avez la + // dernière version. »), sans bouton. + let updateMessage: string | null = null; + let updateMessageTimer: ReturnType | null = null; + // Les parties jouées et gagnées, lues dans « Mes parties » pour + // l'accueil. + let homeProgress: { played: number; won: number } | null = null; const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)"); const portraitQuery = window.matchMedia("(orientation: portrait)"); @@ -515,6 +582,7 @@ export function startApp(options: AppOptions): void { ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; renderPanel(game, landscape, doubled); renderMenu(); + renderChrome(); ui.resumeOld.disabled = busy; // Pour le style et pour les tests dans un navigateur : l'écran // attend le moteur. @@ -563,6 +631,7 @@ export function startApp(options: AppOptions): void { ui.info.textContent = notice; } renderMenu(); + renderChrome(); } /** Les commandes de la colonne de gauche, en paysage. */ @@ -641,6 +710,53 @@ export function startApp(options: AppOptions): void { element.hidden = !visible; } + /** Ce qui entoure les écrans : l'aide du moment derrière le bouton + * « Aide », la ligne « Installer » du menu, le bandeau de mise à jour, + * et l'accueil s'il est affiché. */ + function renderChrome(): void { + const helpLink = helpHash(helpMoment()); + 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); + 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, + ); + ui.updateBanner.hidden = !shown; + ui.updateText.textContent = HOME_TEXTS.updateAvailable; + ui.updateApply.hidden = false; + } + if (navigation.route.screen === "home") { + renderHome(); + } + } + + /** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */ + function helpMoment(): HelpMoment | null { + if (game === null) { + return null; + } + const state = game.state; + if (state.phase === "over") { + return "over"; + } + if (state.turn === "computer" && state.phase === "doubled") { + return "doubled"; + } + if (state.turn === "player" && state.phase === "moving") { + return stuck ? "stuck" : "moving"; + } + return null; + } + // --------------------------------------------- Vibrations, écran allumé /** Une seule vibration par fin de partie. */ @@ -801,10 +917,18 @@ export function startApp(options: AppOptions): void { notice = null; ui.bubble.hidden = true; render(); + const before = game; + const wasOver = game.state.phase === "over"; try { await action(game); save(); await computerTurns(); + // Une partie vient de se terminer (pas une partie finie + // reprise) : elle compte pour l'invitation à installer. + if (game === before && !wasOver && game.state.phase === "over") { + saveSettings({ gamesFinished: settings.gamesFinished + 1 }); + homeProgress = null; + } } catch (error) { info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error)); } finally { @@ -888,7 +1012,8 @@ export function startApp(options: AppOptions): void { // 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"; + const isSheet = (route: Route) => + route.screen === "menu" || route.screen === "games" || route.screen === "install"; // « Mes parties » : reprendre une partie (ou une branche) en change à // l'écran, sans recharger la page. @@ -1018,7 +1143,7 @@ export function startApp(options: AppOptions): void { }, help: () => { closeTransient(); - navigation.go("#/aide"); + navigation.go(helpHash(helpMoment())); }, fullscreen: () => { closeOverlays(); @@ -1037,6 +1162,8 @@ export function startApp(options: AppOptions): void { navigation.leave(isHelpScreen); } else if (route.screen === "position") { navigation.leave((r) => r.screen === "position"); + } else if (route.screen === "tutorial" || route.screen === "tutorialEnd") { + navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd"); } }, }; @@ -1075,6 +1202,7 @@ export function startApp(options: AppOptions): void { function leaveSheet(): void { const menuWasOpen = !ui.menuVeil.hidden; ui.menuVeil.hidden = true; + ui.installVeil.hidden = true; gamesView?.hide(); if (menuWasOpen) { (menuReturnFocus ?? ui.menuButton).focus(); @@ -1125,6 +1253,8 @@ export function startApp(options: AppOptions): void { // téléphone ne rouvre pas le menu. replacingLink(ui.menuGames, navigation); replacingLink(ui.menuHelp, navigation); + replacingLink(ui.menuHome, navigation); + replacingLink(ui.menuTutorial, navigation); ui.menuClose.onclick = () => closeOverlays(); ui.menuNewGame.onclick = () => newGame(); ui.menuCopyLink.onclick = () => { @@ -1164,14 +1294,41 @@ export function startApp(options: AppOptions): void { function applyRoute(route: Route): void { const help = isHelpScreen(route); const position = route.screen === "position"; + const home = route.screen === "home"; + const tutorial = route.screen === "tutorial" || route.screen === "tutorialEnd"; + const page = help || position || home || tutorial; const wasPage = ui.game.hidden; - ui.game.hidden = help || position; + const wasScreen = ui.root.dataset.screen; + ui.game.hidden = page; ui.help.hidden = !help; ui.position.hidden = !position; - ui.root.classList.toggle("page-open", help || position); - if (wasPage && !ui.game.hidden) { + ui.home.hidden = !home; + ui.tutorial.hidden = !tutorial; + ui.root.classList.toggle("page-open", page); + // Le tutoriel et le premier lancement ont leur propre en-tête. + ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings))); + ui.root.dataset.screen = route.screen; + if ((wasPage && !ui.game.hidden) || (page && wasScreen !== route.screen)) { window.scrollTo(0, 0); } + if (tutorial) { + void tutorialView.show(route); + } else { + tutorialView.hide(); + } + if (home) { + homeProgress = null; + } + + // La marche à suivre pour installer : une feuille, comme le menu. + if (route.screen === "install" && ui.installVeil.hidden) { + closeTransient(); + ui.menuVeil.hidden = true; + ui.installVeil.hidden = false; + ui.installDone.focus(); + } else if (route.screen !== "install") { + ui.installVeil.hidden = true; + } // Feuilles : le menu et « Mes parties ». const menuOpen = route.screen === "menu"; @@ -1214,16 +1371,147 @@ export function startApp(options: AppOptions): void { 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; + // Une adresse inconnue : la partie, avec un message, à l'adresse de + // la partie. + if (route.screen === "unknown") { + notice = TEXTS.unknownAddress; navigation.replace("#/"); return; } render(); } + // ----------------------------------------------- Accueil et tutoriel + + const homeView = createHomeView({ + section: ui.home, + newGame: () => { + if (busy || game === null) { + return; + } + navigation.go("#/"); + newGame(); + }, + install: () => void install(false), + dismissInstall: () => saveSettings(dismissInstall(settings)), + welcomed: () => saveSettings(welcomed()), + }); + + let progressLoading = false; + function renderHome(): void { + const playing = game === null ? null : game.state.phase !== "over"; + homeView.render({ + model: homeModel(settings, playing, installer.installed), + sides: sides ?? options.cachedBoard()?.sides ?? null, + tutorialSeen: settings.tutorialSeen, + progress: homeProgress, + busy: busy || game === null, + }); + if (homeProgress === null && !progressLoading) { + progressLoading = true; + // La liste vient du stockage, ouvert avec le moteur. + options.engineReady + .then(() => options.games.list()) + .then((list) => { + const over = list.filter((summary) => summary.status === "over"); + homeProgress = { + played: over.length, + won: over.filter((summary) => summary.result?.winner === "player").length, + }; + if (navigation.route.screen === "home") { + renderHome(); + } + }) + .catch(() => undefined) + .finally(() => { + progressLoading = false; + }); + } + } + + const tutorialView = createTutorialView({ + section: ui.tutorial, + access: options.tutorial, + engineReady: options.engineReady, + seen: () => settings.tutorialSeen, + visit: (place) => { + const seen = seenAfter(settings.tutorialSeen, place); + if (seen !== settings.tutorialSeen || !settings.welcomed) { + saveSettings({ tutorialSeen: seen, ...welcomed() }); + } + }, + placed: () => haptic("checkerPlaced"), + reducedMotion, + replace: (hash) => navigation.replace(hash), + }); + + // ------------------------------------------------------- Installation + + /** « Installer » : l'invite du navigateur s'il en a donné une, sinon la + * marche à suivre (#/installer). Depuis le menu, l'adresse du menu est + * remplacée : le retour du téléphone ne le rouvre pas. */ + async function install(fromMenu: boolean): Promise { + if (installer.canPrompt) { + if (fromMenu) { + closeOverlays(); + } + await installer.offer(); + render(); + return; + } + if (fromMenu) { + ui.menuVeil.hidden = true; + navigation.replace("#/installer"); + } else { + navigation.go("#/installer"); + } + } + ui.menuInstall.onclick = () => void install(true); + const leaveInstall = () => { + ui.installVeil.hidden = true; + navigation.leave((route) => route.screen === "install"); + }; + ui.installClose.onclick = leaveInstall; + ui.installDone.onclick = leaveInstall; + ui.installVeil.addEventListener("click", (event) => { + if (event.target === ui.installVeil) { + leaveInstall(); + } + }); + + // ------------------------------------------------------- Mise à jour + + updates = options.startUpdates(() => render()); + ui.updateApply.onclick = () => { + ui.updateApply.disabled = true; + updates?.apply(); + }; + function showUpdateMessage(message: string | null, lasting: boolean): void { + if (updateMessageTimer !== null) { + clearTimeout(updateMessageTimer); + updateMessageTimer = null; + } + updateMessage = message; + if (message !== null && !lasting) { + updateMessageTimer = setTimeout(() => showUpdateMessage(null, true), 4000); + } + render(); + } + ui.menuUpdate.onclick = () => { + closeOverlays(); + showUpdateMessage(HOME_TEXTS.updateChecking, true); + void (updates?.check() ?? Promise.resolve("unavailable" as const)).then((found) => { + showUpdateMessage( + found === "found" + ? null + : found === "latest" + ? HOME_TEXTS.updateLatest + : HOME_TEXTS.updateUnavailable, + found === "found", + ); + }); + }; + // -------------------------------------------- Vue d'une position /** #/position/... : lit la position par le moteur, puis la montre. */ @@ -1408,6 +1696,9 @@ export function startApp(options: AppOptions): void { if (shownPosition !== null) { renderPosition({ kind: "shown", shared: shownPosition }); } + if (!ui.tutorial.hidden) { + tutorialView.relayout(); + } }); }; window.addEventListener("resize", relayout); @@ -1550,8 +1841,14 @@ export function startApp(options: AppOptions): void { // ---------------------------------------------------------- Démarrage // Réponse immédiate : le plateau est dessiné avant que le moteur arrive, - // ou l'écran que désigne l'adresse. - applyRoute(navigation.route); + // ou l'écran que désigne l'adresse. Lancée sans adresse d'écran (icône, + // adresse du site), l'application s'ouvre sur l'accueil. + const launch = launchHash(location.hash); + if (launch !== null) { + navigation.replace(launch); + } else { + applyRoute(navigation.route); + } options.engineReady.then( async () => { game = options.savedGame(); diff --git a/app/src/ui/help-context.ts b/app/src/ui/help-context.ts new file mode 100644 index 0000000..229c916 --- /dev/null +++ b/app/src/ui/help-context.ts @@ -0,0 +1,32 @@ +// La question de l'aide qui correspond au moment de la partie : le bouton +// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par +// défaut sauf celle qui correspond à l'écran d'où l'on vient »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ; +// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie +// que chaque titre ci-dessous est bien celui d'un scénario : changer un +// titre dans un .feature demande de le changer ici. + +import { routeHash, slug } from "./router.js"; + +/** Le moment de la partie, vu de l'aide. */ +export type HelpMoment = "moving" | "stuck" | "doubled" | "over"; + +export const HELP_QUESTIONS: Readonly> = { + moving: "Comment jouer un coup ?", + stuck: "Je ne peux rien jouer avec mes dés", + doubled: "L'ordinateur double : que se passe-t-il si je prends ?", + over: "Comment se termine une partie ?", +}; + +/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans + * question particulière : null). */ +export function helpHash(moment: HelpMoment | null): string { + return routeHash({ + screen: "help", + question: moment === null ? null : slug(HELP_QUESTIONS[moment]), + }); +} diff --git a/app/src/ui/help-text.ts b/app/src/ui/help-text.ts index 4b4ccc7..8f6c1b5 100644 --- a/app/src/ui/help-text.ts +++ b/app/src/ui/help-text.ts @@ -14,10 +14,16 @@ export interface HelpFeature { } export interface HelpScenario { + /** Le titre, formulé comme la question du joueur. */ readonly title: string; + /** La réponse : le paragraphe sous le titre. */ readonly description: readonly string[]; - /** Les étapes, mot-clé compris : « Quand vous prenez ». */ + /** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne + * s'affichent pas dans l'aide : elles prouvent la réponse. */ readonly steps: readonly string[]; + /** La position de l'exemple, donnée par l'étiquette + * @position: du scénario (null sans étiquette). */ + readonly position: string | null; } const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/; @@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/; /** * Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte * libre consécutives forment un paragraphe ; les commentaires (#) sont - * ignorés. + * ignorés ; une étiquette @position: donne la position de + * l'exemple du scénario qui la suit. */ export function parseFeature(text: string): HelpFeature { let title = ""; const description: string[] = []; - const scenarios: { title: string; description: string[]; steps: string[] }[] = []; + const scenarios: { + title: string; + description: string[]; + steps: string[]; + position: string | null; + }[] = []; let paragraph: string[] = []; + let tags: string[] = []; // Le paragraphe en cours rejoint la description de la fonctionnalité ou // du dernier scénario. @@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature { const scenario = SCENARIO.exec(line); if (line === "" || line.startsWith("#")) { endParagraph(); + } else if (line.startsWith("@")) { + endParagraph(); + tags.push(...line.split(/\s+/)); } else if (feature) { endParagraph(); title = feature[1] ?? ""; + tags = []; } else if (scenario) { endParagraph(); - scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] }); + const position = tags.find((tag) => tag.startsWith("@position:")); + scenarios.push({ + title: scenario[2] ?? "", + description: [], + steps: [], + position: position === undefined ? null : position.slice("@position:".length), + }); + tags = []; } else if (STEP.test(line) && scenarios.length > 0) { endParagraph(); scenarios.at(-1)?.steps.push(line); @@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] { return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? ""); } -/** Une fiche du glossaire : le terme et sa phrase « En bref ». */ +/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa + * phrase « En bref », et ses renvois « Voir aussi ». */ export interface GlossaryEntry { readonly term: string; + readonly english: string; readonly brief: string; + /** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */ + readonly seeAlso: readonly { readonly term: string; readonly name: string }[]; } /** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */ export function parseGlossaryEntry(text: string): GlossaryEntry | null { const term = /^term: (.+)$/m.exec(text)?.[1]?.trim(); + const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? ""; const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim(); if (term === undefined || brief === undefined) { return null; } - return { term, brief: brief.replaceAll("**", "") }; + const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? ""; + const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({ + term: m[1] ?? "", + name: m[2] ?? "", + })); + return { term, english, brief: brief.replaceAll("**", ""), seeAlso }; +} + +/** Un texte sans accents ni majuscules, pour la recherche dans le + * glossaire. */ +export function searchable(text: string): string { + return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); +} + +/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa + * phrase « En bref » contiennent chacun des mots cherchés) ? */ +export function matchesSearch(entry: GlossaryEntry, query: string): boolean { + const words = searchable(query).split(/\s+/).filter(Boolean); + const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`); + return words.every((word) => haystack.includes(word)); } diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index bbca105..3c6e1ba 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -8,25 +8,33 @@ // // 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). +// est ouvert (show). Le titre d'un scénario est la question, sa +// description la réponse ; ses étapes, la preuve que l'application fait ce +// que dit la réponse, ne s'affichent pas +// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »). import { type GlossaryEntry, type HelpFeature, type HelpScenario, + matchesSearch, parseFeature, parseGlossaryEntry, parseGlossaryIndex, } from "./help-text.js"; +import { HOME_TEXTS } from "./home-texts.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 = [ +export const FEATURES = [ "docs/scenarios/jouer-une-partie.feature", + "docs/scenarios/apprendre-les-regles.feature", "docs/scenarios/mes-parties.feature", "docs/scenarios/partager-une-position.feature", "docs/scenarios/regler-l-application.feature", + "docs/scenarios/installer-l-application.feature", + "docs/scenarios/decouvrir-l-application.feature", ]; const GLOSSARY = "docs/glossaire/"; @@ -59,6 +67,18 @@ export async function loadGlossaryEntry(file: string): Promise { + try { + const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown }; + return typeof version.commit === "string" && version.commit !== "" + ? version.commit.slice(0, 12) + : null; + } catch { + return null; + } +} + function element( parent: HTMLElement, tag: K, @@ -101,7 +121,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV notice.className = "help-notice"; notice.setAttribute("role", "status"); const questions: Question[] = []; - const terms = new Map(); + const terms = new Map(); let loading: Promise | null = null; // La question ouverte au dernier affichage : refermée, elle reste en vue. let lastOpen: Question | null = null; @@ -126,10 +146,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV 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); + if (scenario.position !== null) { + const board = element(answer, "a", HOME_TEXTS.seeOnBoard); + board.className = "see-board"; + board.href = routeHash({ + screen: "position", + position: scenario.position, + match: null, + }); } questions.push({ id, section, link, answer }); } @@ -137,7 +161,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV function renderFeature(feature: HelpFeature): void { element(play, "h2", feature.title); for (const paragraph of feature.description) { - element(play, "p", paragraph); + element(play, "p", paragraph).className = "feature-intro"; } for (const scenario of feature.scenarios) { renderScenario(play, scenario); @@ -146,38 +170,103 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV function renderGlossary(entries: readonly GlossaryCard[]): void { element(glossary, "h2", TEXTS.glossaryTitle); + const label = element(glossary, "label"); + label.className = "glossary-search"; + element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label"; + const search = element(label, "input"); + search.id = "glossary-search"; + search.type = "search"; + search.autocomplete = "off"; + search.spellcheck = false; + const none = element(glossary, "p", HOME_TEXTS.glossaryNone); + none.className = "glossary-none muted"; + none.hidden = true; 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); + const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr")); + for (const entry of sorted) { + const card = element(list, "div"); + card.className = "term-card"; + const dt = element(card, "dt"); + dt.id = `glossaire-${entry.name}`; + const link = element(dt, "a", entry.term); link.href = routeHash({ screen: "glossary", term: entry.name }); - element(list, "dd", entry.brief); - terms.set(entry.name, term); + if (entry.english !== "") { + const english = element(dt, "span", entry.english); + english.className = "english"; + english.lang = "en"; + } + const dd = element(card, "dd"); + element(dd, "p", entry.brief); + if (entry.seeAlso.length > 0) { + const see = element(dd, "p"); + see.className = "see-also"; + see.append(`${HOME_TEXTS.glossarySeeAlso} `); + entry.seeAlso.forEach((other, i) => { + if (i > 0) { + see.append(", "); + } + const a = element(see, "a", other.term); + a.href = routeHash({ screen: "glossary", term: other.name }); + }); + } + terms.set(entry.name, { dt, card, entry }); } + search.addEventListener("input", () => { + let shown = 0; + for (const { card, entry } of terms.values()) { + const match = matchesSearch(entry, search.value); + card.hidden = !match; + shown += match ? 1 : 0; + } + none.hidden = shown > 0; + }); } - function renderAbout(): void { + function renderAbout(version: string | null): void { element(about, "h2", TEXTS.aboutTitle); - const text = element(about, "p", TEXTS.about); - text.className = "about"; + for (const paragraph of HOME_TEXTS.aboutParagraphs) { + element(about, "p", paragraph).className = "about"; + } + const line = element( + about, + "p", + version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version), + ); + line.id = "about-version"; + line.className = "about"; + } + + function renderTutorialCard(): void { + const card = element(container, "a"); + card.id = "help-tutorial"; + card.className = "card help-tutorial"; + card.href = "#/tutoriel/1"; + const text = element(card, "span"); + text.className = "help-tutorial-text"; + element(text, "strong", HOME_TEXTS.helpTutorialTitle); + element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted"; + element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill"; } async function load(): Promise { container.replaceChildren(notice); notice.textContent = TEXTS.helpLoading; try { - const [features, entries] = await Promise.all([ + const [features, entries, version] = await Promise.all([ Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))), loadGlossary(), + publishedVersion(), ]); + const intro = element(play, "p", HOME_TEXTS.helpIntro); + intro.className = "help-intro muted"; for (const feature of features) { renderFeature(feature); } renderGlossary(entries); - renderAbout(); + renderAbout(version); notice.textContent = ""; + renderTutorialCard(); container.append(play, glossary, about); container.dataset.loaded = "true"; return true; @@ -215,10 +304,12 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV q.section.classList.toggle("open", open); } const term = route.screen === "glossary" ? route.term : null; - for (const [name, dt] of terms) { + for (const [name, { dt, card }] of terms) { dt.classList.toggle("current", name === term); + card.classList.toggle("current", name === term); if (name === term) { dt.setAttribute("aria-current", "true"); + card.hidden = false; } else { dt.removeAttribute("aria-current"); } @@ -231,7 +322,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV const opened = questions.find((q) => q.id === question) ?? null; const closed = route.screen === "help" && opened === null ? lastOpen : null; lastOpen = opened; - const target = opened?.section ?? (term === null ? undefined : terms.get(term)); + const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card); if (target !== undefined) { target.scrollIntoView({ block: "start" }); } else if (closed !== null) { diff --git a/app/src/ui/home-model.ts b/app/src/ui/home-model.ts new file mode 100644 index 0000000..7b5c673 --- /dev/null +++ b/app/src/ui/home-model.ts @@ -0,0 +1,64 @@ +// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures, +// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { installCardShown, isFirstLaunch, type Settings } from "./settings.js"; + +/** Les deux choix du premier lancement : « Je découvre » mène au + * tutoriel, « Je connais les règles » à la partie. */ +export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const; + +/** Les réglages après l'un des deux choix du premier lancement. */ +export function welcomed(): Partial { + return { welcomed: true }; +} + +/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de + * l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est + * gardée (null). */ +export function launchHash(hash: string): string | null { + return hash === "" || hash === "#" ? "#/accueil" : null; +} + +/** L'accueil, au premier lancement ou ensuite. */ +export interface HomeModel { + /** Premier lancement : le choix « Je découvre » / « Je connais les + * règles », et rien d'autre. */ + readonly welcome: boolean; + /** La carte de la partie : « Reprendre la partie » si une partie est en + * cours, sinon « Nouvelle partie » en action principale. */ + readonly resume: boolean; + /** L'invitation à installer. */ + readonly installCard: boolean; +} + +/** Ce que montre l'accueil. playing : une partie est en cours (null : pas + * encore connu, le moteur se charge). */ +export function homeModel( + settings: Settings, + playing: boolean | null, + installed: boolean, +): HomeModel { + const welcome = isFirstLaunch(settings); + return { + welcome, + resume: playing !== false, + installCard: !welcome && installCardShown(settings, installed), + }; +} + +/** La ligne « Installer l'application » du menu : tant que l'application + * n'est pas installée. */ +export function installMenuShown(settings: Settings, installed: boolean): boolean { + return !installed && !settings.installed; +} + +/** Les réglages après « Plus tard » sur l'invitation à installer. */ +export function dismissInstall(settings: Settings): Partial { + return { + installDismissals: settings.installDismissals + 1, + installDismissedAt: settings.gamesFinished, + }; +} diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts new file mode 100644 index 0000000..622635f --- /dev/null +++ b/app/src/ui/home-texts.ts @@ -0,0 +1,125 @@ +// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à +// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md). +// Première version, à valider par Pierre. Le texte des étapes du tutoriel +// n'est pas ici : il vit dans docs/tutoriel/regles.md. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { ResultKind } from "../domain/game/engine.js"; +import type { InstallPlatform } from "./install.js"; + +function plural(n: number, one: string, many: string): string { + return `${n} ${n > 1 ? many : one}`; +} + +export const HOME_TEXTS = { + // Premier lancement + welcomeTitle: "Bienvenue à la table", + welcomeText: + "Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.", + discover: "Je découvre", + discoverDetail: "Les règles en huit petites étapes, plateau à l'appui", + knowRules: "Je connais les règles", + knowRulesDetail: "Une partie tout de suite", + tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.", + + // Accueil + homeLink: "Accueil", + homeTitle: "À vous de jouer", + homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.", + gameOverline: "Partie en cours", + noGameOverline: "Pas de partie en cours", + againstComputer: "Contre l'ordinateur", + homePips: (player: number, computer: number) => + `Compte de points : vous ${player}, l'ordinateur ${computer}`, + homeLoading: "Chargement…", + resumeGame: "Reprendre la partie", + rulesTitle: "Les règles du jeu", + rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui", + rulesStart: "Commencer", + rulesContinue: (step: number) => `Reprendre à l'étape ${step}`, + rulesAgain: "Revoir", + progress: (played: number, won: number) => + `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`, + footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.", + + // Installation + installTitle: "Installer Ustagammon", + installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.", + install: "Installer", + installLater: "Plus tard", + installMenu: "Installer l'application", + installLead: + "Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.", + installSteps: { + firefox: [ + "Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.", + "Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».", + "Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.", + ], + ios: [ + "Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.", + "Choisissez « Sur l'écran d'accueil ».", + "Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.", + ], + } satisfies Record, + installUnderstood: "J'ai compris", + installed: "Ustagammon est installé : ouvrez-le depuis son icône.", + + // Mise à jour + updateAvailable: "Nouvelle version disponible.", + updateApply: "Mettre à jour", + updateCheck: "Rechercher une mise à jour", + updateChecking: "Recherche d'une mise à jour…", + updateLatest: "Vous avez la dernière version.", + updateUnavailable: + "Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.", + + // Tutoriel + tutorialMenu: "Les règles (tutoriel)", + tutorialProgress: "Étapes du tutoriel", + tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`, + tutorialSkip: "Passer", + tutorialSkipLabel: "Passer le tutoriel", + tutorialOverline: (n: number, total: number, practice: boolean) => + `Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`, + tutorialEndOverline: "Les règles · fin", + tutorialEndTitle: "Vous connaissez les règles", + back: "Retour", + next: "Continuer", + replay: "Revoir", + replayLabel: "Revoir le coup", + take: "Je prends", + drop: "Je passe", + tutorialLoading: "Chargement du tutoriel…", + tutorialFailed: "Le tutoriel n'a pas pu se charger.", + pathLabel: "Le sens du jeu de vos pions, en pointillés", + /** Le résultat d'une partie finie par le coup illustré (étape 7). */ + illustratedEnd: (kind: ResultKind) => + kind === "single" + ? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple." + : kind === "gammon" + ? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon." + : "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.", + computerDoubles: "L'ordinateur double.", + + // Aide + helpTutorialTitle: "Nouveau au backgammon ?", + helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.", + helpTutorialLink: "Apprendre les règles", + helpIntro: + "Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.", + seeOnBoard: "Voir sur le plateau", + glossarySearch: "Chercher un mot", + glossaryNone: "Aucun mot ne correspond.", + glossarySeeAlso: "Voir aussi :", + aboutVersion: (commit: string) => `Version publiée : ${commit}.`, + aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).", + aboutParagraphs: [ + "Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.", + "Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.", + "L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.", + "Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.", + ], +} as const; diff --git a/app/src/ui/home-view.ts b/app/src/ui/home-view.ts new file mode 100644 index 0000000..9b12663 --- /dev/null +++ b/app/src/ui/home-view.ts @@ -0,0 +1,242 @@ +// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » / +// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à +// installer, les règles et la progression résumée +// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore : +// l'accueil n'affiche que ce qui existe. + +import type { SideBoards } from "../domain/game/game.js"; +import { boardModel, OPENING_BOARDS } from "./board-model.js"; +import { NO_MARKS, renderBoard } from "./board-view.js"; +import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js"; +import { type HomeModel, WELCOME_LINKS } from "./home-model.js"; +import { HOME_TEXTS } from "./home-texts.js"; +import { icon } from "./icons.js"; +import { TUTORIAL_DONE } from "./settings.js"; +import { TEXTS } from "./texts.js"; + +export interface HomeViewOptions { + readonly section: HTMLElement; + /** « Nouvelle partie » (bouton : une action, pas un écran). */ + readonly newGame: () => void; + readonly install: () => void; + readonly dismissInstall: () => void; + /** Un des deux choix du premier lancement a été suivi. */ + readonly welcomed: () => void; +} + +export interface HomeState { + readonly model: HomeModel; + /** Le plateau de la partie en cours ou de la dernière partie (null : + * jamais joué). */ + readonly sides: SideBoards | null; + /** La dernière étape du tutoriel vue. */ + readonly tutorialSeen: number; + /** Parties jouées et gagnées (null : pas encore lues). */ + readonly progress: { readonly played: number; readonly won: number } | null; + /** Le moteur se charge, ou l'écran est occupé. */ + readonly busy: boolean; +} + +export interface HomeView { + render(state: HomeState): void; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +function element( + parent: HTMLElement, + tag: K, + options: { id?: string; className?: string; text?: string } = {}, +): HTMLElementTagNameMap[K] { + const child = document.createElement(tag); + if (options.id !== undefined) { + child.id = options.id; + } + if (options.className !== undefined) { + child.className = options.className; + } + if (options.text !== undefined) { + child.textContent = options.text; + } + parent.append(child); + return child; +} + +function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.id = id; + svg.setAttribute("class", `board ${className}`); + svg.setAttribute("aria-hidden", "true"); + parent.append(svg); + return svg; +} + +function logo(parent: HTMLElement): void { + for (const [variant, file] of [ + ["logo-light", "logo-horizontal-couleur.svg"], + ["logo-dark", "logo-horizontal-sombre.svg"], + ] as const) { + const img = element(parent, "img", { className: variant }); + img.src = `icons/${file}`; + img.alt = "Ustagammon"; + } +} + +function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void { + renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), { + ...NO_MARKS, + dice: [], + cube: { value: 1, owner: null }, + band: null, + controls: null, + }); +} + +/** Une grande carte-lien du premier lancement : le choix et son détail. */ +function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) { + const link = element(parent, "a", { id, className: "choice" }); + link.href = href; + element(link, "strong", { text: label }); + element(link, "span", { text: detail }); + return link; +} + +export function createHomeView(options: HomeViewOptions): HomeView { + const { section } = options; + section.replaceChildren(); + + // Premier lancement. + const welcome = element(section, "div", { id: "home-welcome", className: "welcome" }); + const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" }); + logo(welcomeLogo); + const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board"); + const welcomeText = element(welcome, "div", { className: "welcome-text" }); + element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle }); + element(welcomeText, "p", { text: HOME_TEXTS.welcomeText }); + const choices = element(welcome, "div", { className: "welcome-choices" }); + const discover = choice( + choices, + "welcome-discover", + WELCOME_LINKS.discover, + HOME_TEXTS.discover, + HOME_TEXTS.discoverDetail, + ); + discover.classList.add("primary"); + const play = choice( + choices, + "welcome-play", + WELCOME_LINKS.play, + HOME_TEXTS.knowRules, + HOME_TEXTS.knowRulesDetail, + ); + element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu }); + for (const link of [discover, play]) { + link.addEventListener("click", () => options.welcomed()); + } + + // Ensuite. + const main = element(section, "div", { id: "home-main", className: "home-main" }); + const intro = element(main, "div", { className: "home-intro" }); + element(intro, "h2", { text: HOME_TEXTS.homeTitle }); + element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead }); + + const card = element(main, "article", { id: "home-game", className: "card home-game" }); + const cardTop = element(card, "div", { className: "home-game-top" }); + const miniBox = element(cardTop, "div", { className: "home-mini" }); + const mini = boardSvg(miniBox, "home-board", "home-board"); + const facts = element(cardTop, "div", { className: "home-facts" }); + const gameOverline = element(facts, "span", { className: "overline" }); + element(facts, "strong", { text: HOME_TEXTS.againstComputer }); + const pips = element(facts, "span", { id: "home-pips", className: "home-pips" }); + const actions = element(card, "div", { className: "home-actions" }); + const resume = element(actions, "a", { + id: "home-resume", + className: "button", + text: HOME_TEXTS.resumeGame, + }); + resume.href = "#/"; + const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame }); + fresh.type = "button"; + fresh.addEventListener("click", () => options.newGame()); + + const install = element(main, "aside", { id: "install-card", className: "install-card" }); + install.setAttribute("aria-label", HOME_TEXTS.installTitle); + const symbol = element(install, "img", { className: "install-symbol" }); + symbol.src = "icons/symbole-couleur.svg"; + symbol.alt = ""; + const installText = element(install, "span", { className: "install-text" }); + element(installText, "strong", { text: HOME_TEXTS.installTitle }); + element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText }); + const installButton = element(install, "button", { + id: "install-card-button", + className: "secondary", + text: HOME_TEXTS.install, + }); + installButton.type = "button"; + installButton.addEventListener("click", () => options.install()); + const later = element(install, "button", { id: "install-later", className: "icon-button" }); + later.type = "button"; + later.append(icon("close")); + later.setAttribute("aria-label", HOME_TEXTS.installLater); + later.title = HOME_TEXTS.installLater; + later.addEventListener("click", () => options.dismissInstall()); + + const rules = element(main, "a", { id: "home-rules", className: "card home-rules" }); + const rulesText = element(rules, "span", { className: "home-rules-text" }); + element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle }); + element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail }); + const rulesAction = element(rules, "span", { className: "pill" }); + + const progress = element(main, "p", { + id: "home-progress", + className: "caption home-progress", + }); + const footer = element(main, "footer", { className: "home-footer" }); + const footerSymbol = element(footer, "img"); + footerSymbol.src = "icons/symbole-couleur.svg"; + footerSymbol.alt = ""; + element(footer, "span", { text: HOME_TEXTS.footer }); + + return { + render(state) { + const { model } = state; + welcome.hidden = !model.welcome; + main.hidden = model.welcome; + if (model.welcome) { + drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360); + return; + } + const sides = state.sides ?? OPENING_BOARDS; + drawBoard(mini, sides, miniBox.clientWidth || 132); + gameOverline.textContent = model.resume + ? HOME_TEXTS.gameOverline + : HOME_TEXTS.noGameOverline; + pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount); + // Une seule action principale : « Reprendre la partie », ou + // « Nouvelle partie » quand il n'y a rien à reprendre. + resume.hidden = !model.resume; + fresh.textContent = TEXTS.newGame; + fresh.classList.toggle("secondary", model.resume); + fresh.disabled = state.busy; + install.hidden = !model.installCard; + + const seen = state.tutorialSeen; + rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1"; + rulesAction.textContent = + seen === 0 + ? HOME_TEXTS.rulesStart + : seen >= TUTORIAL_DONE + ? HOME_TEXTS.rulesAgain + : HOME_TEXTS.rulesContinue(seen); + progress.textContent = + state.progress === null || state.progress.played === 0 + ? "" + : HOME_TEXTS.progress(state.progress.played, state.progress.won); + }, + }; +} diff --git a/app/src/ui/icons.ts b/app/src/ui/icons.ts index 4e748bf..b65d151 100644 --- a/app/src/ui/icons.ts +++ b/app/src/ui/icons.ts @@ -22,6 +22,9 @@ export const ICON_PATHS = { vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4", theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18", screenOn: "M7 3h10v18H7zM11 18h2", + home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6", + rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z", + install: "M12 4v11M7 10l5 5 5-5M5 20h14", verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6", verdictDoubtful: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1", diff --git a/app/src/ui/install.ts b/app/src/ui/install.ts new file mode 100644 index 0000000..a2a9c60 --- /dev/null +++ b/app/src/ui/install.ts @@ -0,0 +1,82 @@ +// L'installation de l'application (docs/identite/charte/accueil-tutoriel- +// aide.md, « Installation ») : l'invite du système quand le navigateur en +// donne une (Chrome, événement beforeinstallprompt), sinon une marche à +// suivre en trois étapes (Firefox, iPhone). Plus rien une fois +// l'application installée. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */ +export type InstallPlatform = "ios" | "firefox"; + +/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à + * suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */ +export function installPlatform(userAgent: string): InstallPlatform { + return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox"; +} + +/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */ +interface InstallPromptEvent extends Event { + readonly prompt: () => Promise; + readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export interface Installer { + /** L'application tourne installée, ou vient de l'être. */ + readonly installed: boolean; + /** Le navigateur a donné son invite d'installation. */ + readonly canPrompt: boolean; + /** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */ + offer(): Promise<"accepted" | "dismissed" | "unavailable">; +} + +/** + * Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et + * empêche le navigateur de l'afficher de lui-même), et note l'installation + * (display-mode: standalone, événement appinstalled). onChange est appelé à + * chaque changement. + */ +export function startInstaller(onChange: (installedNow: boolean) => void): Installer { + const standalone = window.matchMedia("(display-mode: standalone)"); + let installedNow = false; + let deferred: InstallPromptEvent | null = null; + + window.addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + deferred = event as InstallPromptEvent; + onChange(false); + }); + window.addEventListener("appinstalled", () => { + installedNow = true; + deferred = null; + onChange(true); + }); + standalone.addEventListener("change", () => onChange(false)); + + return { + get installed() { + return installedNow || standalone.matches; + }, + get canPrompt() { + return deferred !== null; + }, + async offer() { + const event = deferred; + if (event === null) { + return "unavailable"; + } + // Une invite ne sert qu'une fois. + deferred = null; + try { + await event.prompt(); + const choice = await event.userChoice; + return choice.outcome; + } catch { + return "unavailable"; + } finally { + onChange(false); + } + }, + }; +} diff --git a/app/src/ui/settings.ts b/app/src/ui/settings.ts index b7646a8..027bf75 100644 --- a/app/src/ui/settings.ts +++ b/app/src/ui/settings.ts @@ -3,6 +3,10 @@ // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a +// toujours une valeur par défaut, et un réglage enregistré par une version +// précédente se relit sans perte. export type Theme = "auto" | "light" | "dark"; @@ -15,6 +19,21 @@ export interface Settings { /** Parties commencées : la consigne est longue pendant les trois * premières, courte ensuite. */ readonly gamesStarted: number; + /** Parties terminées (l'invitation à installer vient après la + * première). */ + readonly gamesFinished: number; + /** Le choix du premier lancement (« Je découvre », « Je connais les + * règles ») a été fait : il ne revient plus. */ + readonly welcomed: boolean; + /** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE : + * la fin). La barre de progression ne mène qu'aux étapes vues. */ + readonly tutorialSeen: number; + /** Combien de fois « Plus tard » a masqué l'invitation à installer, et + * le nombre de parties terminées à la dernière fois. */ + readonly installDismissals: number; + readonly installDismissedAt: number; + /** L'application a été installée (événement appinstalled). */ + readonly installed: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -22,11 +41,24 @@ export const DEFAULT_SETTINGS: Settings = { keepScreenOn: true, theme: "auto", gamesStarted: 0, + gamesFinished: 0, + welcomed: false, + tutorialSeen: 0, + installDismissals: 0, + installDismissedAt: 0, + installed: false, }; /** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */ export const LONG_PROMPT_GAMES = 3; +/** tutorialSeen quand la fin du tutoriel a été vue. */ +export const TUTORIAL_DONE = 99; + +/** Parties terminées de plus avant que l'invitation à installer, masquée + * par « Plus tard », revienne (une seule fois). */ +export const INSTALL_AGAIN_AFTER = 3; + /** Les réglages lus (JSON), complétés par les valeurs par défaut ; une * valeur illisible garde sa valeur par défaut. */ export function parseSettings(text: string | null): Settings { @@ -39,16 +71,33 @@ export function parseSettings(text: string | null): Settings { } catch { // Réglages illisibles : valeurs par défaut. } - const bool = (key: "vibrations" | "keepScreenOn") => + const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") => typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]; + const count = ( + key: + | "gamesStarted" + | "gamesFinished" + | "tutorialSeen" + | "installDismissals" + | "installDismissedAt", + ) => { + const value = raw[key]; + return typeof value === "number" && Number.isInteger(value) && value >= 0 + ? value + : DEFAULT_SETTINGS[key]; + }; const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto"; - const games = raw.gamesStarted; return { vibrations: bool("vibrations"), keepScreenOn: bool("keepScreenOn"), theme, - gamesStarted: - typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0, + gamesStarted: count("gamesStarted"), + gamesFinished: count("gamesFinished"), + welcomed: bool("welcomed"), + tutorialSeen: count("tutorialSeen"), + installDismissals: count("installDismissals"), + installDismissedAt: count("installDismissedAt"), + installed: bool("installed"), }; } @@ -61,3 +110,37 @@ export function nextTheme(theme: Theme): Theme { export function longPrompts(settings: Settings): boolean { return settings.gamesStarted <= LONG_PROMPT_GAMES; } + +/** + * Premier lancement : rien n'a encore été fait dans l'application (le choix + * d'accueil, une partie terminée, une étape du tutoriel). La partie + * commencée d'office au chargement compte pour une : jusqu'à une partie + * commencée, c'est encore le premier lancement. + */ +export function isFirstLaunch(settings: Settings): boolean { + return ( + !settings.welcomed && + settings.gamesStarted <= 1 && + settings.gamesFinished === 0 && + settings.tutorialSeen === 0 + ); +} + +/** + * L'accueil montre-t-il l'invitation à installer ? Jamais une fois + * l'application installée ; à partir de la première partie terminée ; + * « Plus tard » la masque, et elle ne revient qu'une fois, après + * INSTALL_AGAIN_AFTER parties terminées de plus. + */ +export function installCardShown(settings: Settings, installed: boolean): boolean { + if (installed || settings.installed || settings.gamesFinished < 1) { + return false; + } + if (settings.installDismissals === 0) { + return true; + } + return ( + settings.installDismissals === 1 && + settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER + ); +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index 67b73ef..c73e28a 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -164,7 +164,6 @@ export const TEXTS = { // 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", diff --git a/app/src/ui/tutorial-nav.ts b/app/src/ui/tutorial-nav.ts new file mode 100644 index 0000000..04784e8 --- /dev/null +++ b/app/src/ui/tutorial-nav.ts @@ -0,0 +1,96 @@ +// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre +// de progression (charte : « les segments sont des liens vers les étapes +// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { Route } from "./router.js"; +import { TUTORIAL_DONE } from "./settings.js"; +import type { Tutorial } from "./tutorial-text.js"; + +/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */ +export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end"; + +/** L'écran que désigne une adresse, ramené à un écran qui existe : une + * étape hors du tutoriel mène à la première, un « à vous » absent à + * l'explication de son étape. */ +export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace { + if (route.screen === "tutorialEnd") { + return "end"; + } + if (route.screen !== "tutorial" || route.step === null) { + return { step: 1, practice: false }; + } + const step = tutorial.steps[route.step - 1]; + if (step === undefined) { + return { step: 1, practice: false }; + } + return { step: step.number, practice: route.practice && step.practice !== null }; +} + +/** L'adresse d'un écran du tutoriel. */ +export function placeRoute(place: TutorialPlace): Route { + return place === "end" + ? { screen: "tutorialEnd" } + : { screen: "tutorial", step: place.step, practice: place.practice }; +} + +/** L'écran suivant (la fin après la dernière étape). */ +export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace { + if (place === "end") { + return "end"; + } + const step = tutorial.steps[place.step - 1]; + if (!place.practice && step?.practice != null) { + return { step: place.step, practice: true }; + } + return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false }; +} + +/** L'écran précédent (null avant la première étape). */ +export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null { + if (place === "end") { + const last = tutorial.steps.at(-1); + return last === undefined ? null : { step: last.number, practice: last.practice !== null }; + } + if (place.practice) { + return { step: place.step, practice: false }; + } + const before = tutorial.steps[place.step - 2]; + return before === undefined + ? null + : { step: before.number, practice: before.practice !== null }; +} + +/** Ce que retient la progression quand on arrive sur un écran : la + * dernière étape vue (TUTORIAL_DONE à la fin). */ +export function seenAfter(seen: number, place: TutorialPlace): number { + return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step); +} + +/** Un segment de la barre de progression. */ +export interface ProgressSegment { + readonly step: number; + /** Étape faite ou en cours : le segment est en brique. */ + readonly done: boolean; + /** Étape déjà vue : le segment est un lien vers elle. */ + readonly link: boolean; + readonly current: boolean; +} + +/** La barre de progression, sur l'écran place, quand les étapes jusqu'à + * seen ont déjà été vues. */ +export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] { + const current = place === "end" ? total + 1 : place.step; + const reached = Math.max(seen, current); + return Array.from({ length: total }, (_, i) => { + const step = i + 1; + return { + step, + done: step <= current, + link: step <= reached && step !== current, + current: step === current, + }; + }); +} diff --git a/app/src/ui/tutorial-text.ts b/app/src/ui/tutorial-text.ts index a781da9..e8df0a0 100644 --- a/app/src/ui/tutorial-text.ts +++ b/app/src/ui/tutorial-text.ts @@ -121,8 +121,12 @@ function screenOf(raw: RawScreen, where: string): TutorialScreen { /** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les * étapes ne se suivent pas. */ export function parseTutorial(text: string): Tutorial { - const steps: { number: number; title: string; explain: RawScreen; practice: RawScreen | null }[] = - []; + const steps: { + number: number; + title: string; + explain: RawScreen; + practice: RawScreen | null; + }[] = []; let end: RawScreen | null = null; let current: RawScreen | null = null; let paragraph: string[] = []; diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts new file mode 100644 index 0000000..5ec28e6 --- /dev/null +++ b/app/src/ui/tutorial-view.ts @@ -0,0 +1,702 @@ +// L'écran du tutoriel (#/tutoriel/, #/tutoriel//a-vous, +// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte, +// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau +// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux, +// les coups illustrés et le jugement des gestes viennent du domaine, branché +// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est +// écrite ici. + +import type { MoveSteps } from "../domain/game/game.js"; +import type { HalfMove } from "../domain/game/position.js"; +import type { Roll } from "../domain/game/roll.js"; +import type { Side } from "../domain/game/side.js"; +import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js"; +import { + boardModel, + moveTargets, + moveTrace, + onlyHalfMove, + type Place, + placeOf as placeOfChecker, +} from "./board-model.js"; +import { + type BoardDecor, + checkerSpot, + NO_MARKS, + renderBoard, + type ShownDie, + slideChecker, +} from "./board-view.js"; +import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js"; +import { HOME_TEXTS } from "./home-texts.js"; +import { icon } from "./icons.js"; +import { type Route, routeHash } from "./router.js"; +import { + nextPlace, + placeOf, + placeRoute, + previousPlace, + progress, + type TutorialPlace, +} from "./tutorial-nav.js"; +import { + type Paragraph, + parseTutorial, + type Tutorial, + type TutorialScreen, +} from "./tutorial-text.js"; + +/** Le coup illustré se joue tout seul après une seconde. */ +const AUTOPLAY_MS = 1000; +const SLIDE_MS = 160; +const PAUSE_MS = 120; +/** Un pion refusé reste un instant à sa place avant de revenir. */ +const REFUSED_MS = 450; + +const SOURCE = "docs/tutoriel/regles.md"; + +/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */ +export interface PracticeSession { + board(): Promise<{ + player: TutorialBoard["sides"]["player"]; + computer: TutorialBoard["sides"]["computer"]; + }>; + nextHalfMoves(): Promise; + play(halfMove: HalfMove): Promise; + reset(): void; + readonly diceToPlay: readonly { value: number; used: boolean }[]; +} + +/** Ce que le tutoriel demande au domaine et au moteur. */ +export interface TutorialAccess { + board(position: string, onRoll: Side): Promise; + illustrate( + position: string, + roll: Roll, + move: string, + ): Promise<{ steps: MoveSteps; after: TutorialBoard }>; + practice(position: string, roll: Roll, expected: readonly string[]): Promise; + shouldTake(position: string): Promise; +} + +export interface TutorialViewOptions { + readonly section: HTMLElement; + readonly access: TutorialAccess; + readonly engineReady: Promise; + /** La dernière étape vue, et son enregistrement à l'arrivée sur un + * écran. */ + readonly seen: () => number; + readonly visit: (place: TutorialPlace) => void; + /** Un geste juste : la vibration « pion posé ». */ + readonly placed: () => void; + readonly reducedMotion: MediaQueryList; + /** L'adresse a désigné un écran qui n'existe pas : la remplacer. */ + readonly replace: (hash: string) => void; +} + +export interface TutorialView { + show(route: Route): Promise; + /** La taille de l'écran a changé : redessiner le plateau. */ + relayout(): void; + /** L'écran est quitté : arrêter le coup illustré. */ + hide(): void; +} + +function element( + parent: HTMLElement, + tag: K, + options: { id?: string; className?: string; text?: string } = {}, +): HTMLElementTagNameMap[K] { + const child = document.createElement(tag); + if (options.id !== undefined) { + child.id = options.id; + } + if (options.className !== undefined) { + child.className = options.className; + } + if (options.text !== undefined) { + child.textContent = options.text; + } + parent.append(child); + return child; +} + +/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */ +export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void { + target.replaceChildren(); + for (const part of paragraph) { + if (part.term !== undefined) { + const link = document.createElement("a"); + link.href = routeHash({ screen: "glossary", term: part.term }); + link.textContent = part.text; + target.append(link); + } else if (part.strong === true) { + const strong = document.createElement("strong"); + strong.textContent = part.text; + target.append(strong); + } else { + target.append(part.text); + } + } +} + +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 { + const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2; + const top = g.height * 0.3; + const bottom = g.height * 0.7; + const right = center(11); + const left = center(0) - g.pointWidth * 0.25; + const turn = g.pointWidth * 0.6; + const end = g.trayLeft + g.tray * 0.25; + const d = + `M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` + + `L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`; + const group = document.createElementNS(SVG_NS, "g"); + group.setAttribute("class", "tutorial-path"); + group.setAttribute("role", "img"); + group.setAttribute("aria-label", HOME_TEXTS.pathLabel); + const width = g.pointWidth * 0.22; + for (const [className, stroke] of [ + ["path-edge", width * 2.4], + ["path-dots", width], + ] as const) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("class", className); + path.setAttribute("d", d); + path.setAttribute("stroke-width", String(stroke)); + if (className === "path-dots") { + path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`); + } + group.append(path); + } + const head = document.createElementNS(SVG_NS, "path"); + head.setAttribute("class", "path-head"); + const size = width * 2.2; + head.setAttribute( + "d", + `M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`, + ); + head.setAttribute("stroke-width", String(width * 1.2)); + group.append(head); + // Sous les zones à toucher, toujours dessinées en dernier. + svg.insertBefore(group, svg.lastChild); +} + +/** Crée l'écran du tutoriel dans section (vide dans index.html). */ +export function createTutorialView(options: TutorialViewOptions): TutorialView { + const { section, access } = options; + section.replaceChildren(); + const head = element(section, "header", { className: "tutorial-head" }); + const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" }); + bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress); + const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" }); + skip.href = "#/"; + skip.textContent = HOME_TEXTS.tutorialSkip; + skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel); + const overline = element(section, "p", { id: "tutorial-overline", className: "overline" }); + const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" }); + const boardBox = element(section, "div", { className: "tutorial-board-box" }); + const svg = document.createElementNS(SVG_NS, "svg"); + svg.id = "tutorial-board"; + svg.setAttribute("class", "board tutorial-board"); + svg.setAttribute("role", "img"); + boardBox.append(svg); + const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" }); + replay.type = "button"; + 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", { + id: "tutorial-feedback", + className: "tutorial-feedback", + }); + feedback.setAttribute("role", "status"); + const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" }); + const drop = element(cube, "button", { + id: "tutorial-drop", + className: "secondary", + text: HOME_TEXTS.drop, + }); + drop.type = "button"; + const take = element(cube, "button", { + id: "tutorial-take", + className: "secondary", + text: HOME_TEXTS.take, + }); + take.type = "button"; + const nav = element(section, "div", { className: "tutorial-nav" }); + const back = element(nav, "a", { + id: "tutorial-back", + className: "button secondary", + text: HOME_TEXTS.back, + }); + const next = element(nav, "a", { + id: "tutorial-next", + className: "button", + text: HOME_TEXTS.next, + }); + + let tutorial: Promise | null = null; + // Chaque affichage a son numéro : un affichage dépassé ne touche plus + // à l'écran (le joueur est déjà ailleurs). + let shown = 0; + let autoplay: ReturnType | null = null; + let busy = false; + + // L'écran affiché. + let screen: TutorialScreen | null = null; + let place: TutorialPlace | null = null; + let boards: TutorialBoard | null = null; + let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null; + let illustrated = false; + let practice: PracticeSession | null = null; + let targets = new Map>(); + let selected: Place | null = null; + let solved = false; + + const load = () => { + tutorial ??= fetch(SOURCE) + .then((response) => { + if (!response.ok) { + throw new Error(`${SOURCE} : ${response.status}`); + } + return response.text(); + }) + .then(parseTutorial) + .catch((error: unknown) => { + tutorial = null; + throw error; + }); + return tutorial; + }; + + function geometry(): Geometry { + const width = svg.clientWidth || Math.min(window.innerWidth, 640); + return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640); + } + + function shownDice(): ShownDie[] { + if (practice !== null) { + return practice.diceToPlay.map((die) => ({ + value: die.value, + side: "player", + blocked: false, + used: die.used, + })); + } + const roll = screen?.move != null ? screen.roll : null; + if (roll === null || roll === undefined) { + return []; + } + const [a, b] = roll; + return [a, b].map((value) => ({ + value, + side: "player" as const, + blocked: false, + used: false, + })); + } + + /** Redessine le plateau dans son état courant. */ + function draw(sides: TutorialBoard["sides"] | null = null): void { + const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides; + if (current === undefined || current === null) { + return; + } + const g = geometry(); + const decor: BoardDecor = { + ...NO_MARKS, + dice: shownDice(), + cube: { value: 1, owner: null }, + band: null, + controls: null, + movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(), + selected, + targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])), + trace: + illustrated && illustration !== null + ? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits) + : null, + }; + renderBoard(svg, g, boardModel(current), decor); + if (screen?.path === true) { + drawPath(svg, g); + } + } + + function clearTimer(): void { + if (autoplay !== null) { + clearTimeout(autoplay); + autoplay = null; + } + } + + /** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux), + * puis ses traces ; sans mouvement si le système le demande. */ + async function playIllustration(id: number): Promise { + const shownMove = illustration; + if (shownMove === null || id !== shown) { + return; + } + busy = true; + replay.disabled = true; + illustrated = false; + try { + if (!options.reducedMotion.matches) { + const { steps } = shownMove; + for (const [i, halfMove] of steps.halfMoves.entries()) { + if (id !== shown) { + return; + } + if (i > 0) { + await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); + } + const from = placeOfChecker("player", halfMove.from); + const to = placeOfChecker("player", halfMove.to); + draw(steps.boards[i] ?? null); + const start = checkerSpot(svg, from); + const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null; + draw(steps.boards[i + 1] ?? null); + await Promise.all([ + start && slideChecker(svg, to, start, SLIDE_MS), + victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS), + ]); + } + } + } finally { + if (id === shown) { + busy = false; + illustrated = true; + replay.disabled = false; + draw(); + const result = shownMove.after.result; + feedback.textContent = + result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind); + feedback.dataset.verdict = result === null ? "" : "info"; + } + } + } + + async function refreshTargets(): Promise { + targets = + practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves()); + if (selected !== null && !targets.has(selected)) { + selected = null; + } + } + + function setVerdict(verdict: "right" | "wrong", words: Paragraph): void { + feedback.dataset.verdict = verdict; + fillParagraph(feedback, words); + if (verdict === "right") { + feedback.prepend(icon("verdictGood")); + } + } + + function markSolved(): void { + solved = true; + next.classList.remove("secondary"); + take.disabled = true; + drop.disabled = true; + options.placed(); + } + + async function playHalfMove(halfMove: HalfMove): Promise { + const session = practice; + const current = screen; + if (session === null || current === null || busy || solved) { + return; + } + const id = shown; + busy = true; + selected = null; + try { + const verdict = await session.play(halfMove); + if (id !== shown) { + return; + } + draw(await session.board()); + if (verdict === "right") { + markSolved(); + setVerdict("right", current.right); + } else if (verdict === "wrong") { + // Le pion revient, et une phrase dit pourquoi. + await new Promise((resolve) => setTimeout(resolve, REFUSED_MS)); + if (id !== shown) { + return; + } + session.reset(); + setVerdict("wrong", current.wrong); + } else { + feedback.textContent = ""; + feedback.dataset.verdict = ""; + } + await refreshTargets(); + } finally { + if (id === shown) { + busy = false; + draw(practice === null ? null : await practice.board()); + } + } + } + + svg.addEventListener("click", (event) => { + if (practice === null || busy || solved) { + return; + } + const target = event.target as Element | null; + const placeName = target + ?.closest("[data-place]") + ?.getAttribute("data-place") as Place | null; + if (placeName == null) { + return; + } + const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName); + if (halfMove !== undefined) { + void playHalfMove(halfMove); + return; + } + const only = placeName === selected ? null : onlyHalfMove(targets, placeName); + if (only !== null) { + void playHalfMove(only); + return; + } + selected = targets.has(placeName) && placeName !== selected ? placeName : null; + void practice.board().then((sides) => draw(sides)); + }); + + replay.addEventListener("click", () => { + clearTimer(); + void playIllustration(shown); + }); + + async function answer(takes: boolean): Promise { + const current = screen; + if (current === null || solved || busy) { + return; + } + const id = shown; + busy = true; + try { + const right = (await access.shouldTake(current.position)) === takes; + if (id !== shown) { + return; + } + if (right) { + markSolved(); + } + setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong); + } finally { + busy = false; + } + } + take.addEventListener("click", () => void answer(true)); + drop.addEventListener("click", () => void answer(false)); + + function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void { + bar.replaceChildren(); + for (const segment of progress(total, at, options.seen())) { + const stepTitle = steps[segment.step - 1]?.title ?? ""; + const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle); + const item = segment.link + ? document.createElement("a") + : document.createElement("span"); + item.className = `segment${segment.done ? " done" : ""}`; + if (item instanceof HTMLAnchorElement) { + item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false }); + item.setAttribute("aria-label", label); + } else { + item.setAttribute("aria-hidden", "true"); + } + if (segment.current) { + item.setAttribute("aria-current", "step"); + } + bar.append(item); + } + bar.setAttribute( + "aria-description", + at === "end" + ? HOME_TEXTS.tutorialEndOverline + : HOME_TEXTS.tutorialOverline(at.step, total, at.practice), + ); + } + + return { + async show(route) { + const id = ++shown; + clearTimer(); + busy = false; + practice = null; + illustration = null; + illustrated = false; + screen = null; + boards = null; + selected = null; + targets = new Map(); + solved = false; + feedback.textContent = ""; + feedback.dataset.verdict = ""; + task.hidden = true; + cube.hidden = true; + replay.hidden = true; + take.disabled = false; + drop.disabled = false; + let loaded: Tutorial; + try { + if (tutorial === null) { + title.textContent = HOME_TEXTS.tutorialLoading; + text.replaceChildren(); + svg.replaceChildren(); + } + loaded = await load(); + } catch { + if (id === shown) { + title.textContent = HOME_TEXTS.tutorialFailed; + } + return; + } + if (id !== shown) { + return; + } + const at = placeOf(loaded, route); + const wanted = routeHash(placeRoute(at)); + if (routeHash(route) !== wanted) { + options.replace(wanted); + return; + } + place = at; + options.visit(at); + const total = loaded.steps.length; + renderProgress(total, at, loaded.steps); + const before = previousPlace(loaded, at); + back.href = before === null ? "#/accueil" : routeHash(placeRoute(before)); + if (at === "end") { + overline.textContent = HOME_TEXTS.tutorialEndOverline; + title.textContent = HOME_TEXTS.tutorialEndTitle; + text.replaceChildren(); + for (const paragraph of loaded.end.paragraphs) { + fillParagraph(element(text, "p"), paragraph); + } + next.textContent = loaded.end.action; + next.href = "#/"; + next.classList.remove("secondary"); + screen = loaded.steps[0]?.explain ?? null; + } else { + const step = loaded.steps[at.step - 1]; + const current = at.practice ? step?.practice : step?.explain; + if (step === undefined || current == null) { + return; + } + screen = { ...current, path: current.path && !at.practice }; + overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice); + title.textContent = step.title; + text.replaceChildren(); + const paragraphs = at.practice ? [] : current.paragraphs; + for (const paragraph of paragraphs) { + fillParagraph(element(text, "p"), paragraph); + } + if (at.practice) { + task.hidden = false; + task.replaceChildren(); + for (const [i, paragraph] of current.paragraphs.entries()) { + if (i > 0) { + task.append(" "); + } + const span = document.createElement("span"); + fillParagraph(span, paragraph); + task.append(span); + } + } + const after = nextPlace(loaded, at); + next.textContent = HOME_TEXTS.next; + next.href = routeHash(placeRoute(after)); + next.classList.toggle("secondary", at.practice); + } + const currentScreen = screen; + if (currentScreen === null) { + return; + } + // Le plateau : la position, puis le coup illustré ou le geste à + // faire, tous lus par le moteur. + try { + await options.engineReady; + const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll); + if (id !== shown) { + return; + } + boards = shownBoard; + if ( + at !== "end" && + at.practice && + currentScreen.roll !== null && + currentScreen.expected.length > 0 + ) { + practice = await access.practice( + currentScreen.position, + currentScreen.roll, + currentScreen.expected, + ); + if (id !== shown) { + return; + } + await refreshTargets(); + } else if (at !== "end" && at.practice && currentScreen.answer !== null) { + cube.hidden = false; + feedback.textContent = HOME_TEXTS.computerDoubles; + feedback.dataset.verdict = "info"; + } else if ( + at !== "end" && + currentScreen.move !== null && + currentScreen.roll !== null + ) { + illustration = await access.illustrate( + currentScreen.position, + currentScreen.roll, + currentScreen.move, + ); + if (id !== shown) { + return; + } + replay.hidden = false; + replay.disabled = true; + autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS); + } + if (id !== shown) { + return; + } + draw(practice === null ? null : await practice.board()); + section.dataset.ready = String(id); + } catch (error) { + if (id === shown) { + feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`; + } + } + }, + relayout() { + if (place === null || busy) { + return; + } + if (practice !== null) { + void practice.board().then((sides) => draw(sides)); + } else { + draw(); + } + }, + hide() { + shown++; + clearTimer(); + busy = false; + }, + }; +} diff --git a/app/src/ui/updates.ts b/app/src/ui/updates.ts new file mode 100644 index 0000000..7e4201e --- /dev/null +++ b/app/src/ui/updates.ts @@ -0,0 +1,139 @@ +// 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. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +export interface Updates { + /** Une nouvelle version est installée et attend l'accord du joueur. */ + readonly available: boolean; + /** « Rechercher une mise à jour » : demande au navigateur de relire le + * service worker publié. found : une nouvelle version attend (elle a pu + * demander de télécharger tout le site) ; latest : rien de nouveau ; + * unavailable : pas de service worker (http hors localhost, navigateur + * qui le refuse) ou réseau absent. */ + check(): Promise<"found" | "latest" | "unavailable">; + /** Active la nouvelle version : la page se recharge dès qu'elle a pris + * la main. */ + apply(): void; +} + +/** Message au service worker en attente : prendre la main (sw.js). */ +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. + */ +export function updateBannerShown( + available: boolean, + moveInProgress: boolean, + otherBanner: boolean, +): boolean { + return available && !moveInProgress && !otherBanner; +} + +/** + * Enregistre le service worker une fois ready résolue (le moteur chargé : + * voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une + * nouvelle version attend. + */ +export function startUpdates( + script: URL, + ready: Promise, + onAvailable: () => void, +): Updates { + const container = "serviceWorker" in navigator ? navigator.serviceWorker : null; + let registration: ServiceWorkerRegistration | null = null; + let waiting: ServiceWorker | null = null; + + /** Une version installée attend, et une version plus ancienne a la + * main : c'est une mise à jour (à la toute première installation, + * aucune page n'est encore contrôlée, et rien n'attend). */ + const notice = (worker: ServiceWorker | null) => { + if (worker !== null && worker.state === "installed" && container?.controller) { + waiting = worker; + onAvailable(); + } + }; + + /** Suit une version en cours d'installation ; résolue quand elle est + * installée (vrai) ou abandonnée (faux). */ + const follow = (worker: ServiceWorker): Promise => + new Promise((resolve) => { + const settle = () => { + if (worker.state === "installed" || worker.state === "activated") { + notice(worker); + resolve(true); + } else if (worker.state === "redundant") { + resolve(false); + } + }; + worker.addEventListener("statechange", settle); + settle(); + }); + + if (container !== null) { + ready + .then(() => container.register(script)) + .then((reg) => { + registration = reg; + notice(reg.waiting); + if (reg.installing !== null) { + void follow(reg.installing); + } + reg.addEventListener("updatefound", () => { + if (reg.installing !== null) { + void follow(reg.installing); + } + }); + }) + .catch(() => { + // Refusé (http hors localhost) : l'application marche sans. + }); + } + + return { + get available() { + return waiting !== null; + }, + async check() { + const reg = registration; + if (reg === null) { + return "unavailable"; + } + try { + await reg.update(); + } catch { + return "unavailable"; + } + if (reg.waiting !== null) { + notice(reg.waiting); + return "found"; + } + if (reg.installing !== null) { + return (await follow(reg.installing)) ? "found" : "latest"; + } + return "latest"; + }, + apply() { + const worker = waiting ?? registration?.waiting ?? null; + if (container === null || worker === null) { + return; + } + let reloading = false; + container.addEventListener("controllerchange", () => { + if (!reloading) { + reloading = true; + location.reload(); + } + }); + worker.postMessage({ type: SKIP_WAITING }); + }, + }; +} diff --git a/app/tests/home.test.ts b/app/tests/home.test.ts new file mode 100644 index 0000000..12e0f31 --- /dev/null +++ b/app/tests/home.test.ts @@ -0,0 +1,84 @@ +// Tests de l'accueil et de l'invitation à installer, sans navigateur : +// premier lancement, règles d'apparition de la carte d'installation, +// réglages enregistrés par une version précédente. +// +// 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 { installPlatform } from "../src/ui/install.js"; +import { + DEFAULT_SETTINGS, + INSTALL_AGAIN_AFTER, + installCardShown, + isFirstLaunch, + parseSettings, + type Settings, +} from "../src/ui/settings.js"; + +test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => { + const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'); + assert.equal(old.welcomed, false); + assert.equal(old.gamesFinished, 0); + assert.equal(old.tutorialSeen, 0); + assert.equal(old.installed, false); + const saved = parseSettings( + '{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' + + '"installDismissedAt": 2, "installed": true}', + ); + assert.deepEqual(saved, { + ...DEFAULT_SETTINGS, + welcomed: true, + gamesFinished: 2, + tutorialSeen: 5, + installDismissals: 1, + installDismissedAt: 2, + installed: true, + }); + assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0); +}); + +test("premier lancement : tant que rien n'a été fait, une seule fois", () => { + assert.ok(isFirstLaunch(DEFAULT_SETTINGS)); + // La partie commencée d'office au chargement ne compte pas. + assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 })); + assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true })); + assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 })); + assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 })); + assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 })); +}); + +test("invitation à installer : après la première partie terminée, une seule relance", () => { + const at = (changes: Partial, installed = false) => + installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed); + assert.ok(!at({}), "jamais avant la première partie terminée"); + assert.ok(at({ gamesFinished: 1 })); + assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée"); + assert.ok(!at({ gamesFinished: 1, installed: true })); + // « Plus tard » : masquée, puis de retour après trois parties de plus. + const later = { installDismissals: 1, installDismissedAt: 1 }; + assert.equal(INSTALL_AGAIN_AFTER, 3); + assert.ok(!at({ ...later, gamesFinished: 1 })); + assert.ok(!at({ ...later, gamesFinished: 3 })); + assert.ok(at({ ...later, gamesFinished: 4 })); + // Une seconde fois « Plus tard » : plus jamais sur l'accueil. + assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 })); +}); + +test("la marche à suivre pour installer dépend du navigateur", () => { + const iphone = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15"; + const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0"; + const chrome = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/130.0 Mobile Safari/537.36"; + assert.equal(installPlatform(iphone), "ios"); + assert.equal(installPlatform(ipad), "ios"); + assert.equal(installPlatform(firefox), "firefox"); + // Sans invite du système, Chrome suit la marche à suivre de Firefox + // (menu ⋮, « Installer »), la plus proche de la sienne. + assert.equal(installPlatform(chrome), "firefox"); +}); diff --git a/app/tests/layout.test.ts b/app/tests/layout.test.ts index 1cb6348..a0eaa20 100644 --- a/app/tests/layout.test.ts +++ b/app/tests/layout.test.ts @@ -218,14 +218,18 @@ function sources(dir: string): string[] { } test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => { + // Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite + // d'installation de Chrome (install.ts), pas une boîte de dialogue. for (const path of sources("app/src/")) { const text = read(path); assert.doesNotMatch( text, - /\b(window\.)?(confirm|alert|prompt)\s*\(/, + /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m, `${path} appelle une boîte native`, ); } + assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m); + assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m); }); test("les deux blocs du thème sombre de app.css sont identiques", () => { diff --git a/app/tests/steps/accueil.steps.ts b/app/tests/steps/accueil.steps.ts new file mode 100644 index 0000000..c5f2c46 --- /dev/null +++ b/app/tests/steps/accueil.steps.ts @@ -0,0 +1,181 @@ +// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et +// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à +// installer passent par les modules de l'interface qui les décident +// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le +// vrai moteur, le tutoriel par son texte source. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { BeforeAll, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { Game } from "../../src/domain/game/game.js"; +import { + dismissInstall, + homeModel, + installMenuShown, + launchHash, + WELCOME_LINKS, + welcomed, +} from "../../src/ui/home-model.js"; +import { HOME_TEXTS } from "../../src/ui/home-texts.js"; +import { installPlatform } from "../../src/ui/install.js"; +import { parseRoute } from "../../src/ui/router.js"; +import { parseSettings, type Settings } from "../../src/ui/settings.js"; +import { placeOf } from "../../src/ui/tutorial-nav.js"; +import { parseTutorial } from "../../src/ui/tutorial-text.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { seededDice, step } from "../support/play.js"; + +const root = new URL("../../../../", import.meta.url); + +let engine: Engine; +let settings: Settings; +let game: Game | null = null; +let installed = false; +let route = ""; +let installSteps: readonly string[] = []; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +/** Ce que montre l'accueil, la partie étant celle du scénario. */ +function home() { + const playing = game === null ? false : game.state.phase !== "over"; + return homeModel(settings, playing, installed); +} + +// ------------------------------------------------------- Premier lancement + +Given("vous ouvrez Ustagammon pour la toute première fois", () => { + settings = parseSettings(null); + game = null; + installed = false; +}); + +Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => { + assert.equal(launchHash(""), "#/accueil"); + assert.equal(parseRoute(launchHash("") ?? "").screen, "home"); + assert.ok(home().welcome); + assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game"); +}); + +When("vous choisissez de découvrir les règles", () => { + settings = { ...settings, ...welcomed() }; + route = WELCOME_LINKS.discover; +}); + +Then("le tutoriel s'ouvre à sa première étape", () => { + const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8")); + assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false }); +}); + +Then("ce choix ne vous est plus proposé", () => { + assert.ok(!home().welcome); +}); + +Given("vous avez choisi de jouer tout de suite", () => { + settings = { ...parseSettings(null), ...welcomed() }; + installed = false; +}); + +Given("une partie est en cours", () => { + game = Game.start(engine, { dice: seededDice(11), ply: 0 }); + settings = { ...settings, gamesStarted: settings.gamesStarted + 1 }; +}); + +When("vous rouvrez Ustagammon", () => { + route = launchHash("") ?? ""; +}); + +Then("l'accueil vous propose de reprendre votre partie", () => { + assert.equal(parseRoute(route).screen, "home"); + assert.ok(home().resume); +}); + +When("la partie se termine", async () => { + assert.ok(game !== null); + for (let i = 0; game.state.phase !== "over"; i++) { + assert.ok(i < 2000, "la partie ne finit pas"); + await step(game, engine); + } +}); + +Then("l'accueil vous propose une nouvelle partie", () => { + assert.equal(parseRoute(route).screen, "home"); + assert.ok(!home().resume); +}); + +// ------------------------------------------------------------- Installation + +Given("votre navigateur ne propose pas lui-même l'installation", () => { + installSteps = []; +}); + +When("vous demandez à installer l'application avec Firefox", () => { + const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0"; + installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)]; +}); + +When("vous demandez à installer l'application sur iPhone", () => { + const iphone = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)]; +}); + +Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => { + assert.equal(installSteps.length, 3); + assert.match(installSteps[0] ?? "", /menu du navigateur/); +}); + +Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => { + assert.equal(installSteps.length, 3); + assert.match(installSteps[0] ?? "", /bouton de partage/); +}); + +Then("l'accueil ne vous propose pas d'installer l'application", () => { + assert.ok(!home().installCard); +}); + +Then("l'accueil vous propose d'installer l'application", () => { + assert.ok(home().installCard); +}); + +When("vous terminez une partie", () => { + settings = { ...settings, gamesFinished: settings.gamesFinished + 1 }; +}); + +When("vous terminez {int} parties de plus", (n: number) => { + settings = { ...settings, gamesFinished: settings.gamesFinished + n }; +}); + +When("vous choisissez « Plus tard »", () => { + settings = { ...settings, ...dismissInstall(settings) }; +}); + +Then("le menu vous le propose toujours", () => { + assert.ok(installMenuShown(settings, installed)); +}); + +Given("vous avez terminé une partie", () => { + settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 }; + installed = false; + assert.ok(home().installCard); +}); + +When("vous installez l'application", () => { + // L'événement appinstalled du navigateur, retenu dans les réglages. + installed = true; + settings = { ...settings, installed: true }; +}); + +Then("le menu non plus", () => { + assert.ok(!installMenuShown(settings, installed)); + // Même dans un onglet ouvert plus tard, hors de l'application installée. + assert.ok(!installMenuShown(settings, false)); + assert.ok(!homeModel(settings, true, false).installCard); +}); diff --git a/app/tests/steps/apprendre-les-regles.steps.ts b/app/tests/steps/apprendre-les-regles.steps.ts new file mode 100644 index 0000000..396ec7d --- /dev/null +++ b/app/tests/steps/apprendre-les-regles.steps.ts @@ -0,0 +1,132 @@ +// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) : +// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et +// chaque geste est jugé par le domaine branché sur le vrai moteur, comme +// dans l'application. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { BeforeAll, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { + MovePractice, + type PracticeVerdict, + shouldTake, + tutorialBoard, +} from "../../src/domain/learning/tutorial.js"; +import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js"; +import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +const root = new URL("../../../../", import.meta.url); + +let engine: Engine; +let tutorial: Tutorial; +let seen = 0; +let place: TutorialPlace = { step: 1, practice: false }; +let screen: TutorialScreen; +let practice: MovePractice | null = null; +let verdict: PracticeVerdict = "continue"; +let answeredRight = false; + +BeforeAll(async () => { + engine = await loadNodeEngine(); + tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8")); +}); + +Given("vous commencez le tutoriel", () => { + seen = 0; + place = { step: 1, practice: false }; + seen = seenAfter(seen, place); +}); + +Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => { + assert.equal(tutorial.steps.length, 8); + assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4); +}); + +Then("chaque position du tutoriel se lit sur le plateau", async () => { + for (const step of tutorial.steps) { + for (const current of [step.explain, step.practice]) { + if (current !== null) { + const board = await tutorialBoard(engine, current.position, current.onRoll); + assert.equal(board.sides.player.points.length, 24); + } + } + } +}); + +When("vous arrivez à l'étape {int}", (n: number) => { + for (let step = 1; step <= n; step++) { + place = { step, practice: false }; + seen = seenAfter(seen, place); + } +}); + +Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => { + const links = progress(tutorial.steps.length, place, seen) + .filter((segment) => segment.link) + .map((segment) => segment.step); + assert.deepEqual(links, [1, 2]); +}); + +Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => { + const found = tutorial.steps[n - 1]?.practice; + assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`); + screen = found; + practice = + screen.roll === null || screen.expected.length === 0 + ? null + : await MovePractice.start(engine, screen.position, screen.roll, screen.expected); + verdict = "continue"; + answeredRight = false; +}); + +When( + "vous déplacez un pion de la flèche {int} à la flèche {int}", + async (from: number, to: number) => { + assert.ok(practice !== null); + verdict = await practice.play({ from, to }); + if (verdict === "wrong") { + practice.reset(); + } + }, +); + +Then("le pion revient, et une phrase vous dit pourquoi", () => { + assert.equal(verdict, "wrong"); + assert.deepEqual(practice?.pendingHalfMoves, []); + assert.ok(screen.wrong.map((part) => part.text).join("").length > 20); +}); + +When("vous réunissez deux pions sur la flèche 5", async () => { + assert.ok(practice !== null); + verdict = await practice.play({ from: 8, to: 5 }); + assert.equal(verdict, "continue"); + verdict = await practice.play({ from: 6, to: 5 }); +}); + +Then("le tutoriel salue votre coup", () => { + assert.equal(verdict, "right"); + assert.ok(screen.right.length > 0); +}); + +When("vous répondez « Je passe »", async () => { + answeredRight = (await shouldTake(engine, screen.position)) === false; +}); + +When("vous répondez « Je prends »", async () => { + answeredRight = (await shouldTake(engine, screen.position)) === true; +}); + +Then("le maître dit qu'il aurait pris", () => { + assert.ok(!answeredRight); + assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/); +}); + +Then("le tutoriel salue votre réponse", () => { + assert.ok(answeredRight); + assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/); +}); diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index d1c048e..3c24d49 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -20,6 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js"; import { BANNER_MS } from "../../src/ui/app.js"; import { createHaptics } from "../../src/ui/haptics.js"; import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js"; +import { updateBannerShown } from "../../src/ui/updates.js"; import { loadNodeEngine } from "../support/node-engine.js"; setDefaultTimeout(30_000); @@ -163,3 +164,36 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => { } assert.deepEqual(seen, ["auto", "light", "dark", "auto"]); }); + +// ------------------------------------------------------------ Mise à jour + +// Le bandeau dépend de deux choses : une version attend (le service worker +// l'a installée), et aucun coup n'est en cours de saisie. La partie est +// celle du domaine, sur le vrai moteur. +let updateAvailable = false; + +Given("une nouvelle version d'Ustagammon est disponible", () => { + updateAvailable = true; +}); + +Given("vous avez déjà déplacé un pion de votre coup", async () => { + // Jet d'ouverture 3-1 : vous commencez. + game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 }); + assert.equal(game.state.turn, "player"); + assert.ok(await game.playHalfMove({ from: 8, to: 5 })); +}); + +Then("l'application ne vous propose pas encore de mettre à jour", () => { + assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); +}); + +When("vous avez joué votre coup", async () => { + assert.ok(await game.playHalfMove({ from: 6, to: 5 })); + await game.confirmMove(); +}); + +Then("l'application vous propose de mettre à jour", () => { + assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); + // Jamais par-dessus le bandeau « Reprendre l'ancienne ». + assert.ok(!updateBannerShown(updateAvailable, false, true)); +}); diff --git a/app/tests/tutorial.test.ts b/app/tests/tutorial.test.ts index 722ffb2..86eea06 100644 --- a/app/tests/tutorial.test.ts +++ b/app/tests/tutorial.test.ts @@ -36,10 +36,14 @@ test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous » const practice = step.practice; if (practice !== null) { assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`); - assert.ok(practice.right.length > 0 && practice.wrong.length > 0, - `étape ${step.number} : phrases juste et refus absentes`); - assert.ok(practice.expected.length > 0 || practice.answer !== null, - `étape ${step.number} : ni coup attendu, ni réponse au double`); + assert.ok( + practice.right.length > 0 && practice.wrong.length > 0, + `étape ${step.number} : phrases juste et refus absentes`, + ); + assert.ok( + practice.expected.length > 0 || practice.answer !== null, + `étape ${step.number} : ni coup attendu, ni réponse au double`, + ); } } }); @@ -53,7 +57,10 @@ test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => { const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term])); assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`); for (const term of terms) { - assert.ok(existsSync(new URL(`docs/glossaire/${term}.md`, root)), `fiche ${term}.md absente`); + assert.ok( + existsSync(new URL(`docs/glossaire/${term}.md`, root)), + `fiche ${term}.md absente`, + ); } }); @@ -68,7 +75,9 @@ test("les liens et le gras du texte sont lus", () => { }); test("un texte mal formé est refusé", () => { - assert.throws(() => parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n")); + assert.throws(() => + parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"), + ); assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n")); assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n")); assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n")); @@ -104,8 +113,12 @@ test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un continue; } assert.ok(screen.roll !== null, `${name} : coup sans jet`); - const { steps, after } = await illustratedMove(engine, screen.position, screen.roll, - screen.move); + const { steps, after } = await illustratedMove( + engine, + screen.position, + screen.roll, + screen.move, + ); assert.equal(steps.boards.length, steps.halfMoves.length + 1, name); if (name === "étape 7") { assert.deepEqual(after.result, { winner: "player", kind: "gammon" }); @@ -127,8 +140,12 @@ test("chaque écran « à vous » accepte ses coups attendus et refuse les autre assert.ok(screen.roll !== null, `${name} : pas de jet`); const legal = await engine.legalMoves(screen.position, screen.roll); for (const move of legal) { - const practice = await MovePractice.start(engine, screen.position, screen.roll, - screen.expected); + const practice = await MovePractice.start( + engine, + screen.position, + screen.roll, + screen.expected, + ); let verdict = "continue"; for (const halfMove of move.halfMoves) { verdict = await practice.play(halfMove); @@ -140,7 +157,10 @@ test("chaque écran « à vous » accepte ses coups attendus et refuse les autre assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`); } // Un coup attendu au moins, et un coup refusé au moins. - assert.ok(legal.some((m) => !screen.expected.includes(m.notation)), `${name} : rien à refuser`); + assert.ok( + legal.some((m) => !screen.expected.includes(m.notation)), + `${name} : rien à refuser`, + ); } }); @@ -171,9 +191,22 @@ test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => { halfMoves: halfMoves.map(([from, to]) => ({ from, to })), position: notation, }); - const expected = [move("8/5 6/5", [[8, 5], [6, 5]])]; + const expected = [ + move("8/5 6/5", [ + [8, 5], + [6, 5], + ]), + ]; assert.ok(canStillLeadTo([], expected)); assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected)); assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected)); - assert.ok(!canStillLeadTo([{ from: 8, to: 5 }, { from: 8, to: 5 }], expected)); + assert.ok( + !canStillLeadTo( + [ + { from: 8, to: 5 }, + { from: 8, to: 5 }, + ], + expected, + ), + ); }); diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index 6b01298..48815ac 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -19,7 +19,15 @@ import { pointColor, pointSlot, } from "../src/ui/board-model.js"; -import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js"; +import { FEATURES } from "../src/ui/help.js"; +import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js"; +import { + matchesSearch, + parseFeature, + parseGlossaryEntry, + parseGlossaryIndex, +} from "../src/ui/help-text.js"; +import { slug } from "../src/ui/router.js"; import { TEXTS } from "../src/ui/texts.js"; // Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut. @@ -143,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature")); assert.equal(feature.title, "Jouer une partie contre l'ordinateur"); assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/); - assert.equal(feature.scenarios[0]?.title, "Commencer une partie"); + assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?"); + assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA"); assert.deepEqual(feature.scenarios[0]?.steps, [ "Quand vous commencez une nouvelle partie", "Alors les pions sont en position de départ", "Et le premier jet de dés désigne qui commence", ]); - const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé"); - assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/); + const refused = feature.scenarios.find( + (s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?", + ); + assert.match( + refused?.description[0] ?? "", + /^Une flèche où l'ordinateur a au moins deux pions/, + ); assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1"); + assert.equal(feature.scenarios[2]?.position, null); // Chaque scénario a des étapes : aucune n'a été prise pour du texte libre. for (const scenario of feature.scenarios) { assert.ok(scenario.steps.length >= 2, scenario.title); } }); +test("aide : chaque scénario affiché est une question avec sa réponse", () => { + const listed = read("docs/scenarios/README.md"); + const ids = new Set(); + for (const url of FEATURES) { + assert.ok( + listed.includes(`](${url.replace("docs/scenarios/", "")})`), + `${url} hors de l'index`, + ); + const feature = parseFeature(read(url)); + assert.ok(feature.description.length > 0, `${url} : pas d'introduction`); + for (const scenario of feature.scenarios) { + // La réponse se lit seule : pas de vocabulaire technique. + assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`); + for (const word of ["scénario", "événement", "moteur", "Étant donné"]) { + assert.ok( + !scenario.description.join(" ").includes(word), + `${scenario.title} : « ${word} » dans la réponse`, + ); + } + const id = slug(scenario.title); + assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`); + ids.add(id); + } + } +}); + +test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => { + const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title)); + for (const title of Object.values(HELP_QUESTIONS)) { + assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`); + } + assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des"); + assert.equal(helpHash(null), "#/aide"); +}); + +test("glossaire : nom anglais, renvois, recherche sans accents", () => { + const entry = parseGlossaryEntry(read("docs/glossaire/camp.md")); + assert.ok(entry !== null); + assert.equal(entry.english, "Home board"); + assert.deepEqual( + entry.seeAlso.map((s) => s.name), + ["sortie", "barre", "point"], + ); + assert.ok(matchesSearch(entry, "camp")); + assert.ok(matchesSearch(entry, "HOME")); + assert.ok(matchesSearch(entry, "fleches sortir")); + assert.ok(!matchesSearch(entry, "videau")); +}); + test("l'aide lit chaque fiche de l'index du glossaire", () => { const files = parseGlossaryIndex(read("docs/glossaire/README.md")); assert.ok(files.includes("videau.md")); @@ -167,10 +231,13 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => { assert.ok(entry !== null, file); assert.ok(entry.brief.length > 10, file); } - assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), { - term: "Videau", - brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.", - }); + const videau = parseGlossaryEntry(read("docs/glossaire/videau.md")); + assert.equal(videau?.term, "Videau"); + assert.equal(videau?.english, "Cube"); + assert.equal( + videau?.brief, + "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.", + ); assert.equal(parseGlossaryEntry("pas une fiche"), null); }); diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 0c16079..ec4349b 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -33,6 +33,15 @@ Fonctionnalité: Et ``` +Dans l'aide, chaque scénario devient une question repliable : son titre est +la question, formulée comme le joueur se la pose (« Comment jouer un +coup ? ») ; le paragraphe sous le titre est la réponse, qui doit se lire +seule ; les étapes ne s'affichent pas. Une étiquette +`@position:` au-dessus du scénario ajoute à la réponse le lien +« Voir sur le plateau ». L'adresse d'une question (`#/aide/`) +est tirée de son titre : changer un titre change son adresse, et les titres +cités par `app/src/ui/help-context.ts` sont vérifiés par `make test`. + Règles d'écriture : - la deuxième personne (« vous ») et les mots du glossaire (`docs/glossaire/`) ; @@ -49,4 +58,7 @@ 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 | +| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e | +| [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 | +| [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 | +| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 | diff --git a/docs/scenarios/apprendre-les-regles.feature b/docs/scenarios/apprendre-les-regles.feature new file mode 100644 index 0000000..96b362c --- /dev/null +++ b/docs/scenarios/apprendre-les-regles.feature @@ -0,0 +1,42 @@ +# language: fr +Fonctionnalité: Apprendre les règles avec le tutoriel + + Le tutoriel explique les règles en huit petites étapes, sur le vrai + plateau : les pions bougent pour montrer chaque règle, et une étape sur + deux vous fait jouer le geste vous-même. GNU Backgammon vérifie chaque + coup, comme en partie. + + Scénario: Comment se passe le tutoriel ? + Chaque étape tient en un écran court : un titre, le plateau, deux ou + trois phrases. Le coup montré se joue tout seul après une seconde ; + « Revoir » le rejoue. La barre du haut montre où vous en êtes et vous + ramène aux étapes déjà vues. « Passer » vous emmène jouer. + + Étant donné que vous commencez le tutoriel + Alors il compte huit étapes, dont quatre où c'est à vous de jouer + Et chaque position du tutoriel se lit sur le plateau + Quand vous arrivez à l'étape 3 + Alors la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes + + @position:4HPwATDgc/ABMA + Scénario: Je joue un autre coup que celui demandé + Sur les écrans « à vous », vous jouez sur le vrai plateau. Si votre coup + n'est pas celui demandé, le pion revient et une phrase vous dit + pourquoi ; réessayez autant que vous voulez. Le bon coup est salué, et + « Continuer » vous mène à la suite. + + Étant donné que vous êtes à l'écran « à vous » de l'étape 3 + Quand vous déplacez un pion de la flèche 24 à la flèche 21 + Alors le pion revient, et une phrase vous dit pourquoi + Quand vous réunissez deux pions sur la flèche 5 + Alors le tutoriel salue votre coup + + Scénario: Comment répondre au double dans le tutoriel ? + À la dernière étape, l'ordinateur vous propose de doubler. Prenez ou + passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi. + + Étant donné que vous êtes à l'écran « à vous » de l'étape 8 + Quand vous répondez « Je passe » + Alors le maître dit qu'il aurait pris + Quand vous répondez « Je prends » + Alors le tutoriel salue votre réponse diff --git a/docs/scenarios/decouvrir-l-application.feature b/docs/scenarios/decouvrir-l-application.feature new file mode 100644 index 0000000..d454754 --- /dev/null +++ b/docs/scenarios/decouvrir-l-application.feature @@ -0,0 +1,31 @@ +# language: fr +Fonctionnalité: Découvrir l'application + + À la toute première ouverture, Ustagammon vous demande d'où vous partez, + puis s'efface : ensuite, il s'ouvre sur l'accueil, avec votre partie en + cours à reprendre d'un toucher. + + @position:4HPwATDgc/ABMA + Scénario: Que me propose l'application à la première ouverture ? + Deux choix, et c'est tout : « Je découvre » ouvre le tutoriel des règles, + « Je connais les règles » lance une partie tout de suite. Ce choix ne + revient plus ; le tutoriel reste dans le menu. + + Étant donné que vous ouvrez Ustagammon pour la toute première fois + Alors l'accueil vous propose de découvrir les règles ou de jouer tout de suite + Quand vous choisissez de découvrir les règles + Alors le tutoriel s'ouvre à sa première étape + Et ce choix ne vous est plus proposé + + Scénario: Où retrouver ma partie en rouvrant l'application ? + L'application s'ouvre sur l'accueil. Votre partie en cours y est, avec + son plateau et les comptes de points : « Reprendre la partie » vous y + ramène. Sans partie en cours, « Nouvelle partie » en commence une. + + Étant donné que vous avez choisi de jouer tout de suite + Et qu'une partie est en cours + Quand vous rouvrez Ustagammon + Alors l'accueil vous propose de reprendre votre partie + Quand la partie se termine + Et que vous rouvrez Ustagammon + Alors l'accueil vous propose une nouvelle partie diff --git a/docs/scenarios/installer-l-application.feature b/docs/scenarios/installer-l-application.feature new file mode 100644 index 0000000..093809f --- /dev/null +++ b/docs/scenarios/installer-l-application.feature @@ -0,0 +1,46 @@ +# language: fr +Fonctionnalité: Installer l'application + + Installé, Ustagammon a son icône sur l'écran d'accueil du téléphone, + s'ouvre en plein écran et marche sans réseau. L'application vous le + propose au bon moment, une fois, sans jamais interrompre une partie. + + Scénario: Comment installer l'application ? + Touchez « Installer » sur l'accueil, ou « Installer l'application » dans + le menu. Avec Chrome, le téléphone vous demande de confirmer. Avec + Firefox ou sur iPhone, l'application vous montre la marche à suivre, en + trois étapes. + + Étant donné que votre navigateur ne propose pas lui-même l'installation + Quand vous demandez à installer l'application avec Firefox + Alors la marche à suivre compte trois étapes, en commençant par le menu du navigateur + Quand vous demandez à installer l'application sur iPhone + Alors la marche à suivre compte trois étapes, en commençant par le bouton de partage + + Scénario: Quand l'application me propose-t-elle de l'installer ? + Jamais à la première ouverture ni pendant une partie : l'invitation + apparaît sur l'accueil après votre première partie terminée. « Plus + tard » la masque ; elle revient une seule fois, trois parties plus tard. + Le menu garde « Installer l'application » tant qu'elle n'est pas + installée. + + Étant donné que vous ouvrez Ustagammon pour la toute première fois + Alors l'accueil ne vous propose pas d'installer l'application + Quand vous terminez une partie + Alors l'accueil vous propose d'installer l'application + Quand vous choisissez « Plus tard » + Alors l'accueil ne vous propose pas d'installer l'application + Mais le menu vous le propose toujours + Quand vous terminez 3 parties de plus + Alors l'accueil vous propose d'installer l'application + Quand vous choisissez « Plus tard » + Et que vous terminez 3 parties de plus + Alors l'accueil ne vous propose pas d'installer l'application + + Scénario: Et une fois l'application installée ? + Une fois Ustagammon installé, plus rien ne vous propose de l'installer. + + Étant donné que vous avez terminé une partie + Quand vous installez l'application + Alors l'accueil ne vous propose pas d'installer l'application + Et le menu non plus diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 22fbb41..a19af74 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -1,16 +1,22 @@ # language: fr Fonctionnalité: Jouer une partie contre l'ordinateur - Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur le - moteur GNU Backgammon. L'application tire les dés, n'accepte que les coups + Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur + GNU Backgammon. L'application tire les dés, n'accepte que les coups permis par les règles, et l'ordinateur joue et décide du videau tout seul. - Scénario: Commencer une partie + @position:4HPwATDgc/ABMA + Scénario: Comment commence une partie ? + Les pions sont en position de départ. Chaque camp lance un dé : celui + qui a le plus fort commence, et joue ces deux dés. En cas d'égalité, on + relance. + Quand vous commencez une nouvelle partie Alors les pions sont en position de départ Et le premier jet de dés désigne qui commence - Scénario: Jouer un coup permis + @position:4HPwATDgc/ABMA + Scénario: Comment jouer un coup ? Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté @@ -24,16 +30,17 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Alors votre coup est accepté Et c'est ensuite au tour de l'ordinateur - Scénario: Un pion qui n'a qu'une case d'arrivée - Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus - qu'une case d'arrivée, et il y va dès que vous le touchez. + Scénario: Pourquoi mon pion part-il dès que je le touche ? + Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le + touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois + 8/5 joué, il ne vous reste que le 1. Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous jouez 8/5 Alors seul votre 3 est marqué comme joué Et chacun de vos pions jouables n'a plus qu'une case d'arrivée - Scénario: Reprendre un pion avant de valider + Scénario: Je me suis trompé de pion Tant que vous n'avez pas validé votre coup, « Annuler » reprend le dernier pion déplacé ; touchez-le encore pour reprendre le précédent. @@ -45,21 +52,27 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Alors la position n'a pas changé Et aucun de vos dés n'est marqué comme joué - Scénario: Un coup interdit est refusé - La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne - pouvez pas vous y poser. + @position:4HPwATDgc/ABMA + Scénario: Pourquoi je ne peux pas poser mon pion sur cette flèche ? + Une flèche où l'ordinateur a au moins deux pions est fermée : vous ne + pouvez pas vous y poser. Au départ, par exemple, la flèche 12 est tenue + par cinq de ses pions. Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous essayez de jouer 13/12 Alors votre coup est refusé Et la position n'a pas changé - Scénario: L'ordinateur joue + Scénario: Comment joue l'ordinateur ? + L'ordinateur lance ses dés et joue le coup que GNU Backgammon, l'un des + meilleurs programmes de backgammon, juge le meilleur. Il répond en + moins de deux secondes. + Étant donné que c'est au tour de l'ordinateur Quand ses dés sont tirés Alors il joue le coup recommandé par le moteur en moins de 2 secondes - Scénario: Aucun coup possible avec votre jet + Scénario: Je ne peux rien jouer avec mes dés Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste affiché, barré, avec un message. L'application ne joue jamais à votre place : le tour ne passe à l'ordinateur que quand vous touchez vos dés @@ -72,7 +85,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Quand vous laissez passer votre tour Alors c'est ensuite au tour de l'ordinateur - Scénario: Voir le coup de l'ordinateur + Scénario: Qu'a joué l'ordinateur ? Après chaque coup de l'ordinateur, ses pions glissent un par un sur le plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils @@ -84,11 +97,13 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et votre pion frappé part sur la barre Et vous pouvez revoir son coup - Scénario: Voir le compte de points - Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son - compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la - course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les - comptes sont aux deux bouts de la barre. + Scénario: À quoi sert le compte de points ? + Le compte de points dit combien de cases vos pions doivent encore + parcourir avant de sortir : le plus petit mène la course. Au-dessus et + au-dessous du plateau, le bandeau de chaque camp donne son compte, et + « mène de 7 » s'affiche du côté de celui qui mène. Touchez un compte pour + lire ce qu'il veut dire. En paysage, les comptes sont aux deux bouts de + la barre. Quand vous commencez une nouvelle partie Alors chaque camp a un compte de points de 167 @@ -96,7 +111,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Quand vous jouez 13/8 13/11 Alors vous menez la course de 7 - Scénario: Doubler, après confirmation + Scénario: Comment doubler ? Avant de lancer les dés, vous pouvez doubler en touchant le videau ou « Doubler ». L'application vous demande de confirmer : une fois proposé, un double ne se reprend pas. @@ -109,19 +124,29 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et que vous confirmez Alors l'ordinateur prend ou passe, comme le recommande le moteur - Scénario: Prendre le double de l'ordinateur + Scénario: L'ordinateur double : que se passe-t-il si je prends ? + Si vous prenez, la partie continue et vaut deux fois plus. Le videau + passe de votre côté : vous seul pourrez redoubler. + Étant donné que l'ordinateur vous propose de doubler Quand vous prenez Alors la partie continue à valeur doublée Et le videau est de votre côté : vous seul pourrez redoubler - Scénario: Passer le double de l'ordinateur + Scénario: L'ordinateur double : que se passe-t-il si je passe ? + Passer, c'est abandonner la partie tout de suite : vous la perdez à sa + valeur d'avant le double. + Étant donné que l'ordinateur vous propose de doubler Quand vous passez Alors la partie s'arrête Et vous perdez la partie à sa valeur d'avant le double - Scénario: Finir la partie + Scénario: Comment se termine une partie ? + La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de + fin dit qui gagne, et combien de points : la valeur du videau, doublée + pour un gammon et triplée pour un backgammon, une fois le videau doublé. + Étant donné qu'un joueur a sorti tous ses pions Alors la partie s'arrête Et le résultat indique le gagnant et les points gagnés diff --git a/docs/scenarios/mes-parties.feature b/docs/scenarios/mes-parties.feature index b353647..befa97e 100644 --- a/docs/scenarios/mes-parties.feature +++ b/docs/scenarios/mes-parties.feature @@ -7,7 +7,11 @@ Fonctionnalité: Retrouver mes parties une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF pour l'ouvrir dans GNU Backgammon. - Scénario: Reprendre une partie parmi plusieurs + Scénario: Comment reprendre une autre partie ? + Toutes vos parties restent sur le téléphone. Dans « Mes parties », depuis + le menu, la dernière jouée vient en premier : « Reprendre » vous ramène à + une partie telle que vous l'aviez laissée. + Étant donné que vous avez joué deux parties, l'une après l'autre Quand vous fermez l'application puis la rouvrez Alors la dernière partie jouée est à l'écran @@ -15,7 +19,7 @@ Fonctionnalité: Retrouver mes parties Quand vous reprenez la première partie Alors elle est à l'écran, telle que vous l'aviez laissée - Scénario: Rejouer d'ici sans perdre l'original + Scénario: Puis-je rejouer un moment de la partie autrement ? Revenez à un moment passé de la partie et jouez autre chose : une nouvelle branche commence là, et la partie d'origine reste entière. @@ -28,7 +32,11 @@ Fonctionnalité: Retrouver mes parties Alors vous êtes sur la nouvelle branche Et vous pouvez revenir à la partie d'origine, au coup près - Scénario: Exporter une partie + Scénario: Comment garder une partie dans un fichier ? + Dans « Mes parties », « Exporter (SGF) » enregistre la partie dans un + fichier que GNU Backgammon sait ouvrir ; les suites rejouées y sont des + variantes. + Étant donné que vous avez joué plusieurs coups Et que vous avez rejoué d'ici une autre suite Quand vous exportez la partie diff --git a/docs/scenarios/partager-une-position.feature b/docs/scenarios/partager-une-position.feature index 7155076..b6c16d3 100644 --- a/docs/scenarios/partager-une-position.feature +++ b/docs/scenarios/partager-une-position.feature @@ -6,7 +6,7 @@ Fonctionnalité: Partager une position 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 + Scénario: Comment partager une 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. @@ -17,7 +17,7 @@ Fonctionnalité: Partager une position 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 + Scénario: On m'a envoyé le lien d'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 @@ -33,7 +33,7 @@ Fonctionnalité: Partager une position 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é + Scénario: Le lien d'une position ne s'ouvre pas Un lien coupé ou mal recopié ne casse rien : un message le dit, et « Retour à ma partie » reste là. diff --git a/docs/scenarios/regler-l-application.feature b/docs/scenarios/regler-l-application.feature index ee704ac..9a56668 100644 --- a/docs/scenarios/regler-l-application.feature +++ b/docs/scenarios/regler-l-application.feature @@ -7,7 +7,7 @@ Fonctionnalité: Régler l'application l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de question : ce qui se fait se rattrape. - Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne + Scénario: J'ai commencé une nouvelle partie par erreur Une nouvelle partie commence tout de suite, sans question. Pendant 10 secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que vous venez de quitter, exactement là où vous l'aviez laissée. @@ -19,7 +19,7 @@ Fonctionnalité: Régler l'application Quand vous reprenez l'ancienne partie Alors vous retrouvez exactement la partie quittée - Scénario: Couper les vibrations + Scénario: Comment couper les vibrations ? Le téléphone vibre brièvement quand votre pion se pose, quand un pion est frappé, quand l'ordinateur vous propose de doubler et à la fin de la partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien @@ -32,7 +32,7 @@ Fonctionnalité: Régler l'application Et qu'un pion est frappé Alors le téléphone ne vibre pas - Scénario: Garder l'écran allumé pendant la partie + Scénario: Pourquoi l'écran ne se met-il pas en veille ? Pour que le téléphone ne se mette pas en veille pendant que vous réfléchissez, l'écran reste allumé tant qu'une partie est en cours. @@ -41,8 +41,24 @@ Fonctionnalité: Régler l'application Quand vous coupez « Écran allumé pendant la partie » dans le menu Alors l'écran peut se mettre en veille comme d'habitude - Scénario: Choisir le thème + Scénario: Comment passer en thème sombre ? + Le thème suit d'abord celui du téléphone. Dans le menu, « Thème » passe à + clair, puis à sombre, puis revient à automatique. + Étant donné que vous ouvrez l'application pour la première fois Alors le thème suit celui du téléphone Quand vous changez de thème dans le menu Alors il passe à clair, puis à sombre, puis revient à automatique + + Scénario: Comment mettre l'application à jour ? + Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la + télécharge d'elle-même, puis affiche « Nouvelle version disponible », + jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et + vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à + jour » vérifie tout de suite s'il y en a une. + + Étant donné qu'une nouvelle version d'Ustagammon est disponible + Et que vous avez déjà déplacé un pion de votre coup + Alors l'application ne vous propose pas encore de mettre à jour + Quand vous avez joué votre coup + Alors l'application vous propose de mettre à jour diff --git a/tests/test_site.sh b/tests/test_site.sh index dbadebb..dcfd41c 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -83,6 +83,15 @@ check "docs/glossaire/README.md" "text/markdown" check "docs/glossaire/videau.md" "text/markdown" cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \ || report "/docs/glossaire/videau.md diffère de la source" +# Tutoriel : copie de sa source unique (ADR 0008). +check "docs/tutoriel/regles.md" "text/markdown" +cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \ + || report "/docs/tutoriel/regles.md diffère de la source" +for feature in apprendre-les-regles decouvrir-l-application installer-l-application; do + check "docs/scenarios/$feature.feature" "text/plain" +done +# Le service worker attend l'accord du joueur avant de prendre la main. +grep -q 'skip-waiting' build/site/sw.js || report "sw.js : pas d'attente de l'accord du joueur" # Le manifeste désigne des icônes servies, et l'application à la racine. manifest="$(curl -s "$base/manifest.webmanifest")" diff --git a/web/app.css b/web/app.css index 11a94cd..fcc7352 100644 --- a/web/app.css +++ b/web/app.css @@ -1521,3 +1521,604 @@ html.landscape .position-board { width: 20px; height: 20px; } + +/* ------------------------------------------- Accueil, tutoriel, aide */ + +/* Maquettes « Étape 0e.5 » et « Accueil » ; règles : + * docs/identite/charte/accueil-tutoriel-aide.md. */ + +.brand-link { + display: flex; + align-items: center; + min-height: 44px; +} + +/* Le tutoriel et le premier lancement ont leur propre en-tête. */ +html.bare .topbar { + display: none; +} + +.home, +.tutorial { + max-width: var(--page-width); + margin: 0 auto; + padding-bottom: var(--space-6); +} + +.overline { + margin: 0; + font-size: 13px; + line-height: 16px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.muted { + color: var(--muted); +} + +.caption { + margin: 0; + font-size: 13px; + line-height: 18px; + color: var(--muted); +} + +.card { + display: block; + padding: var(--space-4); + color: var(--text); + text-decoration: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-raised); +} + +/* Une étiquette d'action dans une carte-lien, en contour. */ +.pill { + display: inline-flex; + flex: none; + align-items: center; + min-height: 44px; + padding: 0 16px; + font-weight: 700; + color: var(--text); + border: 2px solid var(--border-strong); + border-radius: var(--radius-pill); +} + +/* Premier lancement. */ +.welcome { + display: flex; + flex-direction: column; + gap: var(--space-6); + min-height: 100vh; + min-height: 100dvh; + padding: var(--space-6) 0 var(--space-6); +} + +.welcome-logo { + margin: 0; +} + +.welcome-logo img { + height: 34px; + width: auto; +} + +.welcome-board { + margin-inline: calc(-1 * var(--space-2)); + width: calc(100% + 2 * var(--space-2)); +} + +.welcome-text h2, +.home-intro h2 { + margin: 0 0 var(--space-2); + font-size: 32px; + line-height: 36px; + font-weight: 800; + letter-spacing: -0.01em; +} + +.welcome-text p, +.home-intro p { + margin: 0; +} + +.welcome-choices { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: auto; +} + +.choice { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 64px; + padding: 10px 20px; + color: var(--text); + text-decoration: none; + border: 2px solid var(--border-strong); + border-radius: 16px; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.choice span { + font-size: 15px; + line-height: 20px; + color: var(--muted); +} + +.choice.primary { + color: var(--on-accent); + background: var(--accent); + border-color: var(--accent); +} + +.choice.primary span { + color: var(--on-accent); +} + +.choice:active { + transform: translateY(1px); + filter: brightness(0.94); +} + +.welcome-choices .caption { + margin-top: var(--space-1); + text-align: center; +} + +/* Accueil. */ +.home-main { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: var(--space-2); +} + +.home-game { + display: flex; + flex-direction: column; + gap: 14px; +} + +.home-game-top { + display: flex; + gap: 14px; + align-items: center; +} + +.home-mini { + flex: none; + width: 132px; +} + +/* La miniature ne porte pas les numéros des flèches, illisibles à cette + * taille. */ +.home-board .point-number { + display: none; +} + +.home-facts { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.home-pips { + font-size: 15px; + line-height: 20px; + color: var(--muted); + font-variant-numeric: tabular-nums lining-nums; +} + +.home-actions { + display: flex; + gap: var(--space-2); +} + +.home-actions > * { + flex: 1 1 0; + min-width: 0; + padding: 0 12px; +} + +.install-card { + display: flex; + gap: var(--space-3); + align-items: center; + margin-top: calc(-1 * var(--space-2)); + padding: 10px 4px 10px 14px; + background: var(--surface-sunken); + border-radius: var(--radius-md); +} + +.install-card .install-symbol { + flex: none; + width: 32px; + height: 32px; +} + +.install-text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + font-size: 15px; + line-height: 20px; +} + +.install-card button.secondary { + flex: none; + min-height: 44px; + padding: 0 14px; + background: var(--surface); +} + +.install-card .icon-button .icon { + width: 18px; + height: 18px; + color: var(--muted); +} + +.home-rules { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.home-rules-text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.home-rules-text .muted { + font-size: 15px; + line-height: 20px; +} + +.home-progress:empty { + display: none; +} + +.home-footer { + display: flex; + gap: 10px; + align-items: center; + margin-top: var(--space-4); + font-size: 13px; + line-height: 18px; + color: var(--muted); +} + +.home-footer img { + flex: none; + width: 28px; + height: 28px; +} + +/* Feuille d'installation. */ +.sheet-head .install-symbol { + width: 40px; + height: 40px; + margin-right: var(--space-3); +} + +#install-sheet .sheet-title { + flex: 1; +} + +.install-lead { + margin: var(--space-2) 0 var(--space-4); + color: var(--muted); +} + +.install-steps { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; +} + +.install-steps li { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} + +.step-number { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-weight: 800; + font-variant-numeric: tabular-nums lining-nums; + background: var(--accent-soft); + border-radius: 50%; +} + +.install-done { + width: 100%; +} + +/* Bandeau de mise à jour : en haut, discret, jamais sur la barre + * d'actions ni sur « Reprendre l'ancienne ». */ +.update-banner { + top: var(--space-2); + bottom: auto; +} + +/* Tutoriel. */ +.tutorial-head { + display: flex; + gap: var(--space-3); + align-items: center; + min-height: 52px; + padding-top: var(--space-2); +} + +.tutorial-progress { + display: grid; + flex: 1; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: var(--space-1); +} + +/* Un segment : 4 px de trait, dans une zone de 44 px de haut quand c'est + * un lien vers une étape déjà vue. */ +.tutorial-progress .segment { + position: relative; + display: block; + height: 44px; +} + +.tutorial-progress .segment::before { + content: ""; + position: absolute; + top: 20px; + left: 0; + right: 0; + height: 4px; + background: var(--border); + border-radius: var(--radius-pill); +} + +.tutorial-progress .segment.done::before { + background: var(--accent); +} + +.tutorial-progress .segment[aria-current]::before { + top: 19px; + height: 6px; +} + +.tutorial-skip { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 15px; + font-weight: 500; + color: var(--muted); +} + +.tutorial-title { + margin: var(--space-1) 0 var(--space-3); + font-size: 24px; + line-height: 30px; +} + +.tutorial-board-box { + position: relative; +} + +@media (max-width: 40rem) { + .tutorial-board-box { + margin-inline: calc(var(--space-2) - var(--space-4)); + } +} + +#tutorial-replay { + position: absolute; + right: var(--space-1); + bottom: calc(-44px - var(--space-1)); + color: var(--accent); +} + +.tutorial-text { + margin-top: var(--space-3); + padding-right: 52px; +} + +.tutorial-text p { + margin: 0 0 10px; +} + +.tutorial-task { + margin: var(--space-3) 0 0; + padding: 10px 14px; + font-weight: 700; + background: var(--accent-soft); + border-radius: var(--radius-md); +} + +.tutorial-feedback { + display: flex; + gap: var(--space-2); + align-items: flex-start; + min-height: 25px; + margin: var(--space-3) 0 0; +} + +.tutorial-feedback:empty { + min-height: 0; +} + +.tutorial-feedback[data-verdict="right"] { + font-weight: 700; +} + +.tutorial-feedback[data-verdict="right"] .icon { + color: var(--verdict-good); +} + +.tutorial-feedback[data-verdict="info"] { + color: var(--muted); +} + +.tutorial-cube { + display: flex; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.tutorial-cube button { + flex: 1 1 0; +} + +.tutorial-nav { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: var(--space-2); + margin-top: var(--space-6); +} + +.tutorial-nav .button { + min-width: 0; +} + +/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique + * (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les + * deux thèmes : le liseré reste lisible sur le bois. */ +.tutorial-path path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +.tutorial-path .path-edge { + stroke: var(--brand-ivoire); +} + +.tutorial-path .path-dots, +.tutorial-path .path-head { + stroke: var(--brand-noyer); +} + +/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */ +html.landscape .tutorial-board, +html.landscape .welcome-board { + width: min(100%, calc(70vh * 640 / 520)); + height: auto; + margin-inline: auto; + aspect-ratio: 640 / 520; +} + +html.landscape .welcome { + min-height: 0; +} + +/* Aide. */ +.help-tutorial { + display: flex; + gap: var(--space-3); + align-items: center; + margin-top: var(--space-4); +} + +.help-tutorial-text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.help-tutorial-text .muted { + font-size: 15px; + line-height: 20px; +} + +.help-intro { + margin-top: var(--space-4); +} + +.see-board { + display: inline-flex; + align-items: center; + min-height: 44px; + font-weight: 500; +} + +.glossary-search { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin-top: var(--space-3); +} + +.search-label { + font-size: 15px; + line-height: 20px; + font-weight: 500; +} + +.glossary-search input { + min-height: 48px; + padding: 0 var(--space-3); + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + user-select: text; + -webkit-user-select: text; +} + +.term-card + .term-card { + border-top: 1px solid var(--border); +} + +.glossary .english { + margin-left: var(--space-2); + font-size: 13px; + font-style: italic; + font-weight: 400; + color: var(--muted); +} + +.glossary dd p { + margin: 0 0 var(--space-1); +} + +.see-also { + font-size: 15px; + line-height: 20px; + color: var(--muted); +} + +.glossary .term-card.current { + margin-inline: calc(-1 * var(--space-4)); + padding-inline: var(--space-4); + background: var(--accent-soft); +} + +.glossary .term-card.current dt, +.glossary .term-card.current dd { + margin-inline: 0; + padding-inline: 0; + background: none; +} diff --git a/web/index.html b/web/index.html index 43dae8d..dcd8aa9 100644 --- a/web/index.html +++ b/web/index.html @@ -26,8 +26,10 @@

- Ustagammon - Ustagammon + + Ustagammon + Ustagammon +

@@ -82,6 +84,11 @@ + + + +