diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md
index d14041b..3b753a1 100644
--- a/DESIGN_SYSTEM.md
+++ b/DESIGN_SYSTEM.md
@@ -623,7 +623,7 @@ Modales :
- panneau centre ;
- fermeture au clic exterieur quand l'action le permet ;
- boutons icones pour fermer/zoomer quand approprie.
-- la visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes vignettes non recadrees dans une grille responsive et scrollable ;
+- la visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes cards `4:3`, avec image non recadrée et libellé toujours visible, dans un flux responsive centré et scrollable uniquement verticalement ; les boutons `−` et `+` règlent leur largeur de 180 à 420 px par paliers de 40 px ; les gouttières entre cards restent compactes tandis que le bord de la galerie conserve un padding plus généreux ;
- la vue detaillee expose la position, un retour explicite a la galerie et des boutons precedent/suivant bloques aux extremites ; les fleches gauche/droite reprennent cette navigation ;
- une visionneuse d'image seule, notamment pour les annotations, ne doit afficher ni galerie ni navigation de collection.
diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md
index fa7983e..a61ca92 100644
--- a/docs/FEATURE_CHECKLIST.md
+++ b/docs/FEATURE_CHECKLIST.md
@@ -27,6 +27,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.
+- Pour la galerie d'images, vérifier les boutons de taille `−` / `+`, leur blocage aux limites, la persistance après rechargement, les cards `4:3` avec image et libellé contenus, la redistribution centrée des vignettes, le padding périphérique et l'absence de scroll horizontal.
- Pour l'éditeur de couverture, vérifier les décodages concurrents, les erreurs après fermeture et la libération des bitmaps/URLs ; une image en chargement ne doit pas permettre de valider la couverture précédente.
- Les parcours `e2e/toolbox-cover.spec.js` vérifient le choix d'icône, l'enregistrement d'une couverture 620 × 248, sa persistance après rechargement et import/export, l'annulation et les fichiers illisibles. L'association à un jeu doit afficher sa couverture sans perdre l'image personnalisée, restaurée après dissociation ; choisir une icône doit supprimer la couverture personnalisée.
- Les parcours `e2e/table.spec.js` vérifient les en-têtes éditables sans changer les références des formules, le redimensionnement sans restauration des cellules supprimées, la navigation et la sélection clavier, la copie des valeurs TSV, le collage et l'effacement de blocs, le déplacement à la souris et l'insertion d'une plage dans une formule. Les valeurs et dimensions doivent survivre au rechargement.
diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md
index e4372aa..2bd3885 100644
--- a/docs/STORAGE_SCHEMA.md
+++ b/docs/STORAGE_SCHEMA.md
@@ -3,6 +3,13 @@
Ce document décrit les données persistées dans IndexedDB pour les toolboxes.
Il doit être mis à jour à chaque ajout ou modification d'outil.
+## Préférences locales hors toolbox
+
+`localStorage["sokkog:image-gallery-thumbnail-width"]` conserve la largeur cible
+des aperçus de la galerie d'images. La valeur est bornée de 180 à 420 px, alignée
+sur des paliers de 40 px et vaut 260 px par défaut. Cette préférence d'interface
+globale n'est pas incluse dans les exports de toolboxes.
+
## IndexedDB
Base : `sokkog`
diff --git a/e2e/basic-tools.spec.js b/e2e/basic-tools.spec.js
index 8d992a8..b14d089 100644
--- a/e2e/basic-tools.spec.js
+++ b/e2e/basic-tools.spec.js
@@ -1,4 +1,4 @@
-// Rôle : couvre les mutations persistantes des liens, compteurs et checklists.
+// Rôle : couvre les parcours persistants des liens, compteurs, checklists et galeries d'images.
import { expect, test } from "@playwright/test";
import { Buffer } from "node:buffer";
@@ -151,7 +151,20 @@ test("images open a gallery and navigate the current collection without looping"
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
await expect(dialog).toBeVisible();
- await expect(dialog.locator(".image-viewer-gallery-item")).toHaveCount(3);
+ const thumbnails = dialog.locator(".image-viewer-gallery-item");
+ const gallery = dialog.locator(".image-viewer-gallery");
+ await expect(thumbnails).toHaveCount(3);
+ const initialThumbnailWidth = (await thumbnails.first().boundingBox()).width;
+ await dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true }).click();
+ await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(initialThumbnailWidth + 40, 0);
+ expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("300");
+ expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
+ await page.reload();
+ await tool.getByRole("button", { name: "Galerie", exact: true }).click();
+ await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(300, 0);
+ await dialog.getByRole("button", { name: "Réduire les aperçus", exact: true }).click();
+ await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(260, 0);
await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click();
await expect(page.locator(".image-viewer-title")).toHaveText("Second");
await expect(page.locator(".image-viewer-position")).toHaveText("2 / 3");
@@ -175,6 +188,11 @@ test("images open a gallery and navigate the current collection without looping"
expect(galleryBox.y).toBeGreaterThanOrEqual(0);
expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390);
expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844);
+ const zoomIn = dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true });
+ for (let step = 0; step < 4; step += 1) await zoomIn.click();
+ await expect(zoomIn).toBeDisabled();
+ expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("420");
+ expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
await tool.locator(".image-preview").nth(2).click();
@@ -182,3 +200,48 @@ test("images open a gallery and navigate the current collection without looping"
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Fermer", exact: true }).click();
});
+
+test("image gallery keeps proportional cards and compact centered rows at maximum zoom", async ({ page }) => {
+ await page.setViewportSize({ width: 1550, height: 900 });
+ const makeImage = (index) => `data:image/svg+xml,${encodeURIComponent(``)}`;
+ const tool = await setup(page, "images", {
+ images: Array.from({ length: 7 }, (_, index) => ({ id: `image-${index}`, label: `Image ${index + 1}`, dataUrl: makeImage(index) }))
+ });
+ await page.evaluate(() => globalThis.localStorage.setItem("sokkog:image-gallery-thumbnail-width", "420"));
+ await tool.getByRole("button", { name: "Galerie", exact: true }).click();
+
+ const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
+ const gallery = dialog.locator(".image-viewer-gallery");
+ const cards = gallery.locator(".image-viewer-gallery-item");
+ await expect(cards).toHaveCount(7);
+ await expect.poll(() => cards.first().locator("img").evaluate((image) => image.naturalWidth)).toBe(640);
+ const boxes = await cards.evaluateAll((elements) => elements.map((element) => {
+ const box = element.getBoundingClientRect();
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
+ }));
+ expect(boxes[0].width / boxes[0].height).toBeCloseTo(4 / 3, 2);
+ expect(boxes.slice(0, 3).every((box) => Math.abs(box.y - boxes[0].y) < 1)).toBe(true);
+ expect(boxes.slice(3, 6).every((box) => Math.abs(box.y - boxes[3].y) < 1)).toBe(true);
+ expect(boxes[3].y).toBeGreaterThan(boxes[0].y + boxes[0].height);
+ expect(boxes[6].y).toBeGreaterThan(boxes[3].y + boxes[3].height);
+
+ const gap = boxes[1].x - boxes[0].x - boxes[0].width;
+ const galleryBox = await gallery.boundingBox();
+ expect(gap).toBeGreaterThanOrEqual(10);
+ expect(gap).toBeLessThanOrEqual(14);
+ expect(boxes[0].x - galleryBox.x).toBeGreaterThan(gap * 2);
+ expect(galleryBox.x + galleryBox.width - boxes[2].x - boxes[2].width).toBeGreaterThan(gap * 2);
+ expect(boxes[6].x + boxes[6].width / 2).toBeCloseTo(galleryBox.x + galleryBox.width / 2, 0);
+
+ const firstCard = cards.first();
+ const frameBox = await firstCard.locator(".image-viewer-gallery-frame").boundingBox();
+ const imageBox = await firstCard.locator("img").boundingBox();
+ const captionBox = await firstCard.locator(".image-viewer-gallery-caption").boundingBox();
+ expect(imageBox.x).toBeGreaterThanOrEqual(frameBox.x);
+ expect(imageBox.y).toBeGreaterThanOrEqual(frameBox.y);
+ expect(imageBox.x + imageBox.width).toBeLessThanOrEqual(frameBox.x + frameBox.width);
+ expect(imageBox.y + imageBox.height).toBeLessThanOrEqual(frameBox.y + frameBox.height);
+ expect(captionBox.y + captionBox.height).toBeLessThanOrEqual(boxes[0].y + boxes[0].height);
+ expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ expect(await gallery.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
+});
diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs
index eeab27e..4dc22b1 100644
--- a/tests/helpers/data-validation.mjs
+++ b/tests/helpers/data-validation.mjs
@@ -356,6 +356,9 @@ export function validateSiteContent(site) {
"toolboxes.modules.images.galleryTitle",
"toolboxes.modules.images.galleryImageAriaLabel",
"toolboxes.modules.images.returnGalleryTitle",
+ "toolboxes.modules.images.galleryZoomLabel",
+ "toolboxes.modules.images.galleryZoomOutTitle",
+ "toolboxes.modules.images.galleryZoomInTitle",
"toolboxes.modules.images.previousImageTitle",
"toolboxes.modules.images.nextImageTitle",
"toolboxes.modules.images.positionLabel",
diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index e230f00..f599bd0 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -466,6 +466,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageViewer, /ArrowLeft/);
assert.match(imageViewer, /ArrowRight/);
assert.match(imageViewer, / {
@@ -13,7 +13,7 @@ export function ImageGallery({ images, activeIndex, focusIndex = -1, onSelect, t
}, [focusIndex]);
return (
-