Add checklist switching and contextual memo ticker
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-09-28 09:40:29 +02:00
parent 273bc5d528
commit bd4059c9c1
16 changed files with 231 additions and 28 deletions

View file

@ -370,7 +370,8 @@ Regles UI :
- palette de couleurs compacte reprenant les swatches du Bloc Note ;
- la creation de mémo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ;
- réorganisation des cartes par poignée de drag & drop compacte ;
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos.
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos dans l'outil et reste fixée en bas des pages complètes de cette toolbox ainsi que des pages du jeu qui lui est associé ;
- la bande fixe laisse le contenu verticalement accessible, suit la largeur disponible et remonte au-dessus de la navigation mobile.
### Calque De Dessin
@ -859,10 +860,13 @@ reste dorée lorsque la liste est associée. Le bouton NDS
dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes
non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ;
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é.
Quand une liste est associée, un encart compact placé en haut à droite du header
du guide expose directement le switch « Masquer les cochés » et l'action
« Dissocier la checklist ». La colonne d'actions occupe la hauteur existante du
garde ses commandes avec un message explicite.
Un encart compact placé en haut à droite du header du guide expose un sélecteur dès
qu'au moins une checklist compatible existe. Sans association, lui seul est affiché.
Une fois une liste choisie, l'encart ajoute le switch « Masquer les cochés » et
l'action « Dissocier la checklist ». Changer la
valeur du sélecteur remplace immédiatement l'association sans perdre le réglage de
masquage. La colonne d'actions occupe la hauteur existante du
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette
association est conservée dans `localStorage` entre les sessions ; elle est retirée
si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.

View file

@ -237,6 +237,7 @@ Type : `memos`
Stockage compact :
- `tickerEnabled` vaut `true` par défaut et est omis dans ce cas ;
- lorsqu'il est actif, la bande est aussi affichée en position fixe sur la page complète de la toolbox et sur les pages du jeu lié ;
- `updatedAt` est omis si vide ou invalide ;
- les mémos sans texte sont ignorés ;
- si aucun mémo n'est valide et que la bande est active par défaut, l'entrée de module peut être supprimée.
@ -251,7 +252,9 @@ affiche un toggle de lien à côté du « + » de son en-tête dans le panneau l
Il ouvre une section masquée par défaut en haut du contenu :
bouton toggle d'association/dissociation et switch de masquage, actif seulement pour
la liste associée. Associer une autre liste remplace la précédente et conserve le
réglage de masquage ; dissocier réaffiche tous les éléments. Replier les commandes
réglage de masquage ; le sélecteur affiché dans l'en-tête du guide dès qu'une liste
compatible existe permet le même changement ; sans association, les autres actions
de l'encart restent masquées. Dissocier réaffiche tous les éléments. Replier les commandes
ne modifie pas l'association ; cet état d'affichage reste local au composant.
Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox.

View file

