Add planner-aware memos and unified tool configuration panels
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-10-03 15:58:33 +02:00
parent acd9b2fe11
commit 808610dcf2
53 changed files with 842 additions and 329 deletions

View file

@ -69,3 +69,7 @@
{"id":"int-4b71144d1e015bb3fa6cf94d5fb2c90b","kind":"field_change","created_at":"2026-10-01T13:08:30.477630845Z","actor":"Shinuwa","issue_id":"sokko-g-mag","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
{"id":"int-73ab6f8b2af7fdd86148531fd1eacb65","kind":"field_change","created_at":"2026-10-01T13:08:36.827446402Z","actor":"Shinuwa","issue_id":"sokko-g-fpd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
{"id":"int-1ee4fbc06ba1ca47d57a3938ce5c647a","kind":"field_change","created_at":"2026-10-01T13:17:28.985204569Z","actor":"Shinuwa","issue_id":"sokko-g-aut","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Backfilled 13 retrospective issues covering all 23 commits, validated the repository, initialized the empty Dolt remote, and published the complete Beads history."}}
{"id":"int-e642f21f88bfa85d4b4ba7746e994cd3","kind":"field_change","created_at":"2026-10-03T11:31:45.398002709Z","actor":"Shinuwa","issue_id":"sokko-g-noo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented Memos planner linking, reset warning ticker items, deletion cleanup, import/export remapping, documentation, unit coverage, and Playwright coverage. npm run check and the focused Memos E2E suite pass."}}
{"id":"int-e4050bca9a4c650f7447b7dac6c81ebb","kind":"field_change","created_at":"2026-10-03T13:02:57.878785474Z","actor":"Shinuwa","issue_id":"sokko-g-8wa","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented shared toolbox configuration panels, migrated tool settings and text exchange, updated responsive styles, docs, and tests."}}
{"id":"int-c4a9c3aa90751550c16cf7e201af7259","kind":"field_change","created_at":"2026-10-03T13:40:04.048932123Z","actor":"Shinuwa","issue_id":"sokko-g-0x3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Refined Configuration panel into a compact integrated layout with Organisation and Data on the first row, full-width tool settings below, shorter visible data actions, responsive stacking, documentation, and E2E coverage."}}
{"id":"int-11873ba8e71cce0b835e8d8d2124359b","kind":"field_change","created_at":"2026-10-03T13:48:06.348778272Z","actor":"Shinuwa","issue_id":"sokko-g-mwn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Aligned the Espace select and import/export actions at small size, removed the redundant visible move label, retained accessible naming, and updated documentation and tests."}}

View file

@ -200,7 +200,12 @@ Style courant :
- hover violet/violet clair moins intense que les cards ;
- pas de mouvement au hover ;
- contenu interne au-dessus des pseudo-bordures via `z-index`.
- chevron de repli tout à droite de l'en-tête ; l'état replié conserve uniquement l'en-tête visible et retire son séparateur bas.
- l'en-tête suit toujours l'ordre poignée de déplacement, icône, titre, switch de scroll éventuel, ajout éventuel, Configuration, suppression, chevron ; le titre peut se réduire mais les actions ne s'étirent pas ;
- le chevron de repli reste tout à droite ; l'état replié conserve l'en-tête et peut afficher le panneau Configuration sans révéler le contenu métier ;
- l'engrenage Configuration ouvre un panneau pleine largeur sous l'en-tête, nommé par `aria-controls` / `aria-expanded`. Il regroupe conditionnellement Espace, Guide, réglages propres à l'outil et Données (import/export) ; ouvrir l'ajout ferme Configuration et réciproquement ;
- dans Configuration, Espace et Données partagent la première ligne en deux colonnes. Espace affiche directement le sélecteur compact dont le placeholder décrit l'action. Les réglages propres à l'outil et la liaison Guide occupent toute la largeur en dessous ; si Espace est absent, Données utilise toute la première ligne ;
- le panneau Configuration reste une surface intégrée à l'outil : un seul contour discret, des sous-sections sans effet de carte imbriquée, séparées uniquement par des filets légers, avec libellés et contrôles compacts ;
- sur mobile, l'en-tête se répartit sur deux lignes compactes et les sections/champs de Configuration passent en une colonne, dans l'ordre Espace, Données puis réglages spécifiques.
Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code.
@ -296,7 +301,7 @@ Regles UI :
- en vue mensuelle, les evenements multi-jours s'affichent comme une barre horizontale et ne sont pas dupliques dans chaque cellule ;
- les vues hebdomadaire et mensuelle affichent un indicateur discret du moment actuel.
- en vue hebdomadaire, un switch standard peut masquer les longues plages horaires vides ; la tranche masquée reste indiquée dans la grille, pas dans un texte qui déborde de la colonne horaire.
- le calendrier supporte l'import/export texte via le toggle import/export du header, pour les vues hebdomadaire et mensuelle.
- le calendrier supporte l'import/export texte via la section Données du panneau Configuration, pour les vues hebdomadaire et mensuelle.
### Arbre D'objectifs
@ -383,7 +388,7 @@ 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 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é ;
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo ou une alerte de planificateur existe ; elle affiche tous les mémos dans l'outil, peut intégrer les tâches non effectuées proches du reset depuis un ou tous les planificateurs de la même toolbox, 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
@ -871,11 +876,10 @@ Date picker et menus compacts :
### Liaison des guides aux checklists
Dans la toolbox latérale, chaque checklist compatible avec le guide ouvert affiche
un bouton icône de lien à côté du « + » dans son en-tête. Ce toggle NDS affiche
ou masque la section `guide-checklist-controls`, masquée par défaut, sans désactiver
le suivi. Il expose `aria-expanded`, `aria-pressed` et `aria-controls` ; son icône
reste dorée lorsque la liste est associée. Le bouton NDS
Dans la toolbox latérale, chaque checklist compatible avec le guide ouvert expose
sa liaison dans la section Guide du panneau Configuration commun. L'engrenage
conserve un marqueur d'état lorsque la liste est associée et expose `aria-expanded`
et `aria-controls`. Le bouton NDS
« Associer à la page » / « Dissocier » expose `aria-pressed` ; activer une liste
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 ;

View file

@ -17,10 +17,12 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer.
- Pour Images et Annotation d'images, vérifier la lecture différée en page complète et panneau latéral, la reprise après échec local et l'absence d'écrasement pendant le chargement. Les alertes des autres outils doivent rester actives sans ouvrir les médias.
- Vérifier qu'une création, un import ou une suppression préserve les médias non lus des autres outils ; les exports doivent les inclure et refuser un téléchargement partiel en cas d'échec. Une lecture en cours ne doit pas réintroduire des données supprimées.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis la section Données du panneau Configuration.
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
- Vérifier l'affichage dans la page toolbox complète.
- Vérifier l'affichage dans le panneau latéral.
- Vérifier l'ordre compact du header en une et deux colonnes et sur mobile : déplacement, icône, titre, scroll éventuel, ajout éventuel, Configuration, suppression, chevron. Le panneau Configuration doit prendre toute la largeur, placer Espace et Données sur deux colonnes puis les réglages spécifiques sur toute la ligne, et empiler ces sections dans cet ordre sur mobile.
- Vérifier que Configuration et ajout se ferment mutuellement, qu'Échap ferme Configuration et qu'un outil replié peut l'ouvrir sans afficher son contenu métier.
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
- Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile.
- Vérifier qu'un changement de page avec le focus actif remplace immédiatement le contenu de l'éditeur ; une mise à jour externe de la même page attend le blur sans déplacer la sélection pendant la saisie ni reprendre le focus ensuite.
@ -33,7 +35,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- 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.
- Les parcours `e2e/text-exchange.spec.js` vérifient les modales partagées via Tableau et Calculateur : export désactivé à vide, validation des imports, conservation des données après erreur, brouillon effacé à la réouverture, export en lecture seule et copie exacte. L'import doit persister avec ses formules, libellés et relations ; Annuler, Fermer, Échap et le fond de modale doivent fermer correctement, avec restitution du défilement.
- Les parcours `e2e/task-planner.spec.js` couvrent création, renommage, description multiligne, catégorie, suppression et persistance. Un parent transmet sa catégorie ; un prérequis manquant avertit sans bloquer la validation. Supprimer un parent doit conserver l'enfant sans relation orpheline. Avec une horloge contrôlée, les tâches quotidiennes et hebdomadaires se réinitialisent, les ponctuelles restent cochées ; masquage et nettoyage doivent préserver les autres tâches.
- Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante.
- Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante ; vérifier aussi les liaisons Aucun / Tous / planificateur précis, les alertes de tâches proches du reset et le retour à Aucun après suppression du dernier planificateur lié.
- Les parcours `e2e/calculator.spec.js` vérifient la validation des formules, l'édition parent/enfant avec recalcul et persistance, la suppression d'une branche active, le retour à un calcul racine, la réinitialisation du brouillon sans effacer les résultats, la copie pour checklist et la persistance du défilement. La réorganisation des racines doit conserver leurs descendants et refuser de changer le parent d'un enfant.
- Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur.
- Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu.

View file

@ -237,6 +237,7 @@ Type : `memos`
```json
{
"tickerEnabled": true,
"plannerLink": "mod-task-planner",
"items": [
{
"id": "memo1",
@ -252,6 +253,8 @@ 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é ;
- `plannerLink` vaut `""` par défaut, `"all"` pour suivre tous les planificateurs de la même toolbox, ou l'identifiant d'un module `taskPlanner` précis ;
- la liaison ajoute à la bande les tâches quotidiennes ou hebdomadaires non effectuées proches de leur reset ; une référence supprimée revient à `""` ;
- `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.
@ -262,9 +265,8 @@ Stockage compact :
Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une
checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible
affiche un toggle de lien à côté du « + » de son en-tête dans le panneau latéral.
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
affiche sa liaison dans la section Guide du panneau Configuration commun :
bouton 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 ; 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

View file

