Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton
Recette de 1f (F1, F2). 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 revenir ; depuis l'analyse et le glossaire, elle monte dans sa propre feuille. La croix et « Fermer » ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à « ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix, un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et amendement de l'ADR 0013 mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2df1b2e7f
commit
d5f08fe393
@@ -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<void>;
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+31
-6
@@ -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<HTMLButtonElement>("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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+125
-61
@@ -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<PromptResult>): void;
|
||||
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
/** Le prompt est affiché (pas un message). */
|
||||
@@ -68,26 +95,42 @@ function element<K extends keyof HTMLElementTagNameMap>(
|
||||
}
|
||||
|
||||
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<typeof setTimeout> | 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<typeof setTimeout> | 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;
|
||||
|
||||
@@ -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<K extends keyof HTMLElementTagNameMap>(
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,18 +174,21 @@ demande suivie de `/ia` :
|
||||
| Prompt de la décision d'un coup, depuis l'analyse | `#/parties/<id>/analyse/<coup>/ia` (ou `…/analyse/ia`, sur le coup à revoir) |
|
||||
| Prompt d'un terme du glossaire | `#/glossaire/<fiche sans .md>/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/<Position ID>/<Match ID>`),
|
||||
qui se partage.
|
||||
|
||||
@@ -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/<n>/<écran>`)
|
||||
|
||||
|
||||
+174
-73
@@ -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 <dossier des captures>/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) {
|
||||
|
||||
+60
-30
@@ -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
|
||||
|
||||
+3
-6
@@ -166,8 +166,9 @@
|
||||
construit par app/src/ui/summary-view.ts. -->
|
||||
<div id="summary-veil" class="veil summary-veil" hidden></div>
|
||||
|
||||
<!-- Le prompt pour une IA (…/ia) : une feuille, au-dessus de l'écran d'où
|
||||
on le demande, construite par app/src/ui/ask-ai.ts. -->
|
||||
<!-- Le prompt pour une IA (…/ia), depuis l'analyse ou le glossaire : une
|
||||
feuille, construite par app/src/ui/ask-ai.ts (depuis la feuille du
|
||||
maître ou le bilan, il prend leur place). -->
|
||||
<div id="prompt-veil" class="veil sheet-veil prompt-veil" hidden></div>
|
||||
|
||||
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
|
||||
@@ -218,10 +219,6 @@
|
||||
<span class="banner-timer" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<!-- « Prompt copié », 2 s, au-dessus de la feuille du prompt (contenu
|
||||
construit par app/src/ui/ask-ai.ts). -->
|
||||
<div id="prompt-banner" class="banner prompt-banner" role="status" hidden></div>
|
||||
|
||||
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
|
||||
seulement entre deux tours. -->
|
||||
<div id="update-banner" class="banner update-banner" role="status" hidden>
|
||||
|
||||
Reference in New Issue
Block a user