@ -24,6 +24,7 @@ not bugs, missing scenarios or a percentage of work remaining.
| Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` |
| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` |
| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` |
| Mémos : bande fixe, portée par route et persistance du toggle | `e2e/memos-ticker.spec.js` |
| Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` |
| Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` |
| Production onboarding | `e2e/production/onboarding.spec.js` |

View file

@ -132,6 +132,12 @@ for (const width of [1280, 390]) {
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
const card = page.locator(".aniimo-result");
const pageControls = page.locator(".guide-checklist-page-controls");
const unlinkedSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
await expect(pageControls).toBeVisible();
await expect(unlinkedSelect).toHaveValue("");
await expect(unlinkedSelect).toContainText("Choisir une checklist");
await expect(pageControls.getByRole("button")).toHaveCount(0);
await openDrawer(page);
await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(3);
@ -164,7 +170,6 @@ for (const width of [1280, 390]) {
expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page);
const pageControls = page.locator(".guide-checklist-page-controls");
await expect(pageControls).toBeVisible();
const pageControlsBounds = await pageControls.boundingBox();
const pageHeadingBounds = await page.locator(".page-heading").boundingBox();
@ -175,6 +180,14 @@ for (const width of [1280, 390]) {
expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0);
expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0);
}
const checklistSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
await expect(checklistSelect).toHaveValue("forms");
await expect(checklistSelect.locator("option")).toHaveCount(3);
await expect(checklistSelect.locator('option[value="unrelated"]')).toHaveCount(0);
await checklistSelect.selectOption("capture");
await expect(checklistSelect).toHaveValue("capture");
await checklistSelect.selectOption("forms");
await expect(checklistSelect).toHaveValue("forms");
await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false");
await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible();
await expect(card).toHaveAttribute("data-checklist-complete", "true");

56
e2e/memos-ticker.spec.js Normal file
View file

@ -0,0 +1,56 @@
// Rôle : vérifie la bande de mémos fixe sur les pages de toolbox et de jeu associées.
import { test, expect } from "@playwright/test";
import { resetSokkoStorage } from "./helpers/storage.js";
async function seedMemosToolbox(page) {
await resetSokkoStorage(page);
await page.evaluate(async () => {
await new Promise((resolve, reject) => {
const request = globalThis.indexedDB.open("sokkog", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["kv", "modules"], "readwrite");
tx.objectStore("kv").put([
{ id: "memo-toolbox", name: "Toolbox mémos", modules: [{ id: "memos", type: "memos", title: "Mémos" }], moduleColumns: 1 }
], "toolboxes");
tx.objectStore("kv").put({ mhwilds: "memo-toolbox" }, "links");
tx.objectStore("modules").put({
key: "memo-toolbox:memos",
data: { items: [{ id: "memo-1", text: "Préparer les potions", color: "#f6c453" }] }
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
});
});
await page.reload();
}
test("shows and persists the memo ticker on linked game and full toolbox pages", async ({ page }) => {
await seedMemosToolbox(page);
await page.goto("/#/games/mhwilds/monsters");
const globalTicker = page.locator(".global-memos-ticker");
await expect(globalTicker).toBeVisible();
await expect(globalTicker).toContainText("Préparer les potions");
await expect(globalTicker).toHaveCSS("position", "fixed");
await page.goto("/#/games/aniimo/aniimos");
await expect(globalTicker).toHaveCount(0);
await page.goto("/#/toolbox/memo-toolbox");
await expect(globalTicker).toBeVisible();
const toggle = page.locator(".memos-ticker-toggle");
await expect(toggle).toHaveAccessibleName("Masquer la bande");
await toggle.click();
await expect(globalTicker).toHaveCount(0);
await page.reload();
await expect(globalTicker).toHaveCount(0);
await expect(toggle).toHaveAccessibleName("Afficher la bande");
await toggle.click();
await expect(globalTicker).toBeVisible();
await page.goto("/#/toolboxes");
await expect(globalTicker).toHaveCount(0);
});

View file

@ -90,6 +90,8 @@ export function validateSiteContent(site) {
"gamesPage.emptyTitle",
"gamesPage.emptyText",
"guideChecklist.label",
"guideChecklist.selectLabel",
"guideChecklist.selectPlaceholder",
"guideChecklist.associate",
"guideChecklist.noMatches",
"guideChecklist.matches",

View file

@ -2771,6 +2771,8 @@
},
"guideChecklist": {
"label": "Lien avec le guide",
"selectLabel": "Checklist associée",
"selectPlaceholder": "Choisir une checklist",
"associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances",

View file

@ -31,16 +31,27 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
}
export function GuideChecklistPageControls({ checklist }) {
if (!checklist.selected) return null;
if (!checklist.lists.length) return null;
const { content, hideCompleted } = checklist;
return (
<aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}>
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
<Icon name="hide" />
<span>{content.hideCompleted}</span>
<i aria-hidden="true" />
</button>
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
<label className="guide-checklist-page-select">
<span>{content.selectLabel}</span>
<select className="nds-select" data-size="sm" value={checklist.selected?.id || ""} onChange={(event) => checklist.associate(event.target.value)}>
{!checklist.selected && <option value="" disabled>{content.selectPlaceholder}</option>}
{checklist.lists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
</select>
</label>
{checklist.selected && (
<div className="guide-checklist-page-actions">
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
<Icon name="hide" />
<span>{content.hideCompleted}</span>
<i aria-hidden="true" />
</button>
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
</div>
)}
</aside>
);
}

View file

@ -21,6 +21,7 @@ export function useGuideChecklist(requestedGuideId) {
const association = guideChecklistStorage.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id,
title: module.title || "Checklist",
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData]);
const selected = lists.find((list) => list.id === association?.moduleId);

View file

@ -0,0 +1,33 @@
// Rôle : affiche en bas de page les mémos actifs de la toolbox courante ou liée au jeu.
import { useMemo } from "react";
import { moduleStorageKey, normalizeMemosData } from "./storage/toolboxStorage.js";
import { MemosTicker } from "./modules/MemosTicker.jsx";
function getRouteToolboxId(route, links) {
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
if (toolboxMatch) return decodeURIComponent(toolboxMatch[1]);
const gameMatch = route.match(/^\/games\/([^/]+)/);
return gameMatch ? links[gameMatch[1]] || "" : "";
}
export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textContent }) {
const items = useMemo(() => {
const toolboxId = getRouteToolboxId(route, links);
const toolbox = toolboxes.find((item) => item.id === toolboxId);
if (!toolbox) return [];
return toolbox.modules
.filter((module) => module.type === "memos")
.flatMap((module) => {
const data = normalizeMemosData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
if (!data.tickerEnabled) return [];
return data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` }));
});
}, [route, toolboxes, links, moduleData]);
if (!items.length) return null;
return (
<aside className="global-memos-ticker" aria-label={textContent?.tickerLabel || "Mémos"}>
<MemosTicker items={items} label={textContent?.tickerLabel || "Mémos"} />
</aside>
);
}

