Leçon 1 §2 (flèche 7 vide, 5 déjà bloqué), leçon 2 « Faire des points », flèches 7 à 12, reformulations des leçons 3 et 4. Écran des leçons et du tutoriel : « Retour » et « Continuer » en bas en portrait, « Revoir » sur la ligne du titre, ligne « Leçon n · … » gardée en paysage. Barre haute visible hors de l'écran de jeu en paysage (défaut antérieur à 1g), avec un contrôle dans la recette automatique. Rappel des règles haut de 44 px ; « Aucune leçon terminée sur 5 · … » sur l'accueil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
// recette.mjs -- règles de la recette design communes aux tests dans un
|
|
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
|
|
// capturé.
|
|
//
|
|
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
|
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
|
|
|
/**
|
|
* Les boutons visibles (et les liens qui en ont l'allure) plus hauts que
|
|
* 56 px : un bouton fait 48 px, il ne s'étire pas. Renvoie la liste des
|
|
* boutons trop hauts, décrits (vide si tout va bien).
|
|
*/
|
|
export async function tallButtons(target) {
|
|
return target.evaluate(() => {
|
|
const found = [];
|
|
for (const el of document.querySelectorAll("button, a.button")) {
|
|
if (el.closest("[hidden]")) {
|
|
continue;
|
|
}
|
|
const style = getComputedStyle(el);
|
|
const r = el.getBoundingClientRect();
|
|
if (style.display === "none" || style.visibility === "hidden" || r.width === 0) {
|
|
continue;
|
|
}
|
|
if (r.height > 56) {
|
|
const name = el.id ? `#${el.id}` : `« ${el.textContent.trim().slice(0, 30)} »`;
|
|
found.push(`${name} (${Math.round(r.height)} px)`);
|
|
}
|
|
}
|
|
return found;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Les éléments interactifs visibles cachés, même en partie, sous une barre
|
|
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
|
|
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
|
|
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
|
|
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
|
|
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
|
|
* vraiment, au point du recouvrement.
|
|
*
|
|
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
|
|
*/
|
|
export async function hiddenUnderActionBar(target) {
|
|
return target.evaluate(() => {
|
|
const shown = (el) => {
|
|
if (el.closest("[hidden]")) {
|
|
return false;
|
|
}
|
|
const style = getComputedStyle(el);
|
|
if (style.display === "none" || style.visibility === "hidden") {
|
|
return false;
|
|
}
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
};
|
|
const bars = [...document.querySelectorAll("body *")].filter((el) => {
|
|
const style = getComputedStyle(el);
|
|
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
|
|
});
|
|
const describe = (el) =>
|
|
el.id
|
|
? `#${el.id}`
|
|
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
|
|
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
|
|
} »`;
|
|
const hidden = [];
|
|
const interactive = document.querySelectorAll(
|
|
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
|
|
"[tabindex]:not([tabindex='-1'])",
|
|
);
|
|
for (const bar of bars) {
|
|
const b = bar.getBoundingClientRect();
|
|
for (const el of interactive) {
|
|
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
|
|
continue;
|
|
}
|
|
const r = el.getBoundingClientRect();
|
|
if (r.top >= innerHeight) {
|
|
continue;
|
|
}
|
|
const top = Math.max(r.top, b.top);
|
|
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
|
|
const left = Math.max(r.left, b.left);
|
|
const right = Math.min(r.right, b.right);
|
|
if (bottom - top <= 1 || right - left <= 1) {
|
|
continue;
|
|
}
|
|
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
|
if (hit !== null && bar.contains(hit)) {
|
|
hidden.push(
|
|
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return hidden;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* La barre haute (Aide, Plein écran, Menu) hors de l'écran de jeu : sur une
|
|
* page (accueil, leçons, aide…) qui n'a pas son propre en-tête, elle doit
|
|
* rester visible, en paysage comme en portrait ; sans elle, le menu est
|
|
* inaccessible (recette 1g). Renvoie un problème décrit, ou null.
|
|
*/
|
|
export async function missingTopbar(target) {
|
|
return target.evaluate(() => {
|
|
const root = document.documentElement;
|
|
if (!root.classList.contains("page-open") || root.classList.contains("bare")) {
|
|
return null;
|
|
}
|
|
const bar = document.querySelector(".topbar");
|
|
const r = bar?.getBoundingClientRect();
|
|
const shown = bar !== null && getComputedStyle(bar).display !== "none" && r.height > 0;
|
|
return shown
|
|
? null
|
|
: `barre haute absente sur ${root.dataset.screen} (${innerWidth}x${innerHeight})`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne
|
|
* de texte, et la largeur de la page (scrollWidth) face à celle de l'écran.
|
|
* Une page plus large que l'écran défile de côté ; sur un téléphone, Chrome
|
|
* la dézoome pour la montrer en entier, et tout l'écran paraît rétréci.
|
|
*/
|
|
export async function tutorialWidths(target) {
|
|
return target.evaluate(() => {
|
|
const width = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
return el === null ? 0 : el.getBoundingClientRect().width;
|
|
};
|
|
const page = document.scrollingElement;
|
|
// Le plus à droite des descendants du tutoriel, pour nommer le
|
|
// coupable d'un débordement.
|
|
let widest = { what: "", right: 0 };
|
|
for (const el of document.querySelectorAll("#tutorial *")) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width > 0 && r.right > widest.right) {
|
|
const name = el.id
|
|
? `#${el.id}`
|
|
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")}`;
|
|
widest = { what: name, right: r.right };
|
|
}
|
|
}
|
|
return {
|
|
board: width("#tutorial-board"),
|
|
text: width("#tutorial .tutorial-body"),
|
|
page: Math.max(page.scrollWidth, document.body.scrollWidth),
|
|
screen: innerWidth,
|
|
widest,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Règle de la recette : pour une taille d'écran donnée, le plateau du
|
|
* tutoriel a la même largeur à toutes les étapes, et aucune page n'est plus
|
|
* large que l'écran. views : [{ name, size, board, text, page, screen,
|
|
* widest }]. Renvoie la liste des écarts, décrits (vide si tout va bien).
|
|
*/
|
|
export function tutorialWidthProblems(views) {
|
|
const problems = [];
|
|
const bySize = new Map();
|
|
for (const v of views) {
|
|
bySize.set(v.size, [...(bySize.get(v.size) ?? []), v]);
|
|
if (v.page > v.screen + 0.5) {
|
|
problems.push(
|
|
`${v.name} en ${v.size} : page de ${v.page} px pour ${v.screen} px ` +
|
|
`(le plus à droite : ${v.widest.what}, ${Math.round(v.widest.right)} px)`,
|
|
);
|
|
}
|
|
}
|
|
for (const [size, list] of bySize) {
|
|
const widths = list.map((v) => v.board);
|
|
const min = Math.min(...widths);
|
|
const max = Math.max(...widths);
|
|
if (max - min > 0.5) {
|
|
const odd = list
|
|
.filter((v) => Math.abs(v.board - max) > 0.5)
|
|
.map((v) => `${v.name} ${v.board.toFixed(1)}`);
|
|
problems.push(
|
|
`${size} : plateau de ${min.toFixed(1)} à ${max.toFixed(1)} px (${odd.join(", ")})`,
|
|
);
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/**
|
|
* Les contrastes des pions dessinés sur les plateaux visibles de la page
|
|
* (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche,
|
|
* le fond du plateau ou la sortie, trouvé au centre du pion (le dernier
|
|
* dessiné qui le contient). Pour chaque pion : son camp, ce fond, le
|
|
* contraste de son contour (stroke) et celui de sa couleur (fill) avec ce
|
|
* fond. Les pions entourés de brique (jouable, sélectionné) sont laissés :
|
|
* leur contour est celui du pion jouable, pas celui de son camp.
|
|
*/
|
|
export async function checkerContrasts(target) {
|
|
return target.evaluate(() => {
|
|
const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
|
|
const luminance = (color) => {
|
|
const [r, g, b] = rgb(color).map((v) => {
|
|
const c = v / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
const ratio = (a, b) => {
|
|
const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y);
|
|
return (light + 0.05) / (dark + 0.05);
|
|
};
|
|
const BACKGROUNDS = [
|
|
["board-bar", "barre"],
|
|
["board-tray", "sortie"],
|
|
["point-a", "flèche impaire"],
|
|
["point-b", "flèche paire"],
|
|
["board-bed", "fond"],
|
|
];
|
|
const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", ");
|
|
const found = [];
|
|
for (const svg of document.querySelectorAll("svg.board")) {
|
|
if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) {
|
|
continue;
|
|
}
|
|
const shapes = [...svg.querySelectorAll(selector)].reverse();
|
|
for (const checker of svg.querySelectorAll("circle.checker")) {
|
|
if (checker.matches(".movable, .selected")) {
|
|
continue;
|
|
}
|
|
const point = svg.createSVGPoint();
|
|
point.x = Number(checker.getAttribute("cx"));
|
|
point.y = Number(checker.getAttribute("cy"));
|
|
const under = shapes.find((s) => s.isPointInFill(point));
|
|
if (under === undefined) {
|
|
continue;
|
|
}
|
|
const ground = getComputedStyle(under).fill;
|
|
const style = getComputedStyle(checker);
|
|
found.push({
|
|
board: svg.id || "plateau",
|
|
side: checker.matches(".checker-computer") ? "computer" : "player",
|
|
on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1],
|
|
edge: ratio(style.stroke, ground),
|
|
fill: ratio(style.fill, ground),
|
|
});
|
|
}
|
|
}
|
|
return found;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles
|
|
* partout ») : là où un pion de l'ordinateur ne se détache pas de son fond
|
|
* par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond
|
|
* sombre), son contour s'en détache à 3:1 au moins (guide de marque :
|
|
* contours porteurs de sens à 3:1). Et (« Pions du joueur lisibles
|
|
* partout ») : en thème clair, le contour d'un pion du joueur atteint 3:1 sur
|
|
* les flèches en chêne et sur le fond. contrasts : le résultat de
|
|
* checkerContrasts ; theme : « light » ou « dark ». Renvoie la liste des
|
|
* écarts, décrits (vide si tout va bien).
|
|
*/
|
|
export function checkerContrastProblems(contrasts, theme) {
|
|
const problems = new Set();
|
|
for (const c of contrasts) {
|
|
if (c.side === "computer" && c.fill < 3 && c.edge < 3) {
|
|
problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
|
|
}
|
|
if (
|
|
c.side === "player" &&
|
|
theme === "light" &&
|
|
(c.on === "flèche paire" || c.on === "fond") &&
|
|
c.edge < 3
|
|
) {
|
|
problems.add(`pion du joueur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
|
|
}
|
|
}
|
|
return [...problems];
|
|
}
|
|
|
|
/** Le plus faible contraste du contour, par camp et par fond, pour le
|
|
* journal du test (« ordinateur sur barre 6.79:1, … »). */
|
|
export function checkerContrastSummary(contrasts) {
|
|
const lowest = new Map();
|
|
for (const c of contrasts) {
|
|
const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`;
|
|
lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge));
|
|
}
|
|
return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", ");
|
|
}
|