@ -8,7 +8,7 @@ import { normalizeChecklistData } from "../website/src/features/toolboxes/storag
const route = "/#/games/aniimo/aniimos";
const storageKey = "sokkog:guide-checklists";
const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls");
const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true });
const controlsToggle = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).getByRole("button", { name: "Configuration", exact: true });
const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link");
const activeButtons = (page) => page.locator('.guide-checklist-link[aria-pressed="true"]');
const hiddenSwitch = (page) => page.locator('.guide-checklist-controls[data-linked="true"]').getByRole("button", { name: "Masquer les cochés", exact: true });
@ -97,7 +97,7 @@ async function closeDrawer(page) {
await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click();
}
async function openControls(page, id) {
const toggle = id ? controlsToggle(page, id) : page.locator('.module-guide-button[data-linked="true"]');
const toggle = id ? controlsToggle(page, id) : page.locator('.module-configuration-button[data-guide-linked="true"]');
await expect(toggle).toBeVisible();
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click();
}
@ -117,8 +117,8 @@ async function toggleHidden(page) {
}
async function assertLink(page, id, hidden) {
await openDrawer(page);
await expect(activeButtons(page)).toHaveCount(id ? 1 : 0);
if (id) await openControls(page, id);
await expect(activeButtons(page)).toHaveCount(id ? 1 : 0);
if (id) await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden));
else await expect(hiddenSwitch(page)).toHaveCount(0);
@ -206,11 +206,11 @@ for (const width of [1280, 390]) {
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);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls select")).toHaveCount(0);
await expect(controls(page, "unrelated")).toHaveCount(0);
await expect(controlsToggle(page, "unrelated")).toHaveCount(0);
await expect(controls(page, "capture")).toBeHidden();
await expect(controlsToggle(page, "unrelated")).toHaveCount(1);
await expect(controls(page, "capture")).toHaveCount(0);
await openControls(page, "capture");
await expect(controls(page, "capture").getByRole("button", { name: "Masquer les cochés" })).toBeDisabled();
const before = await readModule(page, "forms");
@ -218,15 +218,14 @@ for (const width of [1280, 390]) {
await expect(heading).toHaveAttribute("aria-expanded", "false");
expect(await heading.evaluate((button) => button.previousElementSibling.classList.contains("module-edit-button"))).toBe(true);
await heading.press("Enter");
await expect(heading).toHaveAttribute("aria-pressed", "true");
await expect(controls(page, "forms")).toHaveAttribute("id", await heading.getAttribute("aria-controls"));
await expect(heading).toHaveAttribute("aria-expanded", "true");
await expect(page.locator(`[id="${await heading.getAttribute("aria-controls")}"]`)).toHaveClass(/module-configuration-panel/);
await linkButton(page, "forms").press("Enter");
await expect(linkButton(page, "forms")).toHaveAttribute("aria-pressed", "true");
const panel = controls(page, "forms");
await heading.press("Space");
await expect(heading).toHaveAttribute("aria-expanded", "false");
await expect(heading).toHaveAttribute("aria-pressed", "false");
await expect(heading).toHaveAttribute("data-linked", "true");
await expect(heading).toHaveAttribute("data-guide-linked", "true");
await expect(linkButton(page, "forms")).toBeHidden();
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await heading.press("Enter");
@ -357,7 +356,7 @@ test("renames retain a zero-match selection; changing toolbox clears the associa
await dialog.getByRole("combobox").selectOption("other");
await dialog.getByRole("button", { name: "Valider", exact: true }).click();
await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(1);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
});
@ -398,7 +397,7 @@ test("unavailable local storage retains links in memory across navigation; overv
await page.goto("/#/toolbox/guide");
await expect(page.locator('[data-module-type="checklist"]')).toHaveCount(4);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await expect(page.locator(".module-guide-button")).toHaveCount(0);
await expect(page.locator('.module-configuration-button[data-guide-linked="true"]')).toHaveCount(0);
await page.goto(route);
await assertLink(page, "perfect", true);
await openDrawer(page);

View file

@ -10,14 +10,41 @@ async function seedMemosToolbox(page) {
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["kv", "modules"], "readwrite");
const soonReset = new Date(Date.now() + 60 * 60 * 1000);
const resetTime = `${String(soonReset.getHours()).padStart(2, "0")}:${String(soonReset.getMinutes()).padStart(2, "0")}`;
tx.objectStore("kv").put([
{ id: "memo-toolbox", name: "Toolbox mémos", modules: [{ id: "memos", type: "memos", title: "Mémos" }], moduleColumns: 1 }
{
id: "memo-toolbox",
name: "Toolbox mémos",
modules: [
{ id: "memos", type: "memos", title: "Mémos" },
{ id: "planner-one", type: "taskPlanner", title: "Urgent planner" },
{ id: "planner-two", type: "taskPlanner", title: "Other planner" }
],
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.objectStore("modules").put({
key: "memo-toolbox:planner-one",
data: {
resetTime,
tasks: [
{ id: "category-parent", title: "Raid principal", type: "unique", category: "Raid" },
{ id: "daily-task", title: "Faire le donjon", type: "daily" },
{ id: "done-task", title: "Déjà terminée", type: "daily", checked: true, checkedAt: Date.now() }
],
relations: [{ id: "relation", fromTaskId: "daily-task", toTaskId: "category-parent" }]
}
});
tx.objectStore("modules").put({
key: "memo-toolbox:planner-two",
data: { tasks: [{ id: "unique-task", title: "Ponctuelle", type: "unique" }] }
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
@ -54,3 +81,34 @@ test("shows and persists the memo ticker on linked game and full toolbox pages",
await page.goto("/#/toolboxes");
await expect(globalTicker).toHaveCount(0);
});
test("links a planner, shows its reset warning and clears the link when it is removed", async ({ page }) => {
await seedMemosToolbox(page);
await page.goto("/#/toolbox/memo-toolbox");
const memos = page.locator('[data-module-id="memos"]');
await memos.getByRole("button", { name: "Configuration", exact: true }).click();
const dataSectionBox = await memos.locator(".module-configuration-data").boundingBox();
const settingsSectionBox = await memos.locator(".module-configuration-specific").boundingBox();
expect(settingsSectionBox.y).toBeGreaterThan(dataSectionBox.y + dataSectionBox.height);
expect(settingsSectionBox.width).toBeCloseTo(dataSectionBox.width, 0);
const plannerSelect = memos.getByRole("combobox", { name: "Planificateur lié", exact: true });
await expect(plannerSelect).toHaveValue("");
await plannerSelect.selectOption("planner-one");
const globalTicker = page.locator(".global-memos-ticker");
await expect(globalTicker).toContainText("Tâche : Raid Faire le donjon expire bientôt");
await expect(globalTicker).not.toContainText("Déjà terminée");
await expect(globalTicker.locator(".memos-ticker-item.is-task-warning .ui-icon-warning")).toHaveCount(2);
const planner = page.locator('[data-module-id="planner-one"]');
await planner.getByRole("button", { name: "Retirer Urgent planner", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
await expect(planner).toHaveCount(0);
await expect(plannerSelect).toHaveValue("");
await expect(globalTicker).not.toContainText("Faire le donjon");
await page.reload();
await memos.getByRole("button", { name: "Configuration", exact: true }).click();
await expect(plannerSelect).toHaveValue("");
});

View file

@ -97,7 +97,7 @@ test("recurring resets preserve unique completions and cleanup deletes only comp
await expect(hide).toHaveAttribute("aria-pressed", "true");
await hide.click();
await expect(tool.getByRole("checkbox", { name: "Marquer non effectué Parent", exact: true })).toBeChecked();
await tool.getByRole("button", { name: "Réglages du planificateur", exact: true }).click();
await tool.getByRole("button", { name: "Configuration", exact: true }).click();
const cleanup = tool.getByRole("button", { name: "Nettoyer les tâches ponctuelles effectuées", exact: true });
await cleanup.click();
await expect(cleanup).toBeDisabled();

View file

@ -23,7 +23,7 @@ test("table text import/export preserves formulas and labels, copies read-only t
const table = page.locator('[data-module-type="table"]');
const importButton = table.getByRole("button", { name: "Importer TSV", exact: true });
await expect(importButton).toBeHidden();
await table.getByRole("button", { name: "Afficher l'import/export de Tableau", exact: true }).click();
await table.getByRole("button", { name: "Configuration", exact: true }).click();
await expect(table.getByRole("button", { name: "Exporter TSV", exact: true })).toBeDisabled();
const originalOverflow = await page.locator("body").evaluate((body) => body.style.overflow);
await importButton.click();
@ -49,7 +49,7 @@ test("table text import/export preserves formulas and labels, copies read-only t
await expect(table.getByRole("textbox", { name: "Intitulé de colonne A", exact: true })).toHaveValue("Quantity");
await expect(table.getByRole("textbox", { name: "Intitulé de ligne 1", exact: true })).toHaveValue("Ore");
await expect(table.getByRole("textbox", { name: "Cellule B1", exact: true })).toHaveValue("6");
await table.getByRole("button", { name: "Afficher l'import/export de Tableau", exact: true }).click();
await table.getByRole("button", { name: "Configuration", exact: true }).click();
await table.getByRole("button", { name: "Exporter TSV", exact: true }).click();
const exported = page.getByRole("dialog", { name: "Exporter un tableau TSV", exact: true });
await expect(exported.getByRole("textbox")).toHaveAttribute("readonly", "");
@ -60,14 +60,15 @@ test("table text import/export preserves formulas and labels, copies read-only t
await exported.getByRole("textbox").press("Escape");
await expect(exported).toBeHidden();
await expect.poll(() => page.locator("body").evaluate((body) => body.style.overflow)).toBe(originalOverflow);
await table.getByRole("button", { name: "Masquer l'import/export de Tableau", exact: true }).click();
await table.getByRole("button", { name: "Configuration", exact: true }).click();
await expect(importButton).toBeHidden();
await expect(table.getByRole("button", { name: "Configuration", exact: true })).toHaveAttribute("aria-expanded", "false");
});
test("invalid calculator imports preserve existing results and a corrected import replaces them with a persistent hierarchy", async ({ page }) => {
await setup(page);
const calc = page.locator('[data-module-type="calculator"]');
await calc.getByRole("button", { name: "Afficher l'import/export de Calculateur", exact: true }).click();
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
const openImport = calc.getByRole("button", { name: "Importer du texte", exact: true });
await openImport.click();
const dialog = page.getByRole("dialog", { name: "Importer des résultats", exact: true });
@ -78,7 +79,7 @@ test("invalid calculator imports preserve existing results and a corrected impor
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
await page.reload();
await expect(calc.locator(".calculator-entry em")).toHaveText(["Original"]);
await calc.getByRole("button", { name: "Afficher l'import/export de Calculateur", exact: true }).click();
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
await openImport.click();
await expect(dialog.getByRole("textbox")).toHaveValue("");
await expect(dialog.getByRole("alert")).toHaveCount(0);
@ -97,7 +98,7 @@ test("invalid calculator imports preserve existing results and a corrected impor
await page.reload();
await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]);
await expect(calc.getByRole("button", { name: "Liste scrollable", exact: true })).toHaveAttribute("aria-pressed", "true");
await calc.getByRole("button", { name: "Afficher l'import/export de Calculateur", exact: true }).click();
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
await calc.getByRole("button", { name: "Exporter en format texte", exact: true }).click();
const exported = page.getByRole("dialog", { name: "Exporter des résultats", exact: true });
await expect(exported.getByRole("textbox")).toHaveValue(text);

View file

@ -52,7 +52,18 @@ test("creates at most five spaces and moves tools with the accessible selector",
await expect(page.getByRole("tab", { name: "Divers" })).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Défaut" }).click();
await page.getByRole("combobox", { name: "Déplacer vers… — Alpha" }).selectOption({ label: "Divers" });
const alpha = page.locator("[data-module-id='a']");
await alpha.getByRole("button", { name: "Configuration", exact: true }).click();
const organization = alpha.locator(".module-configuration-organization");
const data = alpha.locator(".module-configuration-data");
await expect(organization).toBeVisible();
await expect(data).toBeVisible();
const organizationBox = await organization.boundingBox();
const dataBox = await data.boundingBox();
expect(dataBox.y).toBeCloseTo(organizationBox.y, 0);
expect(dataBox.x).toBeGreaterThan(organizationBox.x + organizationBox.width);
expect(dataBox.width).toBeCloseTo(organizationBox.width, 0);
await alpha.getByRole("combobox", { name: "Déplacer vers un autre espace", exact: true }).selectOption({ label: "Divers" });
await expect(page.locator("[data-module-id='a']")).toHaveCount(0);
await page.getByRole("tab", { name: "Divers" }).click();
await expect(page.locator("[data-module-id='a']")).toBeVisible();
@ -66,6 +77,52 @@ test("creates at most five spaces and moves tools with the accessible selector",
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
});
test("keeps the compact header order and isolates configuration from add and collapsed content", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 900 });
await seedToolbox(page, {
id: "module-configuration",
name: "Module configuration",
modules: [{ id: "a", type: "counters", title: "Alpha" }],
moduleOrder: { one: ["a"], two: [["a"], []] }
});
const tool = page.locator("[data-module-id='a']");
await expect(tool.locator(".module-header-actions > button")).toHaveCount(5);
expect(await tool.locator(".module-header-actions > button").evaluateAll((buttons) => buttons.map((button) => button.className.split(" ")[0]))).toEqual([
"module-scroll-button",
"module-edit-button",
"module-configuration-button",
"module-delete-button",
"module-collapse-button"
]);
const add = tool.getByRole("button", { name: "Afficher l'ajout de Alpha", exact: true });
const configuration = tool.getByRole("button", { name: "Configuration", exact: true });
await add.click();
await expect(tool.locator(".module-add-panel")).toBeVisible();
await configuration.click();
await expect(add).toHaveAttribute("aria-pressed", "false");
await expect(tool.locator(".module-add-panel")).toHaveCount(0);
const panel = tool.getByRole("region", { name: "Configuration de Alpha", exact: true });
await expect(panel).toBeVisible();
await expect(panel.getByRole("button", { name: "Importer du texte", exact: true })).toBeVisible();
const toolBox = await tool.boundingBox();
const panelBox = await panel.boundingBox();
expect(panelBox.width).toBeGreaterThan(toolBox.width - 24);
expect(panelBox.width).toBeLessThan(toolBox.width);
await tool.getByRole("button", { name: "Masquer Alpha", exact: true }).click();
await expect(panel).toHaveCount(0);
await expect(tool.locator(".module-content")).toHaveCount(0);
await configuration.click();
await expect(panel).toBeVisible();
await expect(tool.locator(".module-content")).toBeHidden();
await expect(panel.getByRole("button", { name: "Importer du texte", exact: true })).toBeVisible();
await configuration.press("Escape");
await expect(panel).toHaveCount(0);
await expect(configuration).toHaveAttribute("aria-expanded", "false");
});
test("shows the tab insertion indicator on the correct side while reordering", async ({ page }) => {
await seedToolbox(page, {
id: "reorder-spaces",

View file

@ -248,6 +248,14 @@ export function validateSiteContent(site) {
"toolboxes.spaces.moveSuccess",
"toolboxes.spaces.emptyText",
"toolboxes.spaces.limitReached",
"toolboxes.moduleConfiguration.buttonLabel",
"toolboxes.moduleConfiguration.panelLabel",
"toolboxes.moduleConfiguration.organizationTitle",
"toolboxes.moduleConfiguration.spaceLabel",
"toolboxes.moduleConfiguration.guideTitle",
"toolboxes.moduleConfiguration.dataTitle",
"toolboxes.moduleConfiguration.importButtonLabel",
"toolboxes.moduleConfiguration.exportButtonLabel",
"toolboxes.drawer.emptyText",
"toolboxes.drawer.createLabel",
"toolboxes.drawer.linkExistingTitle",
@ -345,6 +353,12 @@ export function validateSiteContent(site) {
"toolboxes.modules.memos.tickerLabel",
"toolboxes.modules.memos.hideTickerTitle",
"toolboxes.modules.memos.showTickerTitle",
"toolboxes.modules.memos.settingsTitle",
"toolboxes.modules.memos.plannerLinkLabel",
"toolboxes.modules.memos.plannerNoneLabel",
"toolboxes.modules.memos.plannerAllLabel",
"toolboxes.modules.memos.plannerDefaultLabel",
"toolboxes.modules.memos.taskAlertTemplate",
"toolboxes.modules.memos.importPlaceholder",
"toolboxes.modules.memos.importOpenButton",
"toolboxes.modules.memos.exportOpenButton",
@ -830,7 +844,6 @@ export function validateSiteContent(site) {
"toolboxes.modules.calendar.requiredError",
"toolboxes.modules.calendar.invalidRangeError",
"toolboxes.modules.taskPlanner.settingsTitle",
"toolboxes.modules.taskPlanner.settingsButtonLabel",
"toolboxes.modules.taskPlanner.hideCompletedTitle",
"toolboxes.modules.taskPlanner.globalWeeklyResetDayLabel",
"toolboxes.modules.taskPlanner.resetTimeLabel",

View file

@ -4,6 +4,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js";
import { applyDueResets, getTaskParentMap, getTaskTreeIds, isTaskResetSoon, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
import { getMemosDataAfterPlannerRemoval, getPlannerTaskTickerItems, resolveMemosPlannerLink } from "../website/src/features/toolboxes/modules/memosPlannerUtils.js";
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js";
@ -97,7 +98,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
const toolboxModules = await readFile("website/src/features/toolboxes/modules/ToolboxModules.jsx", "utf8");
const moduleShell = await readFile("website/src/features/toolboxes/modules/ModuleShell.jsx", "utf8");
const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}\n${moduleShell}`;
const moduleConfiguration = await readFile("website/src/features/toolboxes/modules/ModuleConfiguration.jsx", "utf8");
const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}\n${moduleShell}\n${moduleConfiguration}`;
const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
const notepadToolbar = await readFile("website/src/features/toolboxes/modules/NotepadToolbar.jsx", "utf8");
@ -139,7 +141,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8");
const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8");
const taskPlannerBundle = `${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
const taskPlannerBundle = `${taskPlannerModule}\n${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
@ -240,12 +242,18 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleShell, /module-scroll-button/);
assert.match(moduleShell, /module-collapse-button/);
assert.match(moduleShell, /aria-expanded=\{!collapsed\}/);
assert.match(moduleShell, /!collapsed &&/);
assert.match(moduleShell, /!collapsed \|\| configurationOpen/);
assert.match(moduleShell, /module-configuration-button/);
assert.match(moduleShell, /aria-controls=\{configurationId\}/);
assert.match(moduleShell, /module-configuration-panel/);
assert.match(moduleShell, /hidden=\{collapsed\}/);
assert.match(moduleConfiguration, /createPortal/);
assert.doesNotMatch(moduleShell, /module-exchange-button/);
assert.match(addToolControls, /Icon name="dropdown"/);
assert.match(addToolControls, /CompactDropdown/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(notepadModule, /export function NotepadModule/);
assert.match(notepadModule, /TextExchangeActions/);
assert.match(notepadModule, /ModuleTextExchangeConfiguration/);
assert.match(notepadModule, /type="notepad"/);
assert.match(notepadToolbar, /export function NotepadToolbar/);
assert.match(notepadToolbar, /FormattingColorDropdown/);
@ -300,7 +308,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerBundle, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
assert.match(timerModule, /ModuleTextExchangeConfiguration/);
assert.match(calendarModule, /export function CalendarModule/);
assert.match(calendarModule, /normalizeCalendarData/);
assert.match(calendarBundle, /calendar-mode-choice/);
@ -322,7 +330,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calendarBundle, /data-size=\{eventSurfaceSize\}/);
assert.match(calendarBundle, /is-compact/);
assert.match(calendarController, /handleKeyDown/);
assert.match(calendarContent, /TextExchangeActions/);
assert.match(calendarContent, /ModuleTextExchangeConfiguration/);
assert.match(calendarContent, /type="calendar"/);
assert.match(calendarBundle, /repeatLabel/);
assert.match(calendarBundle, /CalendarAlertModeSwitch/);
@ -342,7 +350,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeModule, /export function GoalTreeModule/);
assert.match(goalTreeBundle, /normalizeGoalTreeData/);
assert.match(goalTreeBundle, /applyGoalTreeDueResets/);
assert.match(goalTreeModule, /TextExchangeActions/);
assert.match(goalTreeModule, /ModuleTextExchangeConfiguration/);
assert.match(goalTreeBundle, /DiagramConnectors/);
assert.match(goalTreeEditor, /export function GoalTreeEditor/);
assert.match(goalTreeEditor, /Tabs/);
@ -388,14 +396,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(equipmentPlannerEditors, /export \{ CharacteristicsEditor \}/);
assert.match(equipmentPlannerEditors, /export \{ MaterialsEditor \}/);
assert.match(equipmentPlannerEditors, /export \{ SocketItemsEditor \}/);
assert.match(equipmentPlannerModule, /TextExchangeActions/);
assert.match(equipmentPlannerModule, /ModuleTextExchangeConfiguration/);
assert.match(equipmentPlannerItem, /export function EquipmentItem/);
assert.match(equipmentPlannerPickers, /export function IconChoiceDropdown/);
assert.match(equipmentPlannerSummary, /export function EquipmentPlannerSummary/);
assert.match(equipmentPlannerOptions, /export const EQUIPMENT_ICONS/);
assert.match(equipmentPlannerOptions, /export function formatValue/);
assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextExchangeActions/);
assert.match(checklistModule, /ModuleTextExchangeConfiguration/);
assert.match(checklistModule, /editing &&/);
assert.match(checklistParts, /ChecklistItem/);
assert.match(checklistParts, /export function ChecklistSection/);
@ -420,7 +428,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imagesModule, /editing &&/);
assert.match(imagesModule, /clipboardData/);
assert.match(imagesModule, /createImageAnnotationModule/);
assert.match(imagesModule, /TextExchangeActions/);
assert.match(imagesModule, /ModuleTextExchangeConfiguration/);
assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /kind: "collection"/);
assert.match(imagesModule, /galleryButton/);
@ -432,15 +440,15 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageAnnotationModule, /context\.setImage/);
assert.match(imageAnnotationModule, /createMarkerId/);
assert.match(imageAnnotationModule, /<Icon name="zoom"/);
assert.match(imageAnnotationModule, /TextExchangeActions/);
assert.match(linksModule, /TextExchangeActions/);
assert.match(imageAnnotationModule, /ModuleTextExchangeConfiguration/);
assert.match(linksModule, /ModuleTextExchangeConfiguration/);
assert.match(linksModule, /normalizeUrl/);
assert.match(linksModule, /useGroupedReorder/);
assert.match(linksModule, /link-drag-handle/);
assert.match(countersModule, /useGroupedReorder/);
assert.match(countersModule, /orientation: "horizontal"/);
assert.match(countersModule, /counter-drag-handle/);
assert.match(countersModule, /TextExchangeActions/);
assert.match(countersModule, /ModuleTextExchangeConfiguration/);
assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
assert.match(timerModule, /parentId=\{moduleId\}/);
@ -510,6 +518,41 @@ test("task planner helpers keep hierarchy, reorder and reset behavior stable", (
assert.equal(isTaskResetSoon({ id: "h", title: "H", type: "unique", checked: false }, resetData, new Date(2026, 0, 6, 7, 0).getTime()), false);
});
test("memos planner links select reset alerts and clear removed targets", () => {
const modules = [
{ id: "planner-a", type: "taskPlanner", title: "Raids" },
{ id: "planner-b", type: "taskPlanner", title: "Farm" },
{ id: "notes", type: "memos" }
];
const plannerData = {
"planner-a": {
resetTime: "06:00",
weeklyResetDay: 1,
tasks: [
{ id: "parent", title: "Parent", type: "unique", category: "Raid" },
{ id: "soon", title: "Donjon", type: "daily" },
{ id: "done", title: "Déjà faite", type: "daily", checked: true, checkedAt: Date.UTC(2026, 0, 2, 0) }
],
relations: [{ id: "relation", fromTaskId: "soon", toTaskId: "parent" }]
},
"planner-b": { resetTime: "18:00", tasks: [{ id: "later", title: "Récolte", type: "daily" }] }
};
const alerts = getPlannerTaskTickerItems({
plannerLink: "all",
modules,
getPlannerData: (plannerId) => plannerData[plannerId],
nowMs: Date.UTC(2026, 0, 2, 1),
alertTemplate: "Tâche : {category}{title} expire bientôt"
});
assert.deepEqual(alerts.map((item) => item.text), ["Tâche : Raid Donjon expire bientôt"]);
assert.equal(alerts[0].kind, "task-warning");
assert.equal(resolveMemosPlannerLink("missing", modules), "");
assert.equal(getMemosDataAfterPlannerRemoval({ plannerLink: "planner-a" }, "planner-a", modules.slice(1)).plannerLink, "");
assert.equal(getMemosDataAfterPlannerRemoval({ plannerLink: "all" }, "planner-a", modules.slice(1)).plannerLink, "all");
assert.equal(getMemosDataAfterPlannerRemoval({ plannerLink: "all" }, "planner-b", [{ id: "notes", type: "memos" }]).plannerLink, "");
});
test("equipment planner options expose stable formatting and socket helpers", () => {
assert.deepEqual(SOCKET_SHAPES, ["ball", "jewel"]);
assert.equal(formatValue(1.23456), "1.235");

View file

@ -14,10 +14,34 @@ import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } fro
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, remapMemosPlannerLink, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { createToolboxSpace, deleteToolboxSpace, moveToolboxModule, reorderToolboxSpaces } from "../website/src/features/toolboxes/toolboxSpaceUtils.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
test("memos persist and export a remapped task planner link", () => {
const normalized = normalizeMemosData({ plannerLink: "planner", items: [] });
assert.equal(normalized.plannerLink, "planner");
assert.equal(compactModuleDataForStorage("memos", normalized).plannerLink, "planner");
assert.equal(remapMemosPlannerLink({ plannerLink: "planner" }, (id) => `new-${id}`).plannerLink, "new-planner");
assert.equal(remapMemosPlannerLink({ plannerLink: "all" }, () => "unused").plannerLink, "all");
const toolbox = normalizeToolbox({
id: "toolbox",
name: "Linked planners",
modules: [
{ id: "memos", type: "memos" },
{ id: "planner", type: "taskPlanner" }
]
});
const payload = createToolboxExportPayload(toolbox, {
[moduleStorageKey(toolbox.id, "memos")]: { plannerLink: "planner" },
[moduleStorageKey(toolbox.id, "planner")]: { tasks: [] }
});
const exportedMemos = payload.toolbox.modules.find((module) => module.type === "memos");
const exportedPlanner = payload.toolbox.modules.find((module) => module.type === "taskPlanner");
assert.equal(payload.modules[exportedMemos.id].plannerLink, exportedPlanner.id);
});
function createTextImportContext() {
let index = 0;
return {

View file

@ -96,6 +96,15 @@
"kind": "iconButton",
"icon": "eye-open"
}
},
{
"group": "Bande",
"icon": "settings",
"title": "Lier un ou tous les planificateurs de tâches",
"preview": {
"kind": "iconButton",
"icon": "settings"
}
}
],
"features": [
@ -112,6 +121,7 @@
"text": "Tous les mémos apparaissent dans une bande compacte quand la bande est active.",
"details": [
"La bande peut être masquée sans retirer les mémos.",
"Les paramètres permettent d'ajouter les tâches non effectuées qui approchent de leur reset depuis un planificateur précis ou tous ceux de la toolbox.",
"Les couleurs restent des accents localisés pour conserver une lecture calme."
]
}
@ -1832,6 +1842,16 @@
"emptyText": "Cet espace est vide. Ajoutez-y un outil pour commencer.",
"limitReached": "Limite de 5 espaces atteinte"
},
"moduleConfiguration": {
"buttonLabel": "Configuration",
"panelLabel": "Configuration de",
"organizationTitle": "Espace",
"spaceLabel": "Déplacer vers un autre espace",
"guideTitle": "Guide",
"dataTitle": "Données",
"importButtonLabel": "Importer",
"exportButtonLabel": "Exporter"
},
"eyebrow": "Données locales",
"title": "Toolboxes",
"newButton": "Nouvelle toolbox",
@ -2004,6 +2024,12 @@
"tickerLabel": "Mémos",
"hideTickerTitle": "Masquer la bande",
"showTickerTitle": "Afficher la bande",
"settingsTitle": "Paramètres des mémos",
"plannerLinkLabel": "Planificateur lié",
"plannerNoneLabel": "Aucun",
"plannerAllLabel": "Tous les planificateurs",
"plannerDefaultLabel": "Planificateur de tâches",
"taskAlertTemplate": "Tâche : {category}{title} expire bientôt",
"importPlaceholder": "- Pour la reproduction, combine Pierre Stase + Nœud Destin pour transmettre nature et IV. | color=#f6c453\n- Les IV obtenus par Hyper Training ne sont pas transmis aux oeufs. | color=#8b5cf6\n- Pour une équipe Distorsion, vise 0 IV en Vitesse sur les Pokémon lents. | color=#22d3ee\n- Utilise les objets Pouvoir pour cibler précisément les EV à entraîner. | color=#d946ef",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
@ -2639,7 +2665,6 @@
"exportSuccess": "Export de tâches copié.",
"importError": "Format de tâches invalide.",
"settingsTitle": "Réglages du planificateur",
"settingsButtonLabel": "Paramètres des réinitialisations",
"hideCompletedTitle": "Masquer les tâches effectuées",
"globalWeeklyResetDayLabel": "Reset hebdo",
"resetTimeLabel": "Heure de reset",

View file

@ -1,30 +1,14 @@
// Rôle : expose le suivi du guide dans chaque checklist compatible et marque les cartes terminées.
import { Icon } from "../../components/Icon.jsx";
export function GuideChecklistToggle({ checklist, panel, label }) {
if (!panel.list) return null;
return <button
className={`module-guide-button nds-icon-button ${panel.open ? "active" : ""}`}
data-size="md"
data-linked={checklist.selected?.id === panel.list.id || undefined}
type="button"
aria-label={`${checklist.content.label} — ${label}`}
title={checklist.content.label}
aria-expanded={panel.open}
aria-pressed={panel.open}
aria-controls={panel.id}
onClick={panel.toggle}
><Icon name="link" /></button>;
}
export function GuideChecklistControls({ checklist, list, id, open }) {
if (!list) return null;
const { content, selected, hideCompleted } = checklist;
const active = selected?.id === list.id;
return <div id={id} hidden={!open} className="guide-checklist-controls" data-linked={active || undefined}>
<div className="guide-checklist-actions">
<button type="button" className="guide-checklist-link nds-button" data-size="sm" data-variant={active ? "primary" : "secondary"} aria-pressed={active} onClick={() => checklist.associate(active ? "" : list.id)}>{active ? content.unlink : content.associate}</button>
<button type="button" className="nds-switch" disabled={!active} aria-pressed={active && hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
<button type="button" className="guide-checklist-link nds-button" data-size="xs" data-variant={active ? "primary" : "secondary"} aria-pressed={active} onClick={() => checklist.associate(active ? "" : list.id)}>{active ? content.unlink : content.associate}</button>
<button type="button" className="nds-switch" data-size="xs" disabled={!active} aria-pressed={active && hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
<p>{list.count ? `${list.count} ${content.matches}` : content.noMatches}</p>
</div>;

View file

@ -1,14 +1,13 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useId, useMemo, useState } from "react";
import { createContext, useContext, useId, useMemo } from "react";
import { getGuideChecklistEntries, matchGuideChecklist, toggleGuideChecklistRows } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
export function useGuideChecklistPanel(checklist, module) {
const id = useId();
const [open, setOpen] = useState(false);
const list = module.type === "checklist" ? checklist?.lists.find((item) => item.id === module.id) : undefined;
return { id, open, list, toggle: () => setOpen((value) => !value) };
return { id, list };
}
export function useGuideChecklist(requestedGuideId) {

View file

@ -1,7 +1,8 @@
// Rôle : affiche en bas de page les mémos actifs de la toolbox courante ou liée au jeu.
import { useMemo } from "react";
// Rôle : affiche en bas de page les mémos et alertes de tâches de la toolbox courante ou liée au jeu.
import { useEffect, useMemo, useState } from "react";
import { moduleStorageKey, normalizeMemosData } from "./storage/toolboxStorage.js";
import { MemosTicker } from "./modules/MemosTicker.jsx";
import { getPlannerTaskTickerItems, resolveMemosPlannerLink } from "./modules/memosPlannerUtils.js";
function getRouteToolboxId(route, links) {
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
@ -11,6 +12,13 @@ function getRouteToolboxId(route, links) {
}
export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textContent }) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(intervalId);
}, []);
const items = useMemo(() => {
const toolboxId = getRouteToolboxId(route, links);
const toolbox = toolboxes.find((item) => item.id === toolboxId);
@ -20,9 +28,20 @@ export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textCon
.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}` }));
const plannerLink = resolveMemosPlannerLink(data.plannerLink, toolbox.modules);
const taskItems = getPlannerTaskTickerItems({
plannerLink,
modules: toolbox.modules,
getPlannerData: (plannerId) => moduleData[moduleStorageKey(toolbox.id, plannerId)],
nowMs,
alertTemplate: textContent?.taskAlertTemplate
});
}, [route, toolboxes, links, moduleData]);
return [
...data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` })),
...taskItems.map((item) => ({ ...item, id: `${module.id}:${item.id}` }))
];
});
}, [route, toolboxes, links, moduleData, nowMs, textContent?.taskAlertTemplate]);
if (!items.length) return null;
return (

View file

@ -11,6 +11,7 @@ import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
import { getMemosDataAfterPlannerRemoval } from "./modules/memosPlannerUtils.js";
import { ToolboxSpaces } from "./ToolboxSpaces.jsx";
import {
addModuleToToolboxSpace,
@ -195,7 +196,9 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
isModuleDataReady: actions.isModuleDataReady,
storageStatus: siteContent.toolboxes.storageStatus,
storageReady,
toolboxModules: toolbox.modules,
moduleText,
moduleConfigurationText: siteContent.toolboxes.moduleConfiguration,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
@ -403,6 +406,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
context={moduleContext}
reorder={moduleReorder}
spacesText={spacesText}
configurationText={siteContent.toolboxes.moduleConfiguration}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
@ -419,6 +423,15 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
danger: true,
onResolve: (confirmed) => {
if (!confirmed) return;
const removedModule = toolbox.modules.find((module) => module.id === moduleId);
const remainingModules = toolbox.modules.filter((module) => module.id !== moduleId);
if (removedModule?.type === "taskPlanner") {
toolbox.modules.filter((module) => module.type === "memos").forEach((memosModule) => {
const memosData = normalizeMemosData(getModuleData(toolbox.id, memosModule.id, { items: [] }));
const nextMemosData = getMemosDataAfterPlannerRemoval(memosData, moduleId, remainingModules);
if (nextMemosData !== memosData) updateModuleData(toolbox.id, memosModule.id, nextMemosData, "memos");
});
}
actions.removeModuleData(toolbox.id, moduleId);
updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId));
}

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
BASE_TOKEN,
calculateRemainingValue,
@ -36,7 +36,7 @@ function focusExpressionInputAtEnd(element) {
selection?.addRange(range);
}
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function CalculatorModule({ toolboxId, moduleId, context, configurationTarget }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
@ -223,11 +223,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
return (
<div className="calculator-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<div className="calculator-field">

View file

@ -2,7 +2,7 @@
import { Icon } from "../../../components/Icon.jsx";
import { CalendarEventForm } from "./CalendarEventForm.jsx";
import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
addMonths,
formatLocalDateKey,
@ -31,7 +31,7 @@ export function CalendarContent({
nowDayIndex,
nowMinutes,
hideEmptyHours,
textExchangeOpen,
configurationTarget,
onKeyDown,
onSubmit,
onDraftChange,
@ -74,11 +74,7 @@ export function CalendarContent({
onDisplayedMonthChange={onDisplayedMonthChange}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="calendar" data={data} context={context} textContent={textContent} onImport={onImport} />
{data.viewMode === "week" && (
<CalendarWeekOptions textContent={textContent} hideEmptyHours={hideEmptyHours} onToggle={onHideEmptyHoursToggle} />

View file

@ -3,7 +3,7 @@ import { CalendarContent } from "./CalendarContent.jsx";
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
import { useCalendarController } from "./useCalendarController.js";
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, configurationTarget }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
@ -18,5 +18,5 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
);
}
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
return <CalendarContent {...controller.contentProps} configurationTarget={configurationTarget} />;
}

View file

@ -5,7 +5,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { ChecklistSection } from "./ChecklistParts.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
appendChecklistItems,
getChecklistReorderItems,
@ -17,7 +17,7 @@ import {
updateChecklistItem
} from "./checklistUtils.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ChecklistModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
@ -123,11 +123,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
<div className="checklist-toolbar">
<button
className={`checklist-hide-completed-toggle nds-switch ${data.hideCompletedItems ? "active" : ""}`}

View file

@ -1,7 +1,7 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
import { toggleListValue } from "../../../utils/collectionUtils.js";
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { CombosEditor } from "./CombosEditor.jsx";
import { CombosList } from "./CombosList.jsx";
import {
@ -10,7 +10,7 @@ import {
import { useCombosEditorState } from "./useCombosEditorState.js";
import { useCombosReorder } from "./useCombosReorder.js";
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function CombosModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
const categoryOptions = [
@ -86,11 +86,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
onComboEditCancel={editorState.cancelComboEdit}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
{!data.combos.length && (
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>

View file

@ -2,10 +2,10 @@
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function CountersModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const counterDraft = useDraftForm({ label: "" });
@ -53,11 +53,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing, textExch
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
<div className="counters-grid">
{data.counters.map((counter) => {

View file

@ -1,13 +1,13 @@
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
import { useMemo, useState } from "react";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]);
const textContent = context.moduleText?.equipmentPlanner || {};
@ -114,11 +114,7 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing,
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
<EquipmentPlannerSummary summary={summary} textContent={textContent} />

View file

@ -1,10 +1,10 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
import { GoalTreeEditor } from "./GoalTreeEditor.jsx";
import { GoalTreeView } from "./GoalTreeView.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { useGoalTreeController } from "./useGoalTreeController.js";
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, configurationTarget }) {
const controller = useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor });
return (
@ -30,11 +30,7 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
onToggleLinkedParent={controller.toggleLinkedParent}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="goalTree" data={controller.evaluatedData} context={context} textContent={controller.textContent} onImport={controller.importData} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="goalTree" data={controller.evaluatedData} context={context} textContent={controller.textContent} onImport={controller.importData} />
<GoalTreeView
evaluatedNodes={controller.evaluatedNodes}
@ -46,7 +42,6 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
verticalView={controller.verticalView}
viewScale={controller.viewScale}
storageReady={controller.storageReady}
textExchangeOpen={textExchangeOpen}
onSelect={controller.setSelectedNodeId}
onAdd={controller.addNode}
onAddContentItem={controller.addNodeContentItem}

View file

@ -15,7 +15,6 @@ export function GoalTreeView({
verticalView,
viewScale,
storageReady,
textExchangeOpen,
onSelect,
onAdd,
onAddContentItem,
@ -57,7 +56,7 @@ export function GoalTreeView({
timeoutIds.forEach(clearTimeout);
window.removeEventListener("load", refreshConnectors);
};
}, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing, textExchangeOpen]);
}, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing]);
useLayoutEffect(() => {
const canvas = treeViewRef.current;
@ -82,7 +81,7 @@ export function GoalTreeView({
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
};
}, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
}, [connectorTopologyKey, editing, viewScale]);
return (
<section className={`goal-tree-view tool-split-results ${verticalView ? "is-vertical" : "is-horizontal"} ${editing ? "is-editing" : ""}`} aria-label={textContent.treeTitle || "Arbre d'objectifs"}>

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
function readTemporaryDrawings(sessionKey) {
@ -19,7 +19,7 @@ function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
}
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
const textContent = context.moduleText?.imageAnnotation || {};
const notify = context.notify;
@ -123,14 +123,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, t
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });
}} />
</div>
)}
{data.image ? (
<div className="annotation-workspace">

View file

@ -3,9 +3,9 @@ import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function ImagesModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
const textContent = context.moduleText?.images || {};
const [dragOver, setDragOver] = useState(false);
@ -99,11 +99,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
{data.images.length > 0 && (
<div className="images-toolbar">
<button className="nds-button" data-size="sm" type="button" onClick={() => openCollection("gallery")}>

View file

@ -5,7 +5,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
function getLinkCategory(link) {
@ -32,7 +32,7 @@ function getLinkEntries(data) {
return [...ungrouped, ...categories];
}
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function LinksModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "", category: "" });
@ -169,11 +169,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
<div className="links-category-list">
{getLinkEntries(data).map((entry) => {

View file

@ -1,18 +1,35 @@
// Rôle : fournit l'outil de mémos courts avec post-it, bande déroulante et tri manuel.
// Rôle : fournit les mémos courts, leur tri et les alertes de planificateur dans la bande déroulante.
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ALL_TASK_PLANNERS_LINK,
getPlannerTaskTickerItems,
getTaskPlannerModules,
resolveMemosPlannerLink
} from "./memosPlannerUtils.js";
import { MemoTextEditor } from "./MemoTextEditor.jsx";
import { MemosTicker } from "./MemosTicker.jsx";
import { ModuleConfigurationPortal } from "./ModuleConfiguration.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TEXT_COLORS } from "./notepadUtils.js";
export function MemosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function MemosModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const storedData = context.getModuleData(toolboxId, moduleId, { items: [] });
const data = useMemo(() => context.normalizeMemosData(storedData), [context, storedData]);
const textContent = context.moduleText?.memos || {};
const tickerItems = data.tickerEnabled ? data.items : [];
const [nowMs, setNowMs] = useState(() => Date.now());
const plannerModules = useMemo(() => getTaskPlannerModules(context.toolboxModules), [context.toolboxModules]);
const plannerLink = resolveMemosPlannerLink(data.plannerLink, plannerModules);
const plannerTickerItems = useMemo(() => getPlannerTaskTickerItems({
plannerLink,
modules: plannerModules,
getPlannerData: (plannerId) => context.getModuleData(toolboxId, plannerId, { tasks: [] }),
nowMs,
alertTemplate: textContent.taskAlertTemplate
}), [context, nowMs, plannerLink, plannerModules, textContent.taskAlertTemplate, toolboxId]);
const tickerItems = data.tickerEnabled ? [...data.items, ...plannerTickerItems] : [];
const {
itemReorder,
getItemProps,
@ -37,6 +54,15 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
save({ ...data, items });
}
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
if (data.plannerLink && !plannerLink) context.setModuleData(toolboxId, moduleId, { ...data, plannerLink: "" }, "memos");
}, [context, data, moduleId, plannerLink, toolboxId]);
function addMemo() {
const now = new Date().toISOString();
save({
@ -71,11 +97,33 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
return (
<div className="memos-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="memos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
<ModuleConfigurationPortal target={configurationTarget}>
<section className="module-configuration-section module-configuration-specific">
<h3>{textContent.settingsTitle || "Paramètres des mémos"}</h3>
<div className="memos-settings">
<label>
<span>{textContent.plannerLinkLabel || "Planificateur lié"}</span>
<select
className="nds-select"
data-size="xs"
value={plannerLink}
onChange={(event) => save({ ...data, plannerLink: event.target.value })}
aria-label={textContent.plannerLinkLabel || "Planificateur lié"}
>
<option value="">{textContent.plannerNoneLabel || "Aucun"}</option>
{plannerModules.length > 0 && <option value={ALL_TASK_PLANNERS_LINK}>{textContent.plannerAllLabel || "Tous les planificateurs"}</option>}
{plannerModules.map((planner) => (
<option key={planner.id} value={planner.id}>{planner.title || textContent.plannerDefaultLabel || "Planificateur de tâches"}</option>
))}
</select>
</label>
</div>
)}
</section>
<section className="module-configuration-section module-configuration-data">
<h3>{context.moduleConfigurationText?.dataTitle || "Données"}</h3>
<TextExchangeActions type="memos" data={data} context={context} textContent={textContent} onImport={(nextData) => save({ ...data, items: nextData.items })} />
</section>
</ModuleConfigurationPortal>
{editing && (
<div className="module-add-panel">

View file

@ -1,11 +1,14 @@
// Rôle : affiche la bande déroulante partagée par l'outil Mémos et les pages associées.
// Rôle : affiche la bande déroulante partagée pour les mémos et alertes de tâches.
import { Icon } from "../../../components/Icon.jsx";
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 }}>
<span className={`memos-ticker-item ${memo.kind === "task-warning" ? "is-task-warning" : ""}`} key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.kind === "task-warning" && <Icon name="warning" />}
{memo.text}
</span>
))}

View file

@ -0,0 +1,18 @@
// Rôle : déplace les réglages propres aux outils dans le panneau Configuration commun.
import { createPortal } from "react-dom";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ModuleConfigurationPortal({ target, children }) {
return target ? createPortal(children, target) : null;
}
export function ModuleTextExchangeConfiguration({ target, title = "Données", ...props }) {
return (
<ModuleConfigurationPortal target={target}>
<section className="module-configuration-section module-configuration-data">
<h3>{title}</h3>
<TextExchangeActions {...props} />
</section>
</ModuleConfigurationPortal>
);
}

View file

@ -1,23 +1,26 @@
// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
import { GuideChecklistControls } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", configurationText = {}, scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationTarget, setConfigurationTarget] = useState(null);
const configurationId = useId();
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const hasConfiguration = spaces.length > 1 || Boolean(guidePanel.list) || hasTextExchange;
const scrollable = definition.scrollable && module.scrollable === true;
const collapsed = module.collapsed === true;
const className = [
@ -35,10 +38,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setConfigurationOpen(false);
setEditing(true);
},
textExchangeOpen
configurationTarget
};
useEffect(() => {
@ -50,10 +53,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
return () => window.cancelAnimationFrame(frame);
}, [onScrolledIntoView, scrollIntoView]);
useEffect(() => {
if (!configurationOpen) return undefined;
const closeOnEscape = (event) => {
if (event.key === "Escape" && !document.querySelector('[aria-modal="true"]')) setConfigurationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [configurationOpen]);
function toggleCollapsed() {
if (!collapsed) {
setEditing(false);
setTextExchangeOpen(false);
setConfigurationOpen(false);
}
onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
@ -74,8 +86,8 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>
<header className="module-header">
<div className="module-header-main">
<button
className="module-drag-handle nds-icon-button"
data-size="md"
@ -92,20 +104,7 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{spaces.length > 1 && (
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={`${moveText} — ${module.title || label}`}
title={moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
)}
<div className="module-header-actions">
{definition.scrollable && (
<button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}
@ -133,7 +132,7 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
data-size="md"
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
if (nextEditing) setConfigurationOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
@ -143,21 +142,23 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
<Icon name="add" />
</button>
)}
<GuideChecklistToggle checklist={context.guideChecklist} panel={guidePanel} label={module.title || label} />
{hasTextExchange && (
{hasConfiguration && (
<button
className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`}
className={`module-configuration-button nds-icon-button ${configurationOpen ? "active" : ""}`}
data-size="md"
onClick={() => setTextExchangeOpen((value) => {
data-guide-linked={guidePanel.list && context.guideChecklist?.selected?.id === guidePanel.list.id || undefined}
type="button"
onClick={() => setConfigurationOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
aria-label={configurationText.buttonLabel || "Configuration"}
aria-expanded={configurationOpen}
aria-controls={configurationId}
title={configurationText.buttonLabel || "Configuration"}
>
<Icon name="import" />
<Icon name="settings" />
</button>
)}
<button className="module-delete-button nds-icon-button" data-size="md" data-variant="danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
@ -176,15 +177,46 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
</button>
</div>
</header>
{!collapsed && (
{configurationOpen && (
<section
id={configurationId}
className="module-configuration-panel"
aria-label={`${configurationText.panelLabel || "Configuration de"} ${module.title || label}`}
>
{spaces.length > 1 && (
<section className="module-configuration-section module-configuration-organization">
<h3>{configurationText.organizationTitle || "Espace"}</h3>
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={configurationText.spaceLabel || moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
</section>
)}
{guidePanel.list && (
<section className="module-configuration-section module-configuration-specific">
<h3>{configurationText.guideTitle || "Guide"}</h3>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} open />
</section>
)}
<div className="module-configuration-target" ref={setConfigurationTarget} />
</section>
)}
{(!collapsed || configurationOpen) && (
<div className="module-component-host" hidden={collapsed}>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} />
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
</ModuleDataGate>
</Suspense>
</div>
</div>
)}
</article>
);

View file

@ -5,7 +5,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { DrawingControls } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { NotepadToolbar } from "./NotepadToolbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { normalizeNotepadData } from "../storage/toolboxStorage.js";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
import {
@ -31,7 +31,7 @@ function readTemporaryStrokes(sessionKey) {
}
}
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function NotepadModule({ toolboxId, moduleId, context, configurationTarget }) {
const storedData = context.getModuleData(toolboxId, moduleId, EMPTY_NOTEPAD_DATA);
const normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]);
const notepadDataRef = useRef(normalizedData);
@ -295,11 +295,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
return (
<div className="notepad-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
<CompactDropdown
className="notepad-pages-dropdown"
menuClassName="notepad-pages-menu"

View file

@ -1,12 +1,12 @@
// Rôle : orchestre les onglets de l'outil Randomizer sans porter les sous-vues.
import { Tabs } from "../../../components/Tabs.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { RANDOMIZER_TABS } from "./randomizerUtils.js";
import { DiceRandomizer } from "./randomizer/RandomizerDice.jsx";
import { TeamsRandomizer } from "./randomizer/RandomizerTeams.jsx";
import { WheelRandomizer } from "./randomizer/RandomizerWheel.jsx";
export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function RandomizerModule({ toolboxId, moduleId, context, configurationTarget }) {
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.randomizer || {};
@ -20,11 +20,7 @@ export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpe
return (
<>
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
<div className="randomizer-tool">
<Tabs
tabs={RANDOMIZER_TABS.map((tab) => ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))}

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { useKeyedState } from "../../../hooks/useKeyedState.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import {
RNG_SIMULATOR_DEFAULT_RATE,
RNG_SIMULATOR_HOURLY_DURATION_PRESETS,
@ -146,7 +146,7 @@ function getToneFromDelta(value) {
return value > 0 ? "positive" : "negative";
}
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) {
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, {});
const data = useMemo(() => context.normalizeRngSimulatorData(rawData), [context, rawData]);
const textContent = context.moduleText?.rngSimulator || {};
@ -405,11 +405,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
return (
<>
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
<div className="rng-simulator-tool">
<section className="rng-simulator-panel rng-simulator-rates-panel">
<header>

View file

@ -1,6 +1,6 @@
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
import { useMemo } from "react";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { columnIndexToName, evaluateTable } from "./tableFormulaEngine.js";
import { TableGrid } from "./TableGrid.jsx";
import { TableToolbar } from "./TableToolbar.jsx";
@ -14,7 +14,7 @@ import {
} from "./tableUtils.js";
import { useTableInteractions } from "./useTableInteractions.js";
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function TableModule({ toolboxId, moduleId, context, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} });
const data = useMemo(() => context.normalizeTableData(rawData), [context, rawData]);
const textContent = context.moduleText?.table || {};
@ -61,11 +61,7 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
return (
<div className="table-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
<TableToolbar rows={data.rows} columns={data.columns} textContent={textContent} onResize={resize} onCopy={tableInteractions.copyTsv} />
<TableGrid
data={data}

View file

@ -3,15 +3,17 @@ import { toggleListValue } from "../../../utils/collectionUtils.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
import { ModuleConfigurationPortal } from "./ModuleConfiguration.jsx";
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
applyDueResets,
getTaskParentMap
} from "./taskPlannerUtils.js";
import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, configurationTarget }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const taskDraft = useDraftForm({ title: "", type: "daily", category: "" });
@ -20,7 +22,6 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
...data.tasks.map((task) => task.category).filter(Boolean).filter((category) => !data.categoryOrder.includes(category))
], [data.categoryOrder, data.tasks]);
const [nowMs, setNowMs] = useState(() => Date.now());
const [settingsOpen, setSettingsOpen] = useState(false);
const [openDescriptions, setOpenDescriptions] = useState(() => new Set());
const [openTaskSettings, setOpenTaskSettings] = useState(() => new Set());
const [prerequisiteNoticeTaskIds, setPrerequisiteNoticeTaskIds] = useState(() => new Set());
@ -170,22 +171,9 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
return (
<div className="task-planner-module">
<TaskPlannerTopbar
data={data}
context={context}
editing={editing}
settingsOpen={settingsOpen}
categoryOptions={categoryOptions}
taskDraft={taskDraft}
textContent={textContent}
textExchangeOpen={textExchangeOpen}
onAddTask={addTask}
onHideCompletedToggle={() => save({ ...data, hideCompleted: !data.hideCompleted })}
onSettingsToggle={() => setSettingsOpen((value) => !value)}
onImport={(nextData) => save(nextData)}
/>
{settingsOpen && (
<ModuleConfigurationPortal target={configurationTarget}>
<section className="module-configuration-section module-configuration-specific">
<h3>{textContent.settingsTitle || "Réglages du planificateur"}</h3>
<TaskPlannerSettings
data={data}
textContent={textContent}
@ -193,7 +181,21 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
/>
)}
</section>
<section className="module-configuration-section module-configuration-data">
<h3>{context.moduleConfigurationText?.dataTitle || "Données"}</h3>
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</section>
</ModuleConfigurationPortal>
<TaskPlannerTopbar
data={data}
editing={editing}
categoryOptions={categoryOptions}
taskDraft={taskDraft}
textContent={textContent}
onAddTask={addTask}
onHideCompletedToggle={() => save({ ...data, hideCompleted: !data.hideCompleted })}
/>
<ul className="task-planner-list">
{data.tasks.length ? (

View file

@ -7,18 +7,18 @@ export function TaskPlannerSettings({ data, textContent, onWeeklyResetDayChange,
<section className="task-planner-settings" aria-label={textContent.settingsTitle || "Réglages du planificateur"}>
<label>
<span>{textContent.globalWeeklyResetDayLabel || "Reset hebdo"}</span>
<select className="nds-select" data-size="sm" value={data.weeklyResetDay} onChange={(event) => onWeeklyResetDayChange(Number(event.target.value))}>
<select className="nds-select" data-size="xs" value={data.weeklyResetDay} onChange={(event) => onWeeklyResetDayChange(Number(event.target.value))}>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input className="nds-time" data-size="sm" type="time" value={data.resetTime} onChange={(event) => onResetTimeChange(event.target.value || "00:00")} />
<input className="nds-time" data-size="xs" type="time" value={data.resetTime} onChange={(event) => onResetTimeChange(event.target.value || "00:00")} />
</label>
<div className="task-planner-settings-actions">
<button
className="task-planner-clear-completed-button nds-button"
data-size="sm"
data-size="xs"
data-variant="danger"
type="button"
onClick={onClearCompletedUniqueTasks}

View file

@ -1,7 +1,6 @@
// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
// Rôle : rend les contrôles fréquents et l'ajout rapide du planificateur de tâches.
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
TASK_TYPES,
getTaskTypeLabel
@ -9,17 +8,12 @@ import {
export function TaskPlannerTopbar({
data,
context,
editing,
settingsOpen,
categoryOptions,
taskDraft,
textContent,
textExchangeOpen,
onAddTask,
onHideCompletedToggle,
onSettingsToggle,
onImport
onHideCompletedToggle
}) {
return (
<div className="task-planner-topbar">
@ -36,18 +30,6 @@ export function TaskPlannerTopbar({
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
<button
className={`task-planner-settings-toggle nds-button ${settingsOpen ? "active" : ""}`}
data-size="sm"
type="button"
onClick={onSettingsToggle}
aria-expanded={settingsOpen}
aria-label={textContent.settingsTitle || "Réglages du planificateur"}
title={textContent.settingsTitle || "Réglages du planificateur"}
>
<Icon name="settings" />
<span>{textContent.settingsButtonLabel || "Paramètres des réinitialisations"}</span>
</button>
</div>
{editing && (
<div className="task-planner-add-stack">
@ -61,11 +43,6 @@ export function TaskPlannerTopbar({
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
</div>
);
}

View file

@ -5,17 +5,20 @@ import { useTextExchange } from "./useTextExchange.js";
export function TextExchangeActions({ type, data, context, onImport, textContent = {}, exportLabel = "" }) {
const exchange = useTextExchange({ type, data, context, onImport, textContent });
const importAccessibleLabel = textContent.importOpenButton || "Importer du texte";
const exportAccessibleLabel = textContent.exportOpenButton || exportLabel || "Exporter en format texte";
const configurationText = context.moduleConfigurationText || {};
return (
<>
<div className="text-import-actions">
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openImport}>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openImport} aria-label={importAccessibleLabel}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
<span>{configurationText.importButtonLabel || "Importer"}</span>
</button>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openExport} disabled={!exchange.canExport}>
<button className="nds-button" data-size="sm" type="button" onClick={exchange.openExport} disabled={!exchange.canExport} aria-label={exportAccessibleLabel}>
<Icon name="export" />
<span>{textContent.exportOpenButton || exportLabel || "Exporter en format texte"}</span>
<span>{configurationText.exportButtonLabel || "Exporter"}</span>
</button>
</div>
{exchange.mode === "import" && (

View file

@ -7,7 +7,10 @@ import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
export function TextImportModal({ title, value, placeholder, submitLabel, readOnly = false, error = "", info = "", onChange, onSubmit, onCopy, onClose }) {
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
}
const unlock = lockBodyScroll();

View file

@ -13,7 +13,7 @@ import {
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleTextExchangeConfiguration } from "./ModuleConfiguration.jsx";
import { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
import { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
import {
@ -30,7 +30,7 @@ import {
isAlertGuarded
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
export function TimerModule({ toolboxId, moduleId, context, configurationTarget }) {
const rawData = context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" });
const data = useMemo(() => context.normalizeTimerData(rawData), [context, rawData]);
const textContent = context.moduleText?.timer || {};
@ -252,11 +252,7 @@ export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen })
return (
<div className="timer-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
<ModuleTextExchangeConfiguration target={configurationTarget} title={context.moduleConfigurationText?.dataTitle} type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
<section className="timer-control-card">
<Tabs
className="timer-tabs"

View file

@ -10,7 +10,7 @@ function getModulesByIds(modules, ids) {
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, configurationText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const twoColumnOrder = useMemo(() => space.moduleOrder?.two || [[], []], [space.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, space.moduleOrder?.one || []), [toolbox.modules, space.moduleOrder?.one]);
const columns = useMemo(() => [
@ -32,6 +32,7 @@ export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
configurationText={configurationText}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
@ -65,6 +66,7 @@ export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
configurationText={configurationText}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}

View file

@ -0,0 +1,62 @@
// Rôle : résout les liaisons de Mémos vers les planificateurs et produit leurs alertes de reset.
import { normalizeTaskPlannerData } from "../storage/modules/taskPlanner.js";
import {
applyDueResets,
getEffectiveTaskCategory,
getTaskParentMap,
isTaskResetSoon
} from "./taskPlannerUtils.js";
export const ALL_TASK_PLANNERS_LINK = "all";
export function getTaskPlannerModules(modules) {
return (Array.isArray(modules) ? modules : []).filter((module) => module.type === "taskPlanner");
}
export function resolveMemosPlannerLink(plannerLink, modules) {
const value = String(plannerLink || "");
const planners = getTaskPlannerModules(modules);
if (value === ALL_TASK_PLANNERS_LINK) return planners.length ? value : "";
return planners.some((planner) => planner.id === value) ? value : "";
}
export function getLinkedTaskPlannerModules(plannerLink, modules) {
const planners = getTaskPlannerModules(modules);
if (plannerLink === ALL_TASK_PLANNERS_LINK) return planners;
return planners.filter((planner) => planner.id === plannerLink);
}
export function getMemosDataAfterPlannerRemoval(data, removedPlannerId, remainingModules) {
const remainingPlanners = getTaskPlannerModules(remainingModules);
const link = String(data?.plannerLink || "");
if (link === removedPlannerId || (link === ALL_TASK_PLANNERS_LINK && !remainingPlanners.length)) {
return { ...data, plannerLink: "" };
}
return data;
}
function formatTaskAlert(task, category, template) {
return String(template || "Tâche : {category}{title} expire bientôt")
.replace("{category}", category ? `${category} ` : "")
.replace("{title}", task.title);
}
export function getPlannerTaskTickerItems({
plannerLink,
modules,
getPlannerData,
nowMs = Date.now(),
alertTemplate
}) {
return getLinkedTaskPlannerModules(plannerLink, modules).flatMap((planner) => {
const data = applyDueResets(normalizeTaskPlannerData(getPlannerData(planner.id)), nowMs);
const parentMap = getTaskParentMap(data.tasks, data.relations);
return data.tasks
.filter((task) => isTaskResetSoon(task, data, nowMs))
.map((task) => ({
id: `planner:${planner.id}:${task.id}`,
kind: "task-warning",
text: formatTaskAlert(task, getEffectiveTaskCategory(task, data.tasks, parentMap), alertTemplate)
}));
});
}

View file

@ -1,4 +1,4 @@
// Rôle : normalise et compacte les mémos courts des toolboxes.
// Rôle : normalise et compacte les mémos courts et leur liaison éventuelle aux planificateurs.
import { NOTEPAD_ALLOWED_COLORS } from "./drawing.js";
import { normalizeIsoDate, uid } from "./shared.js";
@ -20,6 +20,7 @@ export function normalizeMemosData(data) {
const source = data && typeof data === "object" ? data : {};
return {
tickerEnabled: source.tickerEnabled !== false,
plannerLink: String(source.plannerLink || "").trim(),
items: (Array.isArray(source.items) ? source.items : []).map(normalizeMemoItem).filter(Boolean)
};
}
@ -28,6 +29,7 @@ export function compactMemosDataForStorage(value) {
const normalized = normalizeMemosData(value);
const compact = {};
if (normalized.tickerEnabled === false) compact.tickerEnabled = false;
if (normalized.plannerLink) compact.plannerLink = normalized.plannerLink;
if (normalized.items.length) {
compact.items = normalized.items.map((item) => {
const compactItem = {

View file

@ -242,6 +242,11 @@ export function compactModuleDataForStorage(type, value) {
return compactor ? compactor(value) : value;
}
export function remapMemosPlannerLink(data, resolveModuleId) {
if (!data || typeof data !== "object" || !data.plannerLink || data.plannerLink === "all") return data;
return { ...data, plannerLink: resolveModuleId(data.plannerLink) || "" };
}
export function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
return compactModuleDataForStorage(moduleType || module?.type, value);
@ -255,7 +260,7 @@ function createExportIdFactory() {
};
}
function remapModuleDataForExport(type, data, nextId) {
function remapModuleDataForExport(type, data, nextId, moduleIdMap) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
@ -332,6 +337,8 @@ function remapModuleDataForExport(type, data, nextId) {
if (type === "memos") {
const remapped = {};
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
if (compact.plannerLink === "all") remapped.plannerLink = "all";
else if (compact.plannerLink && moduleIdMap.has(compact.plannerLink)) remapped.plannerLink = moduleIdMap.get(compact.plannerLink);
if (compact.items) remapped.items = compact.items.map((memo) => ({ ...memo, id: nextId("memo") }));
return remapped;
}
@ -490,7 +497,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
const modules = Object.fromEntries(source.modules
.map((module) => [
moduleIdMap.get(module.id),
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId, moduleIdMap)
])
.filter(([, data]) => data != null));
return { version: 2, toolbox: exportedToolbox, modules };

View file

@ -7,6 +7,7 @@ import {
labelFromFileName,
MAX_TOOLBOX_SPACES,
normalizeToolbox,
remapMemosPlannerLink,
remapModuleOrderIds,
uid
} from "./storage/toolboxStorage.js";
@ -207,7 +208,8 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const nextId = moduleIdMap.get(oldId);
const module = normalizedImport.modules.find((item) => item.id === nextId);
if (nextId) {
const compact = compactModuleDataForStorage(module?.type, data);
const remappedData = module?.type === "memos" ? remapMemosPlannerLink(data, (plannerId) => moduleIdMap.get(plannerId)) : data;
const compact = compactModuleDataForStorage(module?.type, remappedData);
if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact;
}
});
@ -270,7 +272,10 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const nextModuleId = moduleIdMap.get(key);
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
const compact = compactModuleDataForStorage(module?.type, data);
const remappedData = module?.type === "memos"
? remapMemosPlannerLink(data, (plannerId) => moduleIdMap.get(globalModuleKey(oldToolboxId, plannerId)))
: data;
const compact = compactModuleDataForStorage(module?.type, remappedData);
if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact;
});
const saved = await store.persistToolboxSnapshot((current) => ({

View file

@ -74,11 +74,41 @@
.topbar,
.page-heading,
.toolbox-head,
.module header {
.module .module-header {
align-items: stretch;
flex-direction: column;
}
.module .module-header-actions {
justify-content: flex-end;
}
.module-configuration-panel {
grid-template-columns: 1fr;
}
.module-configuration-organization,
.module-configuration-data,
.module-configuration-panel:not(:has(.module-configuration-organization)) .module-configuration-data,
.module-configuration-specific {
grid-column: 1;
grid-row: auto;
}
.module-configuration-organization {
order: 0;
padding-right: 6px;
border-right: 0;
}
.module-configuration-data {
order: 1;
}
.module-configuration-specific {
order: 2;
}
.topbar {
padding: var(--space-4);
}

View file

@ -9,7 +9,7 @@
.nds-switch { max-width: 100%; }
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
}
.module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); }
.module-configuration-button[data-guide-linked] .ui-icon { color: var(--color-accent-gold); }
.game-heading-actions {
display: flex;
align-self: stretch;

View file

@ -22,6 +22,28 @@
flex: 0 0 auto;
}
.memos-settings {
display: grid;
}
.memos-settings label {
display: grid;
grid-template-columns: minmax(120px, auto) minmax(180px, 1fr);
gap: var(--space-3);
align-items: center;
}
.memos-settings label > span {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
text-transform: uppercase;
}
.memos-settings select {
width: 100%;
}
.memo-card.is-drop-target {
border-color: rgba(246, 196, 83, 0.48);
background: rgba(246, 196, 83, 0.1);
@ -74,6 +96,10 @@
bottom: 84px;
left: 12px;
}
.memos-settings label {
grid-template-columns: 1fr;
}
}
.memos-ticker-track {
@ -104,6 +130,19 @@
box-shadow: 0 0 10px color-mix(in srgb, var(--memo-color) 42%, transparent);
}
.memos-ticker-item.is-task-warning {
color: var(--color-warning, #f6c453);
}
.memos-ticker-item.is-task-warning::before {
content: none;
}
.memos-ticker-item.is-task-warning .ui-icon {
width: 16px;
height: 16px;
}
.memos-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));

View file

@ -2,7 +2,7 @@
@use "../design-system/controls";
@use "../mixins";
.module header {
.module .module-header {
display: flex;
align-items: center;
justify-content: space-between;
@ -11,26 +11,43 @@
border-bottom: 1px solid var(--color-border);
}
.module header > div {
.module .module-header-main,
.module .module-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.module header h2 {
.module .module-header-main {
min-width: 0;
flex: 1 1 auto;
}
.module .module-header-actions {
flex: 0 0 auto;
}
.module .module-header h2 {
margin: 0;
font-size: 17px;
}
.module-space-select {
width: auto;
max-width: 150px;
width: 100%;
}
.module-content {
min-width: 0;
}
.module-component-host {
display: contents;
}
.module-component-host[hidden] {
display: none;
}
.module.is-collapsed header {
border-bottom-color: transparent;
}
@ -78,11 +95,14 @@
}
.module-title {
min-width: 80px;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: text;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-title:focus {
@ -92,14 +112,14 @@
}
.module-edit-button,
.module-exchange-button,
.module-configuration-button,
.module-delete-button,
.module-collapse-button {
flex: 0 0 40px;
}
.module-edit-button,
.module-exchange-button,
.module-configuration-button,
.module-collapse-button {
color: var(--color-text-secondary);
}
@ -122,10 +142,88 @@
}
.module-edit-button.active,
.module-exchange-button.active {
.module-configuration-button.active {
@include mixins.temporary-toggle-active;
}
.module-configuration-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--space-4);
row-gap: var(--space-2);
margin: var(--space-1) var(--space-2) var(--space-2);
padding: 10px 12px 12px;
border: 1px solid rgba(165, 180, 252, 0.1);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.035), transparent 56%),
rgba(7, 10, 24, 0.2);
}
.module-configuration-target {
display: contents;
}
.module-configuration-section {
display: grid;
min-width: 0;
align-content: start;
gap: var(--space-2);
padding: 4px 6px;
}
.module-configuration-organization {
grid-column: 1;
grid-row: 1;
padding-right: var(--space-4);
border-right: 1px solid rgba(165, 180, 252, 0.1);
}
.module-configuration-data {
grid-column: 2;
grid-row: 1;
}
.module-configuration-panel:not(:has(.module-configuration-organization)) .module-configuration-data {
grid-column: 1 / -1;
}
.module-configuration-specific {
grid-column: 1 / -1;
padding-top: var(--space-3);
border-top: 1px solid rgba(165, 180, 252, 0.1);
}
.module-configuration-section > h3 {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.6875rem;
font-weight: 900;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.module-configuration-section .module-text-exchange-panel,
.module-configuration-section .guide-checklist-controls {
margin: 0;
padding: 0;
border: 0;
}
.module-configuration-panel .text-import-actions {
gap: 6px;
}
.module-configuration-panel .text-import-actions button {
min-height: 34px;
gap: 6px;
}
.module-configuration-panel .text-import-actions .ui-icon {
width: 15px;
height: 15px;
}
.module-icon {
display: grid;
width: 34px;

View file

@ -29,29 +29,14 @@
gap: 10px;
}
.task-planner-settings-toggle {
grid-template-columns: 16px minmax(0, auto);
width: auto;
max-width: 100%;
padding: 0 12px;
gap: 8px;
}
.task-planner-settings-toggle span {
overflow: hidden;
font-size: var(--font-size-xs);
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-planner-clear-completed-button {
display: inline-flex;
min-height: 38px;
min-height: 30px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
gap: 6px;
padding: 0 10px;
font-size: var(--font-size-xs);
white-space: nowrap;
}
@ -73,6 +58,12 @@
rgba(7, 10, 24, 0.34);
}
.module-configuration-specific .task-planner-settings {
padding: 0;
border: 0;
background: none;
}
.task-planner-settings-actions {
display: flex;
grid-column: 1 / -1;
@ -108,12 +99,18 @@
width: 100%;
}
.task-planner-settings input,
.task-planner-settings input {
@include controls.field-control(xs);
}
.task-planner-reset-override input {
@include controls.field-control(sm);
}
.task-planner-settings input[type="time"],
.task-planner-settings input[type="time"] {
@include controls.time-control(xs);
}
.task-planner-reset-override input[type="time"] {
@include controls.time-control(sm);
}