View file

@ -4,6 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useMemo } from "react";
import { MemoTextEditor } from "./MemoTextEditor.jsx";
import { MemosTicker } from "./MemosTicker.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TEXT_COLORS } from "./notepadUtils.js";
@ -92,21 +93,12 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
type="button"
onClick={() => save({ ...data, tickerEnabled: !data.tickerEnabled })}
aria-pressed={data.tickerEnabled}
aria-label={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
title={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
>
<Icon name={data.tickerEnabled ? "eye-open" : "eye-closed"} />
</button>
{tickerItems.length > 0 && (
<div className="memos-ticker" aria-label={textContent.tickerLabel || "Mémos"}>
<div className="memos-ticker-track">
{[...tickerItems, ...tickerItems].map((memo, index) => (
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.text}
</span>
))}
</div>
</div>
)}
<MemosTicker items={tickerItems} label={textContent.tickerLabel || "Mémos"} />
</div>
<div className="memos-list">

View file

@ -0,0 +1,15 @@
// Rôle : affiche la bande déroulante partagée par l'outil Mémos et les pages associées.
export function MemosTicker({ items, label = "Mémos", className = "" }) {
if (!items?.length) return null;
return (
<div className={["memos-ticker", className].filter(Boolean).join(" ")} aria-label={label}>
<div className="memos-ticker-track">
{[...items, ...items].map((memo, index) => (
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.text}
</span>
))}
</div>
</div>
);
}

View file

@ -18,6 +18,7 @@ import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js";
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
@ -211,6 +212,13 @@ function App() {
/>
</Suspense>
)}
<GlobalMemosTicker
route={route}
toolboxes={store.toolboxes}
links={store.links}
moduleData={store.moduleData}
textContent={siteContent.toolboxes.modules.memos}
/>
<AppOverlays
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
actions={actions}

View file

@ -101,6 +101,11 @@
justify-content: space-between;
}
.guide-checklist-page-actions {
flex-wrap: wrap;
justify-content: space-between;
}
.home-onboarding-steps-action {
padding-left: 0;
}

View file

@ -18,13 +18,31 @@
justify-content: space-between;
}
.guide-checklist-page-controls {
display: flex;
display: grid;
align-self: flex-end;
gap: var(--space-2);
width: min(100%, 360px);
padding: 8px;
}
.guide-checklist-page-select {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
}
.guide-checklist-page-select > span { white-space: nowrap; }
.guide-checklist-page-select .nds-select {
width: auto;
min-width: 0;
flex: 1;
}
.guide-checklist-page-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
width: fit-content;
padding: 8px;
}
.guide-checklist-page-controls .nds-switch { max-width: 100%; }
.game-heading-navigation {

View file

@ -37,6 +37,45 @@
background: rgba(5, 7, 17, 0.56);
}
.global-memos-ticker {
position: fixed;
right: 12px;
bottom: 12px;
left: 260px;
z-index: 45;
padding: 6px;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-lg);
background: rgba(7, 9, 19, 0.9);
box-shadow: var(--shadow-md);
backdrop-filter: blur(10px);
}
.global-memos-ticker .memos-ticker {
width: 100%;
background: rgba(5, 7, 17, 0.72);
}
.app-shell.sidebar-compact .global-memos-ticker {
left: 84px;
}
.app-shell:has(.global-memos-ticker) main {
padding-bottom: calc(var(--space-12) + 60px);
}
@media (max-width: 1024px) {
.global-memos-ticker { left: 84px; }
}
@media (max-width: 760px) {
.global-memos-ticker {
right: 12px;
bottom: 84px;
left: 12px;
}
}
.memos-ticker-track {
display: flex;
width: max-content;