From 2b8fa1a0b05ed15a6d80dc894eee47559b29437e Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 9 Nov 2026 18:52:50 +0100 Subject: [PATCH] =?UTF-8?q?Recette=20design=20des=20adresses=20:=20barre?= =?UTF-8?q?=20de=20l'aide=20coll=C3=A9e,=20boutons=20sur=20une=20ligne,=20?= =?UTF-8?q?position=20en=20paysage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Barre haute de l'aide en position sticky ; question ou terme ouvert par son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s. Boutons en white-space: nowrap ; actions de la position empilées (Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler), colonne de 288 px à droite. Test navigateur : aucun bouton sur deux lignes, barre de l'aide en vue, actions visibles sans défiler, videau entier. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 14 +++++- app/src/ui/help.ts | 2 + tests/browser/play.mjs | 98 ++++++++++++++++++++++++++++++++++++++ web/app.css | 105 +++++++++++++++++++++++++++++++++-------- web/index.html | 22 +++++---- 5 files changed, 210 insertions(+), 31 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index b71bf6f..869c329 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1264,6 +1264,7 @@ export function startApp(options: AppOptions): void { ? TEXTS.unreadablePosition : ""; ui.positionBoard.toggleAttribute("hidden", !shown); + ui.position.classList.toggle("with-board", shown); ui.positionDetail.hidden = !shown; ui.positionCopy.hidden = !shown; ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null; @@ -1271,11 +1272,20 @@ export function startApp(options: AppOptions): void { return; } const shared = state.shared; - const width = ui.positionBoard.clientWidth || Math.min(window.innerWidth, 640); + // En paysage, le plateau occupe toute la hauteur, à gauche de la + // colonne du titre et des actions (app.css), avec la géométrie du + // jeu : la colonne gauche du cadre garde le videau, sans menu ni + // bouton annuler (il ne se joue pas). + const landscape = layout() === "landscape"; + const box = ui.positionBoard.getBoundingClientRect(); + const width = box.width || Math.min(window.innerWidth, 640); + const height = box.height || window.innerHeight; const model = boardModel(shared.sides); renderBoard( ui.positionBoard, - boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), + landscape + ? boardGeometry("landscape", width / height, height / 520) + : boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), model, { dice: [], diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index bbca105..6a3ca07 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -231,6 +231,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV const opened = questions.find((q) => q.id === question) ?? null; const closed = route.screen === "help" && opened === null ? lastOpen : null; lastOpen = opened; + // La barre haute de l'aide reste en vue (app.css) : ce qu'on + // amène en haut s'arrête 12 px sous elle (scroll-margin-top). const target = opened?.section ?? (term === null ? undefined : terms.get(term)); if (target !== undefined) { target.scrollIntoView({ block: "start" }); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 3b9d3c7..c43392b 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -300,10 +300,49 @@ async function checkStable(when) { stableChecks++; } +/** Les boutons visibles d'une page dont le texte passe à la ligne : un + * bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus + * haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ; + * un texte coupé par le bord (white-space: nowrap) est refusé aussi. */ +async function wrappedButtons(target) { + return target.evaluate(() => { + const wrapped = []; + for (const el of document.querySelectorAll("button, a.button")) { + const r = el.getBoundingClientRect(); + if (el.closest("[hidden]") || r.width === 0 || r.height === 0) { + continue; + } + const name = el.id ? `#${el.id}` : el.textContent.trim(); + const tops = new Set(); + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + if (node.textContent.trim() === "") { + continue; + } + const range = document.createRange(); + range.selectNodeContents(node); + for (const line of range.getClientRects()) { + if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) { + tops.add(line.top); + } + } + } + if (r.height > 48.5 && tops.size > 1) { + wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`); + } else if (el.scrollWidth > el.clientWidth + 1) { + wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`); + } + } + return wrapped; + }); +} + /** Les critères de la recette vérifiables sur la vue courante. Renvoie des * mesures pour le compte rendu. */ async function checkView(width, height, when) { const where = `${width}x${height} (${when})`; + const wrapped = await wrappedButtons(page); + check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`); const seen = await page.evaluate(() => { const visible = (el) => { if (el.closest("[hidden]")) { @@ -762,6 +801,65 @@ async function checkAddresses() { await expectScreen(tab, `capture ${name}`, expected); await sleep(300); await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` }); + const where = `${name} ${width}x${height}`; + const wrapped = await wrappedButtons(tab); + check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`); + if (name === "question" || name === "terme") { + // La barre haute de l'aide reste en vue, et ce qu'on a + // ouvert s'arrête juste sous elle. + const seen = await tab.evaluate(() => { + const bar = document.getElementById("help-bar").getBoundingClientRect(); + const target = document.querySelector( + "#help-play .question.open, #help-glossary dt.current", + ).getBoundingClientRect(); + return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top }; + }); + check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1, + `${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`); + check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2, + `${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`); + } + if (name === "position") { + // Les trois actions, dans l'ordre, visibles sans défiler. + const seen = await tab.evaluate(() => ({ + scrolled: window.scrollY, + order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id), + boxes: ["position-play", "position-copy", "position-back"].map((id) => { + const r = document.getElementById(id).getBoundingClientRect(); + return [r.left, r.top, r.right, r.bottom]; + }), + board: (() => { + const r = document.getElementById("position-board").getBoundingClientRect(); + return [r.left, r.top, r.width, r.height]; + })(), + cube: (() => { + const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect(); + return r === undefined ? null : [r.left, r.top, r.right, r.bottom]; + })(), + controls: document.querySelectorAll("#position-board [data-action]").length, + })); + check(seen.order.join() === "position-play,position-copy,position-back", + `${where} : actions dans l'ordre ${seen.order}`); + const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height); + if (width > height) { + check(seen.scrolled === 0 && inView, + `${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`); + check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1, + `${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`); + // Le videau, dans la colonne gauche du cadre, entier à + // l'écran ; ni menu ni annuler : la position ne se joue pas. + const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1]; + check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height, + `${where} : videau coupé : ${JSON.stringify(seen.cube)}`); + check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`); + } else { + // Empilées sur toute la largeur. + const [first] = seen.boxes; + check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 && + Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])), + `${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`); + } + } await tab.close(); } } diff --git a/web/app.css b/web/app.css index 11a94cd..bebd591 100644 --- a/web/app.css +++ b/web/app.css @@ -363,6 +363,8 @@ button { border: 2px solid var(--accent); border-radius: var(--radius-pill); cursor: pointer; + /* Un bouton tient sur une ligne (charte). */ + white-space: nowrap; /* Pas de délai ni de zoom sur un double toucher. */ touch-action: manipulation; -webkit-tap-highlight-color: transparent; @@ -1317,16 +1319,36 @@ html.rotated .banner { -webkit-user-select: text; } -#help-back { - margin-top: var(--space-4); +/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la + * page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une + * question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */ +.help { + --help-bar-height: 113px; } -/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique. */ +.help-bar { + position: sticky; + top: env(safe-area-inset-top); + z-index: 3; + height: var(--help-bar-height); + margin-inline: calc(-1 * var(--space-4)); + padding: var(--space-3) var(--space-4) 0; + background: var(--background); +} + +.help .question, +.glossary dt { + scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top)); +} + +/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ; + * sur une ligne, qui défile si l'écran est trop étroit. */ .help-tabs { display: flex; - flex-wrap: wrap; gap: var(--space-1); - margin-top: var(--space-4); + overflow-x: auto; + white-space: nowrap; + margin-top: var(--space-2); border-bottom: 1px solid var(--border); } @@ -1402,6 +1424,12 @@ html.rotated .banner { margin-inline: calc(-1 * var(--space-4)); padding-inline: var(--space-4); background: var(--accent-soft); + /* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */ + animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards; +} + +@keyframes term-fade { + to { background-color: transparent; } } /* -------------------------------------------------- Liens et boutons */ @@ -1420,6 +1448,7 @@ a.button { background: var(--accent); border: 2px solid var(--accent); border-radius: var(--radius-pill); + white-space: nowrap; touch-action: manipulation; -webkit-tap-highlight-color: transparent; } @@ -1483,14 +1512,6 @@ a.link-button { margin: var(--space-2) 0; } -/* En paysage, le plateau tient dans la hauteur de l'écran. */ -html.landscape .position-board { - width: min(100%, calc(80vh * 640 / 520)); - height: auto; - margin-inline: auto; - aspect-ratio: 640 / 520; -} - .position-detail { margin: var(--space-2) 0 var(--space-4); font-size: 15px; @@ -1498,26 +1519,70 @@ html.landscape .position-board { color: var(--muted); } +/* Les actions empilées sur toute la largeur, la principale en premier. */ .position-actions { - display: grid; - grid-template-columns: 1fr 1.6fr; + display: flex; + flex-direction: column; gap: var(--space-2); } .position-actions > * { - min-width: 0; + width: 100%; } -.position-copy { +#position-copy { display: inline-flex; gap: 6px; align-items: center; justify-content: center; - width: 100%; - margin-top: var(--space-3); } -.position-copy .icon { +#position-copy .icon { width: 20px; height: 20px; } + +/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la + * hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions + * dans une colonne à droite. */ +html.landscape .position-view.with-board { + position: fixed; + inset: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 288px; + grid-template-rows: auto auto auto auto 1fr; + max-width: none; + margin: 0; + padding: 0; + overflow-y: auto; + background: var(--background); +} + +html.landscape .position-view.with-board > * { + grid-column: 2; + margin-inline: 0; + padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right)); +} + +html.landscape .position-view.with-board .position-title { + margin-top: var(--space-4); +} + +html.landscape .position-view.with-board .position-board { + grid-row: 1 / -1; + grid-column: 1; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + aspect-ratio: auto; + background: var(--frame); +} + +html.landscape .position-view.with-board .position-actions { + padding-bottom: var(--space-4); +} + +html.landscape .position-view.with-board .position-actions > * { + padding-inline: 16px; +} diff --git a/web/index.html b/web/index.html index 43dae8d..90d97db 100644 --- a/web/index.html +++ b/web/index.html @@ -84,27 +84,31 @@ + jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche + sur toute la hauteur, le reste dans une colonne à droite. -->