diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index 6e19288..d3207b6 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -18,7 +18,7 @@ import { } from "../domain/game/advice.js"; import type { Game } from "../domain/game/game.js"; import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js"; -import { askAiLink } from "./ask-ai.js"; +import { askAiLink, clearSheet } from "./ask-ai.js"; import { type IconName, icon } from "./icons.js"; import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js"; import { routeHash } from "./router.js"; @@ -48,6 +48,8 @@ export interface AdviceView { /** Ouvre la feuille sur le conseil de l'événement eventId. */ show(eventId: string): Promise; hide(): void; + /** La feuille, où la page du prompt prend la place du contenu (1f). */ + readonly sheet: HTMLElement; } type Shown = "mine" | "best"; @@ -92,7 +94,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { const id = ++opened; const game = options.game(); const advice = game?.advice[eventId] ?? null; - sheet.replaceChildren(); + clearSheet(sheet); veil.hidden = false; const head = element(sheet, "div", { className: "sheet-head" }); element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true"); @@ -332,12 +334,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { return { show, + sheet, hide() { opened++; player?.stop(); if (!veil.hidden) { veil.hidden = true; - sheet.replaceChildren(); + clearSheet(sheet); } }, }; diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 533c368..6a5a37d 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -246,7 +246,6 @@ export function startApp(options: AppOptions): void { explainVeil: byId("explain-veil"), summaryVeil: byId("summary-veil"), promptVeil: byId("prompt-veil"), - promptBanner: byId("prompt-banner"), analysis: byId("analysis"), menuAdvice: byId("menu-advice"), prompt: byId("prompt"), @@ -1319,7 +1318,9 @@ export function startApp(options: AppOptions): void { // ------------------------------------------- Demander à une IA (1f) - // La feuille du prompt, à l'adresse …/ia, au-dessus de l'écran d'où on le demande. + // La feuille du prompt, à l'adresse …/ia : à la place de la feuille du + // maître ou du bilan, ou dans sa propre feuille au-dessus de l'analyse et + // du glossaire. Une seule feuille à la fois. const promptSource = createPromptSource({ access: options.prompts, phrases: () => phrasesLoading, @@ -1327,13 +1328,14 @@ export function startApp(options: AppOptions): void { }); const promptView = createPromptView({ veil: ui.promptVeil, - banner: ui.promptBanner, - close: () => closePrompt(), + back: () => closePrompt(), + close: () => closePromptSheet(), }); // L'adresse de la feuille ouverte (null : fermée). let promptShown: string | null = null; - /** Ferme la feuille du prompt : retour à l'écran d'où on l'a ouverte. */ + /** « ‹ Explication », Échap ou le retour du téléphone : retour à l'écran + * d'où on l'a ouverte. */ function closePrompt(): void { const route = navigation.route; promptView.hide(); @@ -1341,10 +1343,33 @@ export function startApp(options: AppOptions): void { navigation.leave(isAiRoute, isAiRoute(route) ? routeHash(withoutAi(route)) : "#/"); } + /** La croix et « Fermer » : la feuille se ferme. Celle du maître ou le + * bilan, dont elle avait pris la place, se ferme avec elle. */ + function closePromptSheet(): void { + const route = navigation.route; + if (route.screen === "explanation" || route.screen === "summary") { + promptView.hide(); + promptShown = null; + leaveSheet(); + return; + } + closePrompt(); + } + /** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */ function showPrompt(route: AiRoute): void { promptShown = routeHash(route); - promptView.show(promptOf(route)); + const host = + route.screen === "explanation" + ? adviceView.sheet + : route.screen === "summary" + ? summaryView.panel + : null; + promptView.show(promptOf(route), { + host, + back: ASK_AI_TEXTS.back[route.screen], + backHref: routeHash(withoutAi(route)), + }); } /** Le journal de la partie id, pour son bilan ou son analyse : celui de diff --git a/app/src/ui/ask-ai-texts.ts b/app/src/ui/ask-ai-texts.ts index 29b7b49..7ba435a 100644 --- a/app/src/ui/ask-ai-texts.ts +++ b/app/src/ui/ask-ai-texts.ts @@ -10,11 +10,18 @@ export const ASK_AI_TEXTS = { lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.", textLabel: "Le prompt, à relire avant de le copier", copy: "Copier le prompt", - copied: "Prompt copié", + copied: "Copié", notCopied: "Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.", preparing: "Préparation du prompt…", close: "Fermer", + /** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */ + back: { + explanation: "Explication", + summary: "Bilan", + analysis: "Analyse", + glossary: "Glossaire", + }, failed: "Le prompt n'a pas pu être préparé.", noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.", notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.", diff --git a/app/src/ui/ask-ai.ts b/app/src/ui/ask-ai.ts index fb66985..1d3e517 100644 --- a/app/src/ui/ask-ai.ts +++ b/app/src/ui/ask-ai.ts @@ -1,9 +1,15 @@ // « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») : // le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt -// (…/ia, ADR 0013), où le texte se relit avant d'être copié. La copie passe -// par navigator.clipboard ; à défaut, le texte est sélectionné dans la -// feuille et copié par le navigateur, ou laissé sélectionné pour être -// copié à la main. Le bandeau « Prompt copié » le confirme. +// (…/ia, ADR 0013), où le texte se relit avant d'être copié. +// +// Une seule feuille à la fois (recette de 1f) : ouverte depuis la feuille du +// maître ou le bilan, la page du prompt prend la place de leur contenu, à la +// même hauteur, avec « ‹ Explication » (ou « ‹ Bilan ») pour y revenir ; +// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte +// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut, +// le texte est sélectionné dans la feuille et copié par le navigateur, ou +// laissé sélectionné pour être copié à la main. Le bouton la confirme +// (« ✓ Copié », 2 s). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -13,12 +19,15 @@ import { ASK_AI_TEXTS } from "./ask-ai-texts.js"; import { icon } from "./icons.js"; -/** Durée du bandeau « Prompt copié » (charte : 2 s). */ +/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */ export const COPIED_MS = 2000; /** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */ export type PromptResult = { readonly text: string } | { readonly message: string }; +/** Classe de la page du prompt, posée dans une autre feuille. */ +const PAGE_CLASS = "prompt-page"; + /** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */ export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement { const link = document.createElement("a"); @@ -30,18 +39,36 @@ export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAn return link; } +/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */ +export function clearSheet(sheet: HTMLElement): void { + for (const child of [...sheet.children]) { + if (!child.classList.contains(PAGE_CLASS)) { + child.remove(); + } + } +} + +/** D'où l'on vient : la feuille dont la page prend la place (null : sa + * propre feuille) et le nom de l'écran, pour « ‹ Explication ». */ +export interface PromptPlace { + readonly host: HTMLElement | null; + readonly back: string; + /** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */ + readonly backHref: string; +} + export interface PromptViewOptions { - /** Le voile de la feuille (index.html) ; la feuille est construite ici. */ + /** Le voile de sa propre feuille (index.html) ; elle est construite ici. */ readonly veil: HTMLElement; - /** Le bandeau « Prompt copié » (index.html). */ - readonly banner: HTMLElement; - /** Fermer : quitter l'adresse de la feuille. */ + /** « ‹ Explication » : revenir à l'écran d'où l'on vient. */ + readonly back: () => void; + /** La croix et « Fermer » : fermer la feuille. */ readonly close: () => void; } export interface PromptView { /** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */ - show(prompt: Promise): void; + show(prompt: Promise, place: PromptPlace): void; hide(): void; readonly isOpen: boolean; /** Le prompt est affiché (pas un message). */ @@ -68,26 +95,42 @@ function element( } export function createPromptView(options: PromptViewOptions): PromptView { - const { veil, banner } = options; + const { veil } = options; + // Sa propre feuille, pour l'analyse et le glossaire. const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" }); - sheet.setAttribute("role", "dialog"); - sheet.setAttribute("aria-modal", "true"); - sheet.setAttribute("aria-labelledby", "prompt-title"); - const head = element(sheet, "div", { className: "sheet-head" }); + veil.addEventListener("click", (event) => { + if (event.target === veil) { + options.close(); + } + }); + // La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient. + const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS }); + page.setAttribute("role", "dialog"); + page.setAttribute("aria-modal", "true"); + page.setAttribute("aria-labelledby", "prompt-title"); + const head = element(page, "div", { className: "sheet-head prompt-head" }); element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true"); - element(head, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title }); + const back = element(head, "a", { id: "prompt-back", className: "prompt-back" }); + back.addEventListener("click", (event) => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + event.preventDefault(); + options.back(); + }); const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" }); closeIcon.type = "button"; closeIcon.append(icon("close")); closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close); closeIcon.onclick = () => options.close(); - element(sheet, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead }); - const text = element(sheet, "pre", { id: "prompt-text", className: "prompt-text" }); + element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title }); + element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead }); + const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" }); text.tabIndex = 0; text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel); - const status = element(sheet, "p", { id: "prompt-status", className: "prompt-status" }); + const status = element(page, "p", { id: "prompt-status", className: "prompt-status" }); status.setAttribute("role", "status"); - const actions = element(sheet, "div", { className: "prompt-actions" }); + const actions = element(page, "div", { className: "prompt-actions" }); const done = element(actions, "button", { id: "prompt-done", className: "secondary", @@ -97,43 +140,53 @@ export function createPromptView(options: PromptViewOptions): PromptView { done.onclick = () => options.close(); const copy = element(actions, "button", { id: "prompt-copy" }); copy.type = "button"; - copy.append(icon("copy"), ASK_AI_TEXTS.copy); - veil.addEventListener("click", (event) => { - if (event.target === veil) { - options.close(); - } - }); - - const bannerText = element(banner, "span", { text: ASK_AI_TEXTS.copied }); - banner.prepend(icon("verdictGood")); - const timer = element(banner, "span", { className: "banner-timer prompt-timer" }); - timer.setAttribute("aria-hidden", "true"); - let bannerTimeout: ReturnType | null = null; + // Le libellé du bouton est annoncé quand il passe à « Copié ». + const copyLabel = document.createElement("span"); + copyLabel.setAttribute("aria-live", "polite"); // Chaque ouverture a son numéro : un prompt qui arrive après une autre // ouverture, ou après la fermeture, n'est pas montré. let opened = 0; let ready = false; + let open = false; + // La feuille d'où l'on vient, dont la page a pris la place. + let host: HTMLElement | null = null; + let copiedTimeout: ReturnType | null = null; - function showBanner(): void { - bannerText.textContent = ASK_AI_TEXTS.copied; - banner.hidden = false; - // La barre de temps repart de zéro (charte : 2 s). - timer.classList.remove("running"); - void timer.offsetWidth; - timer.classList.add("running"); - if (bannerTimeout !== null) { - clearTimeout(bannerTimeout); + /** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */ + function showCopied(copied: boolean): void { + if (copiedTimeout !== null) { + clearTimeout(copiedTimeout); + copiedTimeout = null; + } + copy.classList.toggle("copied", copied); + copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy; + copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel); + if (copied) { + copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS); } - bannerTimeout = setTimeout(hideBanner, COPIED_MS); } + showCopied(false); - function hideBanner(): void { - banner.hidden = true; - timer.classList.remove("running"); - if (bannerTimeout !== null) { - clearTimeout(bannerTimeout); - bannerTimeout = null; + /** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */ + function place(next: HTMLElement | null): void { + if (host !== next) { + host?.classList.remove("prompting"); + host = next; + } + if (next === null) { + if (page.parentElement !== sheet) { + sheet.append(page); + } + veil.hidden = false; + return; + } + veil.hidden = true; + // La page couvre la feuille telle qu'elle s'affiche, défilée en haut. + next.scrollTop = 0; + next.classList.add("prompting"); + if (page.parentElement !== next) { + next.append(page); } } @@ -177,25 +230,31 @@ export function createPromptView(options: PromptViewOptions): PromptView { } if (copied) { status.textContent = ""; - showBanner(); + showCopied(true); } else { selectText(); + showCopied(false); status.textContent = ASK_AI_TEXTS.notCopied; } }; return { - show(prompt) { + show(prompt, where) { const id = ++opened; - const opening = veil.hidden; - veil.hidden = false; + const opening = !open || host !== where.host; + open = true; + back.textContent = `‹ ${where.back}`; + back.href = where.backHref; + place(where.host); if (opening) { ready = false; text.textContent = ""; text.hidden = true; copy.disabled = true; + showCopied(false); status.textContent = ASK_AI_TEXTS.preparing; - closeIcon.focus(); + delete page.dataset.ready; + back.focus(); } prompt.then( (result) => { @@ -208,13 +267,13 @@ export function createPromptView(options: PromptViewOptions): PromptView { text.hidden = false; copy.disabled = false; status.textContent = ""; - sheet.dataset.ready = "true"; + page.dataset.ready = "true"; } else { ready = false; text.hidden = true; copy.disabled = true; status.textContent = result.message; - delete sheet.dataset.ready; + delete page.dataset.ready; } }, () => { @@ -229,15 +288,20 @@ export function createPromptView(options: PromptViewOptions): PromptView { hide() { opened++; ready = false; - // Le bandeau « Prompt copié » finit ses 2 s, feuille fermée. - if (!veil.hidden) { - veil.hidden = true; - text.textContent = ""; - delete sheet.dataset.ready; + if (!open) { + return; } + open = false; + showCopied(false); + host?.classList.remove("prompting"); + host = null; + veil.hidden = true; + sheet.append(page); + text.textContent = ""; + delete page.dataset.ready; }, get isOpen() { - return !veil.hidden; + return open; }, get ready() { return ready; diff --git a/app/src/ui/summary-view.ts b/app/src/ui/summary-view.ts index 4eed88b..e09356c 100644 --- a/app/src/ui/summary-view.ts +++ b/app/src/ui/summary-view.ts @@ -28,7 +28,7 @@ import type { GameJournal } from "../domain/game/events.js"; import type { Game } from "../domain/game/game.js"; import { COMPUTER_LEVELS } from "../domain/game/level.js"; import { VERDICT_ICONS } from "./advice-view.js"; -import { askAiLink } from "./ask-ai.js"; +import { askAiLink, clearSheet } from "./ask-ai.js"; import { icon } from "./icons.js"; import { MATCH_TEXTS } from "./match-texts.js"; import { boardSvg, drawSteps } from "./move-board.js"; @@ -65,6 +65,8 @@ export interface SummaryView { show(data: SummaryData | null): void; hide(): void; readonly isOpen: boolean; + /** Le panneau, où la page du prompt prend la place du bilan (1f). */ + readonly panel: HTMLElement; } function element( @@ -142,7 +144,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView { return; } drawn = key; - panel.replaceChildren(); + clearSheet(panel); delete panel.dataset.ready; const head = element(panel, "div", { className: "summary-head" }); element(head, "p", { className: "overline", text: TEXTS.endOverline }); @@ -356,7 +358,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView { hide() { if (!veil.hidden) { veil.hidden = true; - panel.replaceChildren(); + clearSheet(panel); delete panel.dataset.ready; drawn = ""; } @@ -364,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView { get isOpen() { return !veil.hidden; }, + panel, }; } diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 6aa5734..693abce 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -174,18 +174,21 @@ demande suivie de `/ia` : | Prompt de la décision d'un coup, depuis l'analyse | `#/parties//analyse//ia` (ou `…/analyse/ia`, sur le coup à revoir) | | Prompt d'un terme du glossaire | `#/glossaire//ia` | -- La feuille monte au-dessus de l'écran d'en dessous, qui reste dessiné - (la feuille du maître, le bilan, l'analyse ou le glossaire). « Fermer », - la croix, Échap et le retour du téléphone reviennent à cet écran ; ouverte - par son adresse, sans historique, la fermer remplace l'adresse par celle - de l'écran d'en dessous. +- Une seule feuille à la fois : depuis la feuille du maître ou le bilan, + la page du prompt prend la place de leur contenu, à la même hauteur ; + depuis l'analyse ou le glossaire, elle monte dans sa propre feuille. + « ‹ Explication » (« ‹ Bilan », « ‹ Analyse », « ‹ Glossaire »), Échap et + le retour du téléphone reviennent à l'écran d'où on l'a demandée ; la + croix et « Fermer » ferment la feuille, et avec elle la feuille du maître + ou le bilan dont elle avait pris la place. Ouverte par son adresse, sans + historique, le retour remplace l'adresse par celle de l'écran d'avant. - Rechargée, l'adresse rouvre la même feuille : le prompt est recalculé à partir du journal enregistré. Comme ses écrans, elle n'a de sens que sur l'appareil qui a joué la partie (sauf pour le glossaire). Une décision sans conseil, une partie absente ou pas encore analysée donne un message, jamais une page cassée. -- Le bandeau « Prompt copié » n'a pas d'adresse : c'est un bandeau - éphémère. +- La confirmation de la copie (« Copié » sur le bouton, 2 s) n'a pas + d'adresse. - Le prompt contient, lui, le lien public de la position (`https://ustagammon.sans.pub/#/position//`), qui se partage. diff --git a/docs/identite/charte/ecrans-etape-1.md b/docs/identite/charte/ecrans-etape-1.md index a22b8b6..95fc8d8 100644 --- a/docs/identite/charte/ecrans-etape-1.md +++ b/docs/identite/charte/ecrans-etape-1.md @@ -206,9 +206,21 @@ Sur ordinateur, `←` et `→` passent d'un coup à l'autre. Le bouton est secondaire partout, jamais l'action principale, avec l'icône de copie : dans la feuille du maître (1d), dans la carte « Le coup à revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du -glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s), -puis le texte du prompt reste lisible dans une zone `surface-sunken` -repliable, pour qui veut le relire avant de le coller (composant `AskAI`). +glossaire. Le bouton ouvre la feuille du prompt : le texte y est lisible +dans une zone `surface-sunken`, pour qui veut le relire avant de le copier +(composant `AskAI`). + +Recette de 1f : + +- **Une seule feuille à la fois.** Depuis la feuille du maître, la feuille + du prompt en remplace le contenu, à la même hauteur, avec « ‹ Explication » + en haut à gauche pour revenir ; de même depuis le bilan (« ‹ Bilan »). + Depuis l'analyse et le glossaire, elle monte seule (« ‹ Analyse », + « ‹ Glossaire »). Le retour du téléphone ramène à l'écran précédent. +- **La copie se confirme sur le bouton**, sans bandeau : « Copier le + prompt » devient « ✓ Copié » (coche, `verdict-good`) pendant 2 s, puis + revient. Si le navigateur refuse la copie, le texte est sélectionné et + une phrase invite à le copier avec le menu du téléphone. ## 1g. Leçons (`#/lecons`, `#/lecons//<écran>`) diff --git a/tests/browser/prompts.mjs b/tests/browser/prompts.mjs index cce6d0d..22b59be 100644 --- a/tests/browser/prompts.mjs +++ b/tests/browser/prompts.mjs @@ -11,11 +11,14 @@ // l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une // IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une // fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible -// avant la copie, rechargeable ; la copie (dans Chromium, le presse-papiers -// est relu et comparé au texte de la feuille, et aux chiffres de la feuille -// du maître) ; le bandeau « Prompt copié » ; la fermeture qui ramène à -// l'écran d'en dessous ; aucune requête vers un autre site ; l'absence -// d'erreur et de violation de la politique de sécurité. Captures (Chromium) +// avant la copie, rechargeable ; une seule feuille à la fois (depuis la +// feuille du maître ou le bilan, la page du prompt prend leur place, à la +// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans +// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et +// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié », +// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à +// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ; +// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium) // dans /prompts/, en 412×915 et 915×412. import { mkdirSync } from "node:fs"; @@ -109,20 +112,15 @@ const idle = () => until("écran libre", () => !document.body.classList.contains /** Une capture de la recette dans les deux tailles (Chromium seulement), * après la règle commune : rien d'interactif sous une barre d'action, pas - * de bouton étiré. before() est rejoué avant chaque taille (le bandeau - * « Prompt copié » ne dure que 2 s). */ + * de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne + * dure que 2 s sur le bouton). */ async function capture(name, before = null) { if (!full) { return; } - // Le bandeau « Prompt copié » d'une copie précédente est parti (sauf - // pour sa propre capture, où before le fait réapparaître). - await until( - "bandeau parti", - () => document.getElementById("prompt-banner").hidden, - null, - 10_000, - ); + // « Copié » d'une copie précédente est reparti (sauf pour sa propre + // capture, où before le fait réapparaître). + await until("bouton revenu", notCopied, null, 10_000); for (const size of SIZES) { await page.setViewportSize(size); await sleep(300); @@ -141,26 +139,71 @@ async function capture(name, before = null) { await sleep(300); } -/** La feuille du prompt, prête : son texte. */ +/** Le bouton de copie n'est pas (ou plus) en « Copié ». */ +const notCopied = () => { + const copy = document.getElementById("prompt-copy"); + return copy === null || !copy.classList.contains("copied"); +}; + +/** La page du prompt, prête et visible : son texte. */ async function openPrompt(button, hashEnd) { await page.locator(button).scrollIntoViewIfNeeded(); await page.click(button); await until( "feuille du prompt prête", - (end) => - location.hash.endsWith(end) && - !document.getElementById("prompt-veil").hidden && - document.getElementById("prompt-sheet")?.dataset.ready === "true", + (end) => { + const prompt = document.getElementById("prompt-page"); + return ( + location.hash.endsWith(end) && + prompt?.dataset.ready === "true" && + prompt.getClientRects().length > 0 + ); + }, hashEnd, ); return page.textContent("#prompt-text"); } +/** Une seule feuille visible : la page du prompt, dans la feuille hostId + * (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une + * croix et un titre visibles au plus ; « ‹ back ». */ +async function checkOneSheet(hostId, back, what) { + const seen = await page.evaluate((host) => { + const visible = (el) => + el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible"; + const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length; + const prompt = document.getElementById("prompt-page"); + return { + parent: prompt.parentElement.id, + ownSheet: !document.getElementById("prompt-veil").hidden, + grips: count(".grip"), + closes: count('[role="dialog"] .icon-button'), + titles: count('[role="dialog"] h2'), + back: document.getElementById("prompt-back").textContent, + hostVisible: host === null || !document.getElementById(host).parentElement.hidden, + }; + }, hostId); + check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`); + check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`); + check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`); + check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`); + check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`); + check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`); + check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`); +} + +/** La hauteur et le haut d'un élément. */ +const boxOf = (selector) => + page.evaluate((s) => { + const r = document.querySelector(s).getBoundingClientRect(); + return { top: Math.round(r.top), height: Math.round(r.height) }; + }, selector); + /** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */ function checkPrompt(text, what) { for (const part of [ "Tu es un coach de backgammon", - "Je suis un joueur de niveau ", + "Je débute au backgammon.", "Position ID : ", "Match ID : ", "https://ustagammon.sans.pub/#/position/", @@ -174,42 +217,82 @@ function checkPrompt(text, what) { check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`); } -/** Copie le prompt, attend le bandeau, et (Chromium) relit le - * presse-papiers. */ +/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */ +const copiedOnButton = () => { + const copy = document.getElementById("prompt-copy"); + return copy.classList.contains("copied") && copy.textContent === "Copié"; +}; + +/** Copie faite (« Copié ») ou repli (le message sous le texte). */ +const copyDone = () => { + const copy = document.getElementById("prompt-copy"); + return ( + copy.classList.contains("copied") || + document.getElementById("prompt-status").textContent !== "" + ); +}; + +/** Copie le prompt, attend « Copié » sur le bouton puis son retour à + * « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */ async function copyPrompt(what) { await page.click("#prompt-copy"); - await until( - "copie faite", - () => - !document.getElementById("prompt-banner").hidden || - document.getElementById("prompt-status").textContent !== "", - null, - 10_000, - ); - const shown = await page.evaluate(() => ({ - banner: !document.getElementById("prompt-banner").hidden, - bannerText: document.getElementById("prompt-banner").textContent, - status: document.getElementById("prompt-status").textContent, - text: document.getElementById("prompt-text").textContent, - })); + await until("copie faite", copyDone, null, 10_000); + const shown = await page.evaluate(() => { + const copy = document.getElementById("prompt-copy"); + return { + copied: copy.classList.contains("copied"), + label: copy.textContent, + check: copy.querySelector("svg.icon") !== null, + color: getComputedStyle(copy).color, + good: getComputedStyle(document.documentElement) + .getPropertyValue("--verdict-good") + .trim(), + status: document.getElementById("prompt-status").textContent, + text: document.getElementById("prompt-text").textContent, + banners: document.querySelectorAll("#prompt-banner").length, + }; + }); + check(shown.banners === 0, `${what} : un bandeau de copie`); + if (shown.copied) { + check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`); + // La couleur du bouton est verdict-good (#rrggbb dans app.css). + const rgb = shown.good + .replace("#", "") + .match(/../g) + .map((h) => Number.parseInt(h, 16)) + .join(", "); + check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`); + // Deux secondes après, le bouton revient. + await until("« Copier le prompt » revenu", notCopied, null, 5_000); + const label = await page.textContent("#prompt-copy"); + check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`); + } if (full) { - check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`); + check(shown.copied, `${what} : pas de « Copié » sur le bouton`); const clipboard = await page.evaluate(() => navigator.clipboard.readText()); check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`); return clipboard; } // Firefox : la copie, ou son repli (texte sélectionné, et un message). - check(shown.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`); - log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`); + check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`); + log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`); return shown.text; } -/** Ferme la feuille du prompt : l'écran d'en dessous revient. */ -async function closePrompt(hashBelow) { - await page.click("#prompt-done"); +/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */ +async function backFromPrompt(hashBelow) { + await page.click("#prompt-back"); + await promptGone(hashBelow); +} + +/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */ +async function promptGone(hashBelow) { await until( "feuille du prompt fermée", - (below) => document.getElementById("prompt-veil").hidden && location.hash === below, + (below) => + document.getElementById("prompt-veil").hidden && + document.querySelector(".prompting") === null && + location.hash === below, hashBelow, ); } @@ -254,7 +337,16 @@ const sheetNumbers = await page.evaluate(() => await capture("1-bouton-feuille-du-maitre", () => page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(), ); +// La feuille du maître, avant : sa hauteur, que la page du prompt garde. +const explainBox = await boxOf("#explain-sheet"); const moveText = await openPrompt("#explain-ask-ai", "/ia"); +await checkOneSheet("explain-sheet", "Explication", "feuille du maître"); +const promptBox = await boxOf("#prompt-page"); +check( + Math.abs(promptBox.top - explainBox.top) <= 1 && + Math.abs(promptBox.height - explainBox.height) <= 1, + `la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`, +); check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille"); checkPrompt(moveText, "prompt du coup"); check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué"); @@ -276,30 +368,39 @@ await capture("2-feuille-du-prompt"); const copied = await copyPrompt("prompt du coup"); await capture("3-prompt-copie", async () => { await page.click("#prompt-copy"); - await until("bandeau", () => !document.getElementById("prompt-banner").hidden); + await until("« Copié »", copiedOnButton); }); log(`prompt du coup copié (${copied.length} caractères)`); -// Échap ferme la feuille du prompt, pas l'explication dessous. +// Échap ramène à l'explication, qui n'est pas fermée. await page.keyboard.press("Escape"); -await until( - "retour à l'explication", - (below) => - document.getElementById("prompt-veil").hidden && - !document.getElementById("explain-veil").hidden && - location.hash === below, - explainHash, +await promptGone(explainHash); +check( + await page.evaluate( + () => + !document.getElementById("explain-veil").hidden && + getComputedStyle(document.getElementById("explain-close")).visibility === "visible", + ), + "l'explication n'est pas revenue", ); // Rechargée, l'adresse de la feuille la rouvre. await page.goto(`${base}${explainHash}/ia`); await page.reload(); await until( "feuille rouverte au rechargement", - () => document.getElementById("prompt-sheet")?.dataset.ready === "true", + () => document.getElementById("prompt-page")?.dataset.ready === "true", ); check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère"); -await closePrompt(explainHash); -await page.click("#explain-done"); -await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden); +await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée"); +// « ‹ Explication » y ramène. +await backFromPrompt(explainHash); +// La croix ferme la feuille : l'explication avec. +await openPrompt("#explain-ask-ai", "/ia"); +await page.click("#prompt-close"); +await until( + "feuille du maître fermée", + () => document.getElementById("explain-veil").hidden && location.hash === "#/", +); +await promptGone("#/"); // ------------------------------------------------------------- Le bilan @@ -318,6 +419,7 @@ const summaryHash = await page.evaluate(() => location.hash); const id = summaryHash.split("/")[2]; await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded()); const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia"); +await checkOneSheet("summary", "Bilan", "bilan"); checkPrompt(summaryText, "prompt du bilan"); check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat"); check( @@ -327,7 +429,9 @@ check( check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir"); await capture("5-feuille-du-prompt-bilan"); await copyPrompt("prompt du bilan"); -await closePrompt(summaryHash); +// Le retour du téléphone ramène au bilan. +await page.goBack(); +await promptGone(summaryHash); check( await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"), "le bilan n'est plus là après la feuille du prompt", @@ -349,6 +453,7 @@ await capture("6-bouton-analyse", () => ); const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`); checkPrompt(analysisText, "prompt de l'analyse"); +await checkOneSheet(null, "Analyse", "analyse"); // Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau // du joueur, lui, compte maintenant toutes ses décisions de la partie). const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations")); @@ -356,7 +461,7 @@ check( numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50, "les chiffres du coup diffèrent entre la feuille du maître et l'analyse", ); -await closePrompt(`#/parties/${id}/analyse/1`); +await backFromPrompt(`#/parties/${id}/analyse/1`); // Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de // videau. await page.goto(`${base}#/parties/${id}/analyse/2`); @@ -368,7 +473,9 @@ const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia"); checkPrompt(cubeText, "prompt du videau"); check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau"); check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision"); -await closePrompt(`#/parties/${id}/analyse/2`); +// « Fermer » : sans feuille dessous, retour à l'analyse. +await page.click("#prompt-done"); +await promptGone(`#/parties/${id}/analyse/2`); // --------------------------------------------------------- Le glossaire @@ -389,6 +496,7 @@ await capture("7-bouton-glossaire", () => ); const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia"); checkPrompt(conceptText, "prompt du concept"); +await checkOneSheet(null, "Glossaire", "glossaire"); check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept"); check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation"); await capture("8-feuille-du-prompt-glossaire"); @@ -396,24 +504,17 @@ await copyPrompt("prompt du concept"); if (full) { // Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le // texte est sélectionné dans la feuille et copié par le navigateur. - await until("bandeau parti", () => document.getElementById("prompt-banner").hidden); + await until("bouton revenu", notCopied, null, 5_000); await page.evaluate(async () => { await navigator.clipboard.writeText(""); navigator.clipboard.writeText = () => Promise.reject(new Error("refusé")); }); await page.click("#prompt-copy"); - await until( - "copie de repli", - () => - !document.getElementById("prompt-banner").hidden || - document.getElementById("prompt-status").textContent !== "", - null, - 10_000, - ); + await until("copie de repli", copyDone, null, 10_000); const fallback = await page.evaluate(async () => ({ selected: window.getSelection().toString(), text: document.getElementById("prompt-text").textContent, - banner: !document.getElementById("prompt-banner").hidden, + copied: document.getElementById("prompt-copy").classList.contains("copied"), clipboard: await navigator.clipboard.readText(), })); // Copié depuis une sélection, le texte perd ses espaces insécables @@ -421,12 +522,12 @@ if (full) { const plain = (text) => text.replace(/ /g, " ").trim(); check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné"); check( - fallback.banner && plain(fallback.clipboard) === plain(fallback.text), - `repli : le navigateur n'a pas copié le texte sélectionné (bandeau ${fallback.banner}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`, + fallback.copied && plain(fallback.clipboard) === plain(fallback.text), + `repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`, ); log("repli par sélection du texte : copié"); } -await closePrompt("#/glossaire/videau"); +await backFromPrompt("#/glossaire/videau"); const csp = await page.evaluate(() => window.__csp); for (const violation of csp) { diff --git a/web/app.css b/web/app.css index 98eca6e..eb46278 100644 --- a/web/app.css +++ b/web/app.css @@ -3430,17 +3430,65 @@ a.button.ask-ai .icon, margin-top: var(--space-2); } -/* La feuille du prompt passe devant la feuille du maître et le bilan. */ +/* Sa propre feuille, au-dessus de l'analyse et du glossaire. */ .prompt-veil { z-index: 12; } -.prompt-sheet { +.prompt-page { display: flex; flex-direction: column; gap: var(--space-2); } +/* Depuis la feuille du maître ou le bilan, la page du prompt prend la place + * de leur contenu, à la même hauteur : la feuille garde sa taille, son + * contenu reste en place mais invisible, et la page la couvre ; le texte du + * prompt défile dans la hauteur qui reste. Une seule feuille, une seule + * poignée, une seule croix. */ +.sheet.prompting, +.panel.prompting { + position: relative; + overflow: hidden; +} + +.prompting > :not(.prompt-page) { + visibility: hidden; +} + +/* (plus précis que « html.landscape .explain-sheet > * », qui la placerait + * dans une colonne) */ +.sheet.prompting > .prompt-page, +.panel.prompting > .prompt-page { + position: absolute; + inset: 0; + grid-area: auto; + padding: inherit; +} + +.prompting .prompt-text { + flex: 1 1 auto; + max-height: none; +} + +/* Le bilan est un panneau : pas de poignée. */ +.panel .prompt-page .grip { + display: none; +} + +/* « ‹ Explication » : revenir à l'écran d'où l'on vient. */ +a.prompt-back { + display: inline-flex; + align-items: center; + min-height: 44px; + padding-right: var(--space-2); + font-weight: 500; + color: var(--accent); + text-decoration: none; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + .prompt-lead { margin: 0; color: var(--muted); @@ -3478,6 +3526,7 @@ a.button.ask-ai .icon, .prompt-actions { display: flex; + flex: none; gap: var(--space-2); justify-content: flex-end; } @@ -3490,39 +3539,20 @@ a.button.ask-ai .icon, min-width: 120px; } +/* « ✓ Copié », 2 s : la copie se confirme sur le bouton (recette de 1f). */ +#prompt-copy.copied { + color: var(--verdict-good); + background: var(--surface); + border-color: var(--verdict-good); +} + html.landscape .prompt-text { max-height: calc(100vh - 190px); max-height: calc(100dvh - 190px); } -/* « Prompt copié » : le bandeau des autres, en verdict-good, 2 s, devant - * la feuille du prompt ; en haut de l'écran, pour ne pas couvrir les - * boutons de la feuille. */ -.prompt-banner, -html.landscape .prompt-banner { - top: var(--space-4); - bottom: auto; - z-index: 13; - color: var(--verdict-good); - font-weight: 700; -} - -.prompt-banner > span:not(.banner-timer) { - flex: 1; -} - -.prompt-banner .icon { - flex: none; - width: 20px; - height: 20px; -} - -.prompt-banner .banner-timer { - background: var(--verdict-good); -} - -.prompt-banner .banner-timer.running { - animation-duration: 2s; +html.landscape .prompting .prompt-text { + max-height: none; } /* Le bouton ne se coupe pas : trop étroit à côté de « Revoir ce coup » ou diff --git a/web/index.html b/web/index.html index 6aa3888..48bc2ad 100644 --- a/web/index.html +++ b/web/index.html @@ -166,8 +166,9 @@ construit par app/src/ui/summary-view.ts. --> - + @@ -218,10 +219,6 @@ - - -