// 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; }); } /** * 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(", "); }