diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md
index 7e4b4b1..70bf8fd 100644
--- a/DESIGN_SYSTEM.md
+++ b/DESIGN_SYSTEM.md
@@ -816,10 +816,10 @@ Date picker et menus compacts :
- le compteur met en évidence les Aniimos ou les variantes selon le mode de copie ; l’infobulle du bouton précise le contenu copié ;
- le verso occupe exactement la même surface que le recto ; garder son en-tête fixe et faire défiler les détails dans une zone NDS accessible au clavier ; masquer la face inactive aux technologies d'assistance ;
- réduire l'animation selon `prefers-reduced-motion` ;
-- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ;
+- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ; les compétences de logis sont toujours cumulatives et leur sélecteur compact `≥ / =`, aligné comme « Correspondance », utilise le niveau minimum par défaut ; les libellés complets restent accessibles ;
- la chip « Prismana uniquement », sous le nom, garde 12 px de marge inférieure, utilise une étoile SVG arc-en-ciel et limite les résultats à `prismana_form` avant regroupement ; inactive par défaut, elle se cumule avec tous les autres critères et se désactive à la réinitialisation ;
- les catégories Rôles, Éléments et Compétences de logis partagent un défilement unique via `filter-options`, comme MHW ; des séparateurs discrets délimitent les catégories et leurs en-têtes permettent de les replier sans désactiver leurs sélections ;
-- rôles et éléments sont sur deux colonnes de chips `sm` ; les compétences élémentaires précèdent les autres sans séparation interne, avec icône, texte, curseur et valeur sur une ligne ; un seuil à 0 reste visible mais ne participe pas aux opérations ET/OU ;
+- rôles et éléments sont sur deux colonnes de chips `sm` ; les compétences élémentaires précèdent les autres sans séparation interne, avec icône, texte, curseur et valeur sur une ligne ; un seuil à 0 reste visible mais ne participe pas au filtrage ;
- garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
### Liaison des guides aux checklists
@@ -835,9 +835,13 @@ non associées. Les commandes sont centrées et reviennent à la ligne sur mobil
le nombre de correspondances figure dessous. Une liste active devenue incompatible
garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de
-catégorie Diablo), avec une coche accessible « Coché ». Les textes restent
+catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
+accessible « Coché » reste sur le verso des cartes retournables. Les textes restent
lisibles et les indicateurs absolus ne modifient pas la taille des cartes.
-Dans les cartes retournables, les indicateurs appartiennent à chaque face.
+Lorsqu'une carte correspond à la checklist associée, une checkbox NDS apparaît en
+bas à droite de la zone d'image du recto (ou de la carte pour un affixe sans image).
+Elle met à jour toutes les lignes exactes correspondantes, sans retourner la carte ;
+pour Aniimo, la forme spécifique reste prioritaire sur l'entrée générale.
## 18. Scrollbars
diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md
index 8932dfc..bd84ec1 100644
--- a/docs/FEATURE_CHECKLIST.md
+++ b/docs/FEATURE_CHECKLIST.md
@@ -53,7 +53,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier le bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne.
-- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. Les guides ne doivent jamais modifier la progression.
+- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes.
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
- Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés.
diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md
index a74140e..e75b3ff 100644
--- a/docs/STORAGE_SCHEMA.md
+++ b/docs/STORAGE_SCHEMA.md
@@ -251,6 +251,10 @@ Les lignes étrangères sont ignorées. Une correspondance est terminée lorsque
toutes ses lignes atteignent leur propre `qtyTarget` ; les doublons ne sont pas
additionnés. Renommer une ligne peut supprimer sa correspondance. Une liste encore
sélectionnée mais sans correspondance reste liée, avec un message explicite.
+Une checkbox sur la carte d'un guide peut modifier la progression de toutes les
+lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`,
+décocher le remet à `0`. Les données passent par le flux React/IndexedDB existant ;
+aucune copie de progression n'est conservée dans l'état de session du guide.
La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en
affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne
diff --git a/e2e/aniimo.spec.js b/e2e/aniimo.spec.js
index 46c3b26..3a2a49e 100644
--- a/e2e/aniimo.spec.js
+++ b/e2e/aniimo.spec.js
@@ -149,9 +149,16 @@ test("tabs, flip, matching variants and reset remain independent", async ({ page
test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
await page.goto(route);
const abilities = section(page, "Compétences de logis");
- const carry = page.getByRole("slider", { name: "Porter — Niveau minimum" });
- const crafting = page.getByRole("slider", { name: "Artisanat — Niveau minimum" });
+ const carry = page.locator("#aniimo-minimum-carry");
+ const crafting = page.locator("#aniimo-minimum-crafting");
await expect(abilities.getByRole("checkbox")).toHaveCount(0);
+ await expect(abilities.getByRole("button", { name: "OU", exact: true })).toHaveCount(0);
+ const minimumLevel = abilities.getByRole("button", { name: "Niveau minimum", exact: true });
+ const exactLevel = abilities.getByRole("button", { name: "Niveau exact", exact: true });
+ await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
+ await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
+ await expect(carry).toHaveAccessibleName("Porter — Niveau minimum");
+ await expect(crafting).toHaveAccessibleName("Artisanat — Niveau minimum");
await expect(carry).toHaveValue("0");
await expect(carry).toHaveAttribute("min", "0");
await carry.focus();
@@ -165,7 +172,10 @@ test("ability levels use independent native ranges and retain name constraints",
await crafting.press("ArrowRight");
await expect(crafting).toHaveValue("2");
await expect(crafting).toHaveCSS("outline-style", "solid");
- await abilities.getByRole("button", { name: "OU", exact: true }).click();
+ await exactLevel.click();
+ await expect(exactLevel).toHaveAttribute("aria-pressed", "true");
+ await expect(minimumLevel).toHaveAttribute("aria-pressed", "false");
+ await expect(carry).toHaveAccessibleName("Porter — Niveau exact");
await expect(section(page, "Éléments").getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
await carry.press("Home");
await crafting.press("Home");
@@ -173,6 +183,9 @@ test("ability levels use independent native ranges and retain name constraints",
await expect(cards(page)).toHaveCount(88);
await page.getByRole("searchbox").fill("does-not-exist");
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
+ await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
+ await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
+ await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
});
test("seven tabs support keyboard navigation and fall back after filtering", async ({ page }) => {
diff --git a/e2e/guide-checklist.spec.js b/e2e/guide-checklist.spec.js
index 9719491..19d5d85 100644
--- a/e2e/guide-checklist.spec.js
+++ b/e2e/guide-checklist.spec.js
@@ -126,6 +126,7 @@ test("Aniimo copies visible species or forms and round-trips through checklist i
for (const width of [1280, 390]) {
test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => {
+ test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 });
await seed(page);
await page.goto(route);
@@ -164,11 +165,22 @@ for (const width of [1280, 390]) {
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
- await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveAccessibleName("Coché");
+ await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0);
+ await card.getByRole("button", { name: /Afficher les capacités/ }).click();
+ await expect(card.locator(".aniimo-back .guide-checklist-mark")).toHaveAccessibleName("Coché");
+ await card.getByRole("button", { name: /Afficher le portrait/ }).click();
+ const cardCheckbox = card.getByRole("checkbox", { name: "Décocher dans la checklist" });
+ await expect(cardCheckbox).toBeChecked();
+ await cardCheckbox.uncheck();
+ await expect(card).not.toHaveAttribute("data-checklist-complete", "true");
+ await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "true");
+ await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0);
+ await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
+ await expect(card).toHaveAttribute("data-checklist-complete", "true");
await toggleHidden(page);
await expect(card.getByRole("tab")).toHaveCount(2);
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
- expect(await readModule(page, "forms")).toEqual(before);
+ expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items);
await choose(page, "capture");
await assertLink(page, "capture", true);
await expect(card.getByRole("tab")).toHaveCount(3);
@@ -214,6 +226,13 @@ for (const [gameId, category, cardClass, dataPath, dataKey] of [
const count = await page.locator(cardClass).count();
await choose(page, "progress");
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
+ const completedCard = page.locator(`${cardClass}[data-checklist-complete="true"]`);
+ const cardCheckbox = completedCard.getByRole("checkbox", { name: "Décocher dans la checklist" });
+ await expect(cardCheckbox).toBeChecked();
+ await cardCheckbox.uncheck();
+ await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(0);
+ await page.locator(cardClass).first().getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
+ await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
await toggleHidden(page);
await expect(page.locator(cardClass)).toHaveCount(count - 1);
await page.setViewportSize({ width: 390, height: 844 });
diff --git a/tests/aniimo.test.mjs b/tests/aniimo.test.mjs
index d638bc2..e290b4d 100644
--- a/tests/aniimo.test.mjs
+++ b/tests/aniimo.test.mjs
@@ -94,10 +94,11 @@ test("element AND never borrows elements or roles from the parent or sibling var
test("ability thresholds remain independent, inclusive and constrained to a single variant", () => {
assert.equal(filter({ abilities: { carry: 3, crafting: 2 } }, fixture).length, 0);
- assert.equal(filter({ abilities: { carry: 3, crafting: 2 }, abilitiesLogic: "or" }, fixture).length, 3);
- assert.deepEqual(filter({ abilities: { carry: 4, crafting: 2 }, abilitiesLogic: "or" }, fixture).map((v) => v.form), ["water_form", "both_form"]);
assert.deepEqual(filter({ abilities: { carry: 3, crafting: 1 } }, fixture).map((v) => v.form), ["both_form"]);
- assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilitiesLogic: "or", abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
+ assert.deepEqual(filter({ abilities: { carry: 3 }, abilityExact: true }, fixture).map((v) => v.form), ["rock_form"]);
+ assert.deepEqual(filter({ abilities: { carry: 4 }, abilityExact: true }, fixture).map((v) => v.form), ["both_form"]);
+ assert.equal(filter({ abilities: { carry: 2, crafting: 1 }, abilityExact: true }, fixture).length, 0);
+ assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
});
test("grouping preserves order and contains only variants that pass all filters", () => {
diff --git a/tests/guide-checklist.test.mjs b/tests/guide-checklist.test.mjs
index a7d94ca..61ddff8 100644
--- a/tests/guide-checklist.test.mjs
+++ b/tests/guide-checklist.test.mjs
@@ -1,7 +1,7 @@
// Rôle : vérifie les correspondances des guides et la validité des associations de session.
import assert from "node:assert/strict";
import test from "node:test";
-import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
+import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
const variants = [{ key: "1:base", name: "emberpup", form: "base" }, { key: "1:mountain", name: "emberpup", form: "mountain" }];
const entries = aniimoChecklistEntries(variants, { emberpup: "Loufeuteau", base: "Forme de base", mountain: "Montagnes" }, { emberpup: "Emberpup", base: "Basic form", mountain: "Mountain form" });
@@ -43,6 +43,21 @@ test("all duplicate lines must reach their own target, including translated alia
assert.equal(matchGuideChecklist(entries, {}).count, 0);
});
+test("card toggles update every exact matching row to its target or zero", () => {
+ const data = { sections: [{ id: "s1", items: [
+ { id: "a", label: "Loufeuteau", qtyTarget: 3, qtyCurrent: 1 },
+ { id: "b", label: "Emberpup", qtyTarget: 2, qtyCurrent: 0 },
+ { id: "c", label: "Unrelated", qtyCurrent: 1 }
+ ] }] };
+ const match = matchGuideChecklist(entries, data);
+ assert.deepEqual(match.rowIds.get("1:base"), ["a", "b"]);
+ const completed = toggleGuideChecklistRows(data, match.rowIds.get("1:base"), true);
+ assert.deepEqual(completed.sections[0].items.map((item) => item.qtyCurrent), [3, 2, 1]);
+ const cleared = toggleGuideChecklistRows(completed, match.rowIds.get("1:base"), false);
+ assert.deepEqual(cleared.sections[0].items.map((item) => item.qtyCurrent), [0, 0, 1]);
+ assert.equal(data.sections[0].items[0].qtyCurrent, 1);
+});
+
test("session associations require the current linked toolbox and an existing checklist", () => {
const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };
diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs
index 644edb4..512b863 100644
--- a/tests/helpers/data-validation.mjs
+++ b/tests/helpers/data-validation.mjs
@@ -96,6 +96,8 @@ export function validateSiteContent(site) {
"guideChecklist.unlink",
"guideChecklist.hideCompleted",
"guideChecklist.completed",
+ "guideChecklist.checkCard",
+ "guideChecklist.uncheckCard",
"aniimo.copyChecklist",
"aniimo.copyVariantsChecklist",
"aniimo.statsTitle",
@@ -118,7 +120,8 @@ export function validateSiteContent(site) {
"aniimo.role",
"aniimo.abilities",
"aniimo.minimum",
- "aniimo.abilityLevelsHint",
+ "aniimo.exactLevel",
+ "aniimo.levelMode",
"aniimo.abilityDisabled",
"aniimo.level",
"aniimo.match",
diff --git a/website/public/data/site.json b/website/public/data/site.json
index c514975..70c204b 100644
--- a/website/public/data/site.json
+++ b/website/public/data/site.json
@@ -2750,7 +2750,9 @@
"matches": "correspondances",
"unlink": "Dissocier",
"hideCompleted": "Masquer les cochés",
- "completed": "Coché"
+ "completed": "Coché",
+ "checkCard": "Cocher dans la checklist",
+ "uncheckCard": "Décocher dans la checklist"
},
"aniimo": {
"title": "Aniimos",
@@ -2770,7 +2772,8 @@
"role": "Rôle",
"abilities": "Compétences de logis",
"minimum": "Niveau minimum",
- "abilityLevelsHint": "0 : sans filtre · 1–4 : niveau minimum",
+ "exactLevel": "Niveau exact",
+ "levelMode": "Niveau",
"abilityDisabled": "Sans filtre",
"level": "Niv.",
"match": "Correspondance",
diff --git a/website/src/features/games/GuideChecklistControls.jsx b/website/src/features/games/GuideChecklistControls.jsx
index 49cd32d..d47d28e 100644
--- a/website/src/features/games/GuideChecklistControls.jsx
+++ b/website/src/features/games/GuideChecklistControls.jsx
@@ -33,3 +33,12 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
export function GuideChecklistMark({ completed, label }) {
return completed ? ✓ : null;
}
+
+export function GuideChecklistCheckbox({ checklist, itemKey }) {
+ if (!checklist.canToggle(itemKey)) return null;
+ const completed = checklist.isCompleted(itemKey);
+ const label = completed ? checklist.content.uncheckCard : checklist.content.checkCard;
+ return ;
+}
diff --git a/website/src/features/games/aniimo/AniimoCard.jsx b/website/src/features/games/aniimo/AniimoCard.jsx
index ff2de3a..9aa2be9 100644
--- a/website/src/features/games/aniimo/AniimoCard.jsx
+++ b/website/src/features/games/aniimo/AniimoCard.jsx
@@ -1,6 +1,6 @@
// Rôle : affiche une carte Aniimo retournable et les onglets des variantes correspondantes.
import { useId, useLayoutEffect, useRef, useState } from "react";
-import { GuideChecklistMark } from "../GuideChecklistControls.jsx";
+import { GuideChecklistCheckbox, GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { ANIIMO_STATS, aniimoAsset } from "./utils.js";
@@ -15,7 +15,8 @@ function shortForm(label) {
return (words.length === 1 ? words[0].slice(0, 3) : words.map((word) => word[0]).join("")).toLocaleUpperCase("fr");
}
-function CardFaces({ variant, t, content, completed, completedLabel, statsMax }) {
+function CardFaces({ variant, t, content, checklist, statsMax }) {
+ const completed = checklist.isCompleted(variant.key);
const [flipped, setFlipped] = useState(false);
const frontButton = useRef(null);
const backButton = useRef(null);
@@ -35,8 +36,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })