diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 676b0c2..d9e5b16 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -33,3 +33,4 @@ {"id":"int-4b64d68baf132fa99cb3244d8487034c","kind":"field_change","created_at":"2026-09-11T12:05:04.580370366Z","actor":"Shinuwa","issue_id":"sokko-g-kqo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared keyed state initializes Notepad/ImageAnnotation temporary drawings and RNG history before DOM commit; ignores setters bound to a different active key. Storage keys and write/error paths preserved. npm run check:full passed: 100 Node tests and 23 E2E including StrictMode key switches and real Notepad canvas/page/reload checks."}} {"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}} {"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}} +{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 5510f99..cfdfb23 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -7,6 +7,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`. - Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell. - Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie. +- Dans la Librairie, vérifier les liens directs vers un outil, les changements d'ancre et le retour navigateur ; une nouvelle sélection ou une sortie de page doit annuler les défilements différés précédents. - Ajouter les textes nécessaires dans `website/public/data/site.json`. - Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`. - Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`. diff --git a/e2e/library.spec.js b/e2e/library.spec.js index c0f12b7..d50c2fd 100644 --- a/e2e/library.spec.js +++ b/e2e/library.spec.js @@ -1,4 +1,5 @@ -// Rôle : vérifie le chargement des outils de chaque catégorie de la librairie. +// Rôle : vérifie le chargement, la navigation par ancres et le nettoyage des défilements de la librairie. +/* global window, document */ import { test, expect } from "@playwright/test"; import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js"; @@ -22,3 +23,54 @@ test("loads every library tool through the shared component registry", async ({ } expect(errors).toEqual([]); }); + +test("library follows deep links, same-page hashes and browser history", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/#/library#library-tool-m6"); + await expect(page.locator('[data-module-type="combos"]')).toBeVisible(); + await expect(page.locator("#library-category-buildsCommands")).toBeVisible(); + await page.evaluate(() => { window.location.hash = "/library#library-tool-m8"; }); + await expect(page.locator('[data-module-type="table"]')).toBeVisible(); + await expect(page.locator("#library-category-calculationData")).toBeVisible(); + await page.goBack(); + await expect(page.locator('[data-module-type="combos"]')).toBeVisible(); + await page.evaluate(() => { window.location.hash = "/library#%E0%A4%A"; }); + await expect(page.locator("#library-category-notesTracking")).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("library cancels obsolete scroll retries on selection and unmount", async ({ page }) => { + await page.goto("/#/library"); + await expect(page.locator('[data-module-type="notepad"]')).toBeVisible(); + await page.clock.install({ time: new Date("2026-09-11T12:00:00Z") }); + await page.clock.pauseAt(new Date("2026-09-11T12:00:01Z")); + await page.evaluate(() => { + window.libraryScrolls = []; + window.scrollTo = (options) => window.libraryScrolls.push(options); + }); + const nav = page.locator(".library-doc-nav"); + await nav.locator('a[href="#/library#library-tool-m0"]').dispatchEvent("click"); + await page.clock.runFor(40); + expect(await page.evaluate(() => window.libraryScrolls.length)).toBeGreaterThan(0); + await nav.locator('a[href="#/library#library-tool-m1"]').dispatchEvent("click"); + await page.evaluate(() => { window.libraryScrolls = []; }); + await page.clock.runFor(700); + const scrolls = await page.evaluate(() => window.libraryScrolls); + expect(scrolls).toHaveLength(4); + expect(scrolls.map((scroll) => scroll.behavior)).toEqual(["smooth", "auto", "auto", "auto"]); + + await nav.locator('a[href="#/library#library-tool-m2"]').dispatchEvent("click"); + await page.clock.runFor(40); + await page.evaluate(() => { window.location.hash = "/"; }); + await expect(page.locator(".library-page")).toHaveCount(0); + // Garder une cible homonyme permet de détecter un ancien timer après démontage. + await page.evaluate(() => { + const target = document.createElement("div"); + target.id = "library-tool-m2"; + document.body.append(target); + window.libraryScrolls = []; + }); + await page.clock.runFor(700); + expect(await page.evaluate(() => window.libraryScrolls)).toEqual([]); +}); diff --git a/website/src/pages/LibraryPage.jsx b/website/src/pages/LibraryPage.jsx index 9ce5215..ceb7109 100644 --- a/website/src/pages/LibraryPage.jsx +++ b/website/src/pages/LibraryPage.jsx @@ -1,5 +1,5 @@ // Rôle : affiche la librairie des outils avec des exemples locaux non persistés. -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../components/Icon.jsx"; import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js"; import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx"; @@ -62,7 +62,11 @@ function getLibraryCategoryAnchorId(category) { function getCurrentLibraryAnchor() { const match = location.hash.match(/^#\/library#(.+)$/); - return match ? decodeURIComponent(match[1]) : ""; + try { + return match ? decodeURIComponent(match[1]) : ""; + } catch { + return ""; + } } function getLibraryAnchorOffset() { @@ -71,17 +75,20 @@ function getLibraryAnchorOffset() { return topbarHeight + 16; } -function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") { - if (!anchorId) return; - if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`); - LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => { - window.setTimeout(() => { +function scrollToLibraryTool(anchorId, behavior) { + let timers = []; + const frame = requestAnimationFrame(() => { + timers = LIBRARY_ANCHOR_RETRY_DELAYS.map((delay, index) => window.setTimeout(() => { const element = document.getElementById(anchorId); if (!element) return; const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset(); window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" }); - }, delay); + }, delay)); }); + return () => { + cancelAnimationFrame(frame); + timers.forEach((timer) => window.clearTimeout(timer)); + }; } function findCategoryForAnchor(categories, anchorId) { @@ -114,9 +121,13 @@ export function LibraryPage({ siteContent, actions }) { const [libraryPayload, setLibraryPayload] = useState(null); const [libraryError, setLibraryError] = useState(""); const [moduleData, setModuleData] = useState({}); - const [activeCategoryKey, setActiveCategoryKey] = useState(""); + const [navigation, setNavigation] = useState(() => ({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" })); const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]); - const activeCategory = libraryCategories.find((category) => category.key === activeCategoryKey) || libraryCategories[0] || null; + const activeCategory = findCategoryForAnchor(libraryCategories, navigation.anchorId) || libraryCategories[0] || null; + const selectAnchor = useCallback((anchorId) => { + history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`); + setNavigation({ anchorId, behavior: "smooth" }); + }, []); useEffect(() => { let cancelled = false; @@ -137,13 +148,15 @@ export function LibraryPage({ siteContent, actions }) { }, []); useEffect(() => { - if (!libraryPayload || !libraryCategories.length) return; - const anchorId = getCurrentLibraryAnchor(); - const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0]; - setActiveCategoryKey(category.key); - if (!anchorId) return; - requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto")); - }, [libraryPayload, libraryCategories]); + const onHashChange = () => setNavigation({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" }); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => { + if (!activeCategory?.key || !navigation.anchorId) return undefined; + return scrollToLibraryTool(navigation.anchorId, navigation.behavior); + }, [activeCategory?.key, navigation]); const moduleContext = useMemo(() => ({ getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback, @@ -199,9 +212,9 @@ export function LibraryPage({ siteContent, actions }) { ...current, [`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } } })); - requestAnimationFrame(() => scrollToLibraryTool(getLibraryAnchorId(annotationModule), true)); + selectAnchor(getLibraryAnchorId(annotationModule)); } - }), [actions, libraryPayload, moduleData, siteContent.toolboxes.modules]); + }), [actions, libraryPayload, moduleData, selectAnchor, siteContent.toolboxes.modules]); return (
@@ -235,8 +248,7 @@ export function LibraryPage({ siteContent, actions }) { categories={libraryCategories} activeCategoryKey={activeCategory?.key || ""} onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => { - setActiveCategoryKey(category.key); - requestAnimationFrame(() => scrollToLibraryTool(anchorId, true)); + selectAnchor(anchorId); }} />