add randomizer module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-08-31 09:10:38 +02:00
parent 07de342fc2
commit 35fe877d58
32 changed files with 3822 additions and 200 deletions

66
website/AGENTS.md Normal file
View file

@ -0,0 +1,66 @@
# Agent Instructions
Cette zone contient l'application React + Vite et les assets publics.
## Références
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
- `../docs/STORAGE_SCHEMA.md`
- `../docs/FEATURE_CHECKLIST.md`
Avant de modifier le style, les composants UI, les layouts, les boutons, cards, panels, hovers, scrollbars ou notifications, lire :
- `../DESIGN_SYSTEM.md`
Après ce type de modification, mettre à jour ces documents si le format, le workflow ou le pattern visuel change.
## Contenu éditable
Le contenu éditorial du site doit rester dans :
- `public/data/site.json`
- `public/data/<gameId>/...`
`site.json` est la source de vérité. `npm run check` doit échouer si le contenu requis est invalide.
## En-têtes de fichiers
Chaque nouveau fichier source ou test doit commencer par un commentaire court `Rôle : ...` décrivant ce qu'il gère.
- Mettre cet en-tête à jour si la responsabilité du fichier change.
- Garder l'intro concise, une à trois lignes maximum selon la complexité du fichier.
- Enrichir l'en-tête seulement quand le fichier porte une logique transverse, un hook partagé, un format de données ou un comportement réutilisable.
- Utiliser `// Rôle : ...` dans les fichiers JS, JSX, MJS et SCSS.
## Serveur de développement
Si le port `5173` est déjà occupé, considérer qu'un `npm run dev` est probablement déjà en cours. Ne pas lancer automatiquement un nouveau serveur sur `5174`.
Quand une vérification navigateur est nécessaire, utiliser l'instance déjà ouverte sur `http://localhost:5173` si elle répond.
## Validation
Avant de terminer une modification significative, lancer :
```bash
npm run check
```
Le check couvre la génération des index de listes, les tests et le build de production.
## Pré-push check
Quand l'utilisateur indique qu'il va push, faire une passe globale avant de conclure :
- code mort, imports inutilisés, fonctions inutilisées, styles obsolètes ;
- duplication de code ou de style pouvant être factorisée sans complexifier ;
- cohérence entre boutons, badges, cards, panels, hovers, focus states et scrollbars ;
- cohérence entre `public/data/site.json`, les validations et les textes utilisés ;
- cohérence entre `../docs/STORAGE_SCHEMA.md` et les normalisations IndexedDB réelles ;
- import/export des toolboxes quand la structure de données a changé ;
- champs inutiles persistés dans IndexedDB ou dans les exports ;
- fichiers générés attendus, notamment les index de listes ;
- optimisations simples de performance : recalculs au render, listeners non nettoyés, observers, effets CSS lourds appliqués en masse.
Après lecture du diff pré-push, proposer un message de commit anglais, concis et représentatif.

View file

@ -45,6 +45,10 @@
"id": "mf",
"type": "goalTree"
},
{
"id": "mg",
"type": "randomizer"
},
{
"id": "m9",
"type": "timer"
@ -233,6 +237,7 @@
"m7",
"m8",
"mf",
"mg",
"m9",
"md",
"me",
@ -255,6 +260,7 @@
"m7",
"m8",
"mf",
"mg",
"m9",
"md",
"me",
@ -4073,6 +4079,686 @@
}
]
},
"mg": {
"activeTab": "teams",
"dice": {
"faces": 6,
"rolls": 5,
"results": [
1,
2,
4,
5,
6
],
"history": [
{
"id": "x-dice-history-1",
"at": "2026-08-31T06:52:00.000Z",
"results": [
1,
2,
4,
5,
6
]
},
{
"id": "x-dice-history-2",
"at": "2026-08-31T06:49:00.000Z",
"results": [
6,
3,
2,
2,
5
]
},
{
"id": "x-dice-history-3",
"at": "2026-08-31T06:46:00.000Z",
"results": [
4,
4,
1,
3,
6
]
}
]
},
"wheel": {
"draws": 3,
"entries": [
{
"id": "x1",
"label": "Oui",
"weight": 2
},
{
"id": "x2",
"label": "Non",
"weight": 2
},
{
"id": "x3",
"label": "Peut etre",
"weight": 1
}
],
"results": [
"Oui",
"Non",
"Peut etre"
],
"history": [
{
"id": "x-wheel-history-1",
"at": "2026-08-31T06:51:00.000Z",
"results": [
"Oui",
"Non",
"Peut etre"
]
},
{
"id": "x-wheel-history-2",
"at": "2026-08-31T06:47:00.000Z",
"results": [
"Non",
"Oui",
"Non"
]
},
{
"id": "x-wheel-history-3",
"at": "2026-08-31T06:44:00.000Z",
"results": [
"Peut etre",
"Oui",
"Non"
]
}
]
},
"teams": {
"teamCount": 4,
"playerSelectionMode": "all",
"playerSelectionCount": 6,
"players": [
{
"id": "x4",
"name": "Dracaufeu",
"score": 0
},
{
"id": "x5",
"name": "Tortank",
"score": 0
},
{
"id": "x6",
"name": "Florizarre",
"score": 0
},
{
"id": "x7",
"name": "Dracolosse",
"score": 0
},
{
"id": "x8",
"name": "Ectoplasma",
"score": 0
},
{
"id": "x9",
"name": "Alakazam",
"score": 0
},
{
"id": "x10",
"name": "Mackogneur",
"score": 0
},
{
"id": "x11",
"name": "Cizayox",
"score": 0
},
{
"id": "x12",
"name": "Tyranocif",
"score": 0
},
{
"id": "x13",
"name": "Gardevoir",
"score": 0
},
{
"id": "x14",
"name": "Drattak",
"score": 0
},
{
"id": "x15",
"name": "Métalosse",
"score": 0
},
{
"id": "x16",
"name": "Carchacrok",
"score": 0
},
{
"id": "x17",
"name": "Lucario",
"score": 0
},
{
"id": "x18",
"name": "Trioxhydre",
"score": 0
},
{
"id": "x19",
"name": "Amphinobi",
"score": 0
},
{
"id": "x20",
"name": "Archéduc",
"score": 0
},
{
"id": "x21",
"name": "Félinferno",
"score": 0
},
{
"id": "x22",
"name": "Corvaillus",
"score": 0
},
{
"id": "x23",
"name": "Lanssorien",
"score": 0
},
{
"id": "x24",
"name": "Raichu",
"score": 0
},
{
"id": "x25",
"name": "Artikodin",
"score": 0
},
{
"id": "x26",
"name": "Électhor",
"score": 0
},
{
"id": "x27",
"name": "Sulfura",
"score": 0
}
],
"exclusions": [
{
"id": "x28",
"playerAId": "x7",
"playerBIds": [
"x14",
"x16",
"x23"
],
"mode": "group"
},
{
"id": "x29",
"playerAId": "x25",
"playerBIds": [
"x26",
"x27"
],
"mode": "group"
}
],
"teams": [
{
"id": "team-1",
"name": "Équipe 1",
"score": 0,
"players": [
{
"id": "x9",
"name": "Alakazam",
"score": 0
},
{
"id": "x24",
"name": "Raichu",
"score": 0
},
{
"id": "x20",
"name": "Archéduc",
"score": 0
},
{
"id": "x7",
"name": "Dracolosse",
"score": 0
},
{
"id": "x5",
"name": "Tortank",
"score": 0
},
{
"id": "x26",
"name": "Électhor",
"score": 0
}
]
},
{
"id": "team-2",
"name": "Équipe 2",
"score": 0,
"players": [
{
"id": "x25",
"name": "Artikodin",
"score": 0
},
{
"id": "x8",
"name": "Ectoplasma",
"score": 0
},
{
"id": "x13",
"name": "Gardevoir",
"score": 0
},
{
"id": "x15",
"name": "Métalosse",
"score": 0
},
{
"id": "x17",
"name": "Lucario",
"score": 0
},
{
"id": "x16",
"name": "Carchacrok",
"score": 0
}
]
},
{
"id": "team-3",
"name": "Équipe 3",
"score": 0,
"players": [
{
"id": "x12",
"name": "Tyranocif",
"score": 0
},
{
"id": "x11",
"name": "Cizayox",
"score": 0
},
{
"id": "x10",
"name": "Mackogneur",
"score": 0
},
{
"id": "x18",
"name": "Trioxhydre",
"score": 0
},
{
"id": "x6",
"name": "Florizarre",
"score": 0
},
{
"id": "x23",
"name": "Lanssorien",
"score": 0
}
]
},
{
"id": "team-4",
"name": "Équipe 4",
"score": 0,
"players": [
{
"id": "x27",
"name": "Sulfura",
"score": 0
},
{
"id": "x21",
"name": "Félinferno",
"score": 0
},
{
"id": "x19",
"name": "Amphinobi",
"score": 0
},
{
"id": "x22",
"name": "Corvaillus",
"score": 0
},
{
"id": "x14",
"name": "Drattak",
"score": 0
},
{
"id": "x4",
"name": "Dracaufeu",
"score": 0
}
]
}
],
"history": [
{
"id": "x-teams-history-1",
"at": "2026-08-31T06:50:00.000Z",
"teams": [
{
"id": "team-1",
"name": "Équipe 1",
"score": 0,
"players": [
{
"id": "x9",
"name": "Alakazam",
"score": 0
},
{
"id": "x24",
"name": "Raichu",
"score": 0
},
{
"id": "x20",
"name": "Archéduc",
"score": 0
},
{
"id": "x7",
"name": "Dracolosse",
"score": 0
},
{
"id": "x5",
"name": "Tortank",
"score": 0
},
{
"id": "x26",
"name": "Électhor",
"score": 0
}
]
},
{
"id": "team-2",
"name": "Équipe 2",
"score": 0,
"players": [
{
"id": "x25",
"name": "Artikodin",
"score": 0
},
{
"id": "x8",
"name": "Ectoplasma",
"score": 0
},
{
"id": "x13",
"name": "Gardevoir",
"score": 0
},
{
"id": "x15",
"name": "Métalosse",
"score": 0
},
{
"id": "x17",
"name": "Lucario",
"score": 0
},
{
"id": "x16",
"name": "Carchacrok",
"score": 0
}
]
},
{
"id": "team-3",
"name": "Équipe 3",
"score": 0,
"players": [
{
"id": "x12",
"name": "Tyranocif",
"score": 0
},
{
"id": "x11",
"name": "Cizayox",
"score": 0
},
{
"id": "x10",
"name": "Mackogneur",
"score": 0
},
{
"id": "x18",
"name": "Trioxhydre",
"score": 0
},
{
"id": "x6",
"name": "Florizarre",
"score": 0
},
{
"id": "x23",
"name": "Lanssorien",
"score": 0
}
]
},
{
"id": "team-4",
"name": "Équipe 4",
"score": 0,
"players": [
{
"id": "x27",
"name": "Sulfura",
"score": 0
},
{
"id": "x21",
"name": "Félinferno",
"score": 0
},
{
"id": "x19",
"name": "Amphinobi",
"score": 0
},
{
"id": "x22",
"name": "Corvaillus",
"score": 0
},
{
"id": "x14",
"name": "Drattak",
"score": 0
},
{
"id": "x4",
"name": "Dracaufeu",
"score": 0
}
]
}
]
},
{
"id": "x-teams-history-2",
"at": "2026-08-31T06:48:00.000Z",
"teams": [
{
"id": "team-1",
"name": "Équipe 1",
"score": 0,
"players": [
{
"id": "x19",
"name": "Amphinobi",
"score": 0
},
{
"id": "x13",
"name": "Gardevoir",
"score": 0
},
{
"id": "x4",
"name": "Dracaufeu",
"score": 0
},
{
"id": "x9",
"name": "Alakazam",
"score": 0
},
{
"id": "x6",
"name": "Florizarre",
"score": 0
},
{
"id": "x11",
"name": "Cizayox",
"score": 0
}
]
}
]
},
{
"id": "x-teams-history-3",
"at": "2026-08-31T06:48:00.000Z",
"teams": [
{
"id": "team-1",
"name": "Équipe 1",
"score": 0,
"players": [
{
"id": "x13",
"name": "Gardevoir",
"score": 0
},
{
"id": "x19",
"name": "Amphinobi",
"score": 0
},
{
"id": "x16",
"name": "Carchacrok",
"score": 0
},
{
"id": "x10",
"name": "Mackogneur",
"score": 0
},
{
"id": "x21",
"name": "Félinferno",
"score": 0
},
{
"id": "x9",
"name": "Alakazam",
"score": 0
}
]
},
{
"id": "team-2",
"name": "Équipe 2",
"score": 0,
"players": [
{
"id": "x5",
"name": "Tortank",
"score": 0
},
{
"id": "x11",
"name": "Cizayox",
"score": 0
},
{
"id": "x18",
"name": "Trioxhydre",
"score": 0
},
{
"id": "x22",
"name": "Corvaillus",
"score": 0
},
{
"id": "x12",
"name": "Tyranocif",
"score": 0
},
{
"id": "x20",
"name": "Archéduc",
"score": 0
}
]
}
]
}
]
}
},
"m9": {
"activeTab": "countdown",
"stopwatch": {

View file

@ -436,6 +436,61 @@
"example": "Top 1: 3\nTop 2: 6\nTop 3: 2\nTop 4: 1"
}
},
"randomizer": {
"controls": [
{
"group": "Tirage",
"icon": "play-circle",
"title": "Lancer",
"preview": {
"kind": "iconButton",
"icon": "play-circle"
}
},
{
"group": "Paramètres",
"icon": "dice",
"title": "Faces, poids et scores",
"preview": {
"kind": "textButton",
"label": "N"
}
},
{
"group": "Historique",
"icon": "rubber",
"title": "Effacer l'historique",
"preview": {
"kind": "button",
"label": "Effacer"
}
}
],
"features": [
{
"title": "Trois modes fixes",
"text": "Dé numérique, roue pondérée et équipes équilibrées restent disponibles dans le même outil.",
"details": [
"Le dé tire une ou plusieurs valeurs entre 1 et le nombre de faces.",
"La roue utilise des poids pour rendre certaines valeurs plus probables.",
"Les équipes répartissent les joueurs actifs en équilibrant les scores."
]
},
{
"title": "Contraintes d'équipes",
"text": "Les exclusions paire-à-paire empêchent deux joueurs de tomber dans la même équipe quand une solution existe.",
"details": [
"Chaque joueur peut être désactivé sans être supprimé.",
"Le score sert uniquement à équilibrer les équipes.",
"Si les contraintes sont impossibles, le dernier résultat valide reste affiché."
]
}
],
"importFormat": {
"text": "Le format texte contient les sections [dice], [wheel] et [teams]. Les exclusions d'équipes commencent par !.",
"example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group"
}
},
"images": {
"controls": [
{
@ -1602,8 +1657,8 @@
{
"icon": "table",
"title": "Données et calculs",
"text": "Comparer des valeurs, préparer des tableaux, calculer des besoins de craft ou structurer des objectifs.",
"examples": ["Calculateur", "Tableau", "Arbre d'objectifs"]
"text": "Comparer des valeurs, préparer des tableaux, calculer des besoins de craft, structurer des objectifs ou tirer des choix aléatoires.",
"examples": ["Calculateur", "Tableau", "Arbre d'objectifs", "Randomizer"]
},
{
"icon": "clock",
@ -1919,6 +1974,70 @@
"resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer"
},
"randomizer": {
"diceTab": "Dé",
"wheelTab": "Roue",
"teamsTab": "Équipes",
"facesLabel": "Faces",
"rollsLabel": "Lancers",
"rollButton": "Lancer",
"stopButton": "Arrêter",
"totalLabel": "Total",
"drawsLabel": "Tirages",
"drawButton": "Tirer",
"valuesTitle": "Valeurs",
"valuePlaceholder": "Valeur",
"savedValuesLabel": "Valeurs enregistrées",
"weightLabel": "Poids",
"addButton": "Ajouter",
"clearValuesButton": "Supprimer toutes les valeurs",
"teamCountLabel": "Équipes",
"createTeamsButton": "Créer les équipes",
"playerSelectionModeLabel": "Répartition",
"playerSelectionAll": "Tous",
"playerSelectionCount": "Nombre défini",
"playerSelectionCountLabel": "Nombre",
"nameLabel": "Nom",
"namesImportLabel": "Liste de noms",
"namesImportPlaceholder": "Un nom par ligne",
"importNamesButton": "Ajouter",
"clearNamesButton": "Supprimer tous les noms",
"valuesImportLabel": "Liste de valeurs",
"valuesImportPlaceholder": "Une valeur par ligne",
"scoreLabel": "Score",
"activeLabel": "Actif",
"firstPlayerLabel": "Nom source",
"secondPlayerLabel": "Noms exclus",
"selectNamesButton": "Choisir",
"selectedNamesLabel": "noms",
"exclusionModeLabel": "Type",
"exclusionModeSource": "Exclusions granulaires",
"exclusionModeGroup": "Tous séparés",
"addExclusionButton": "Exclure",
"exclusionsListLabel": "Combinaisons exclues",
"teamsSingularLabel": "équipe",
"teamsPluralLabel": "équipes",
"teamScoreLabel": "Score",
"emptyTeams": "Aucune équipe générée.",
"historyTitle": "Historique",
"emptyHistory": "Aucun historique.",
"clearHistoryButton": "Effacer tout",
"deleteTitle": "Supprimer",
"wheelInvalid": "Ajoutez au moins deux valeurs.",
"notEnoughPlayers": "Pas assez de joueurs actifs.",
"constraintsImpossible": "Contraintes impossibles à satisfaire.",
"importPlaceholder": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un randomizer",
"exportModalTitle": "Exporter un randomizer",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import randomizer terminé.",
"exportSuccess": "Export randomizer copié.",
"importError": "Format de randomizer invalide.",
"exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes."
},
"combos": {
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
"importOpenButton": "Importer du texte",

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>dice [#25]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-180.000000, -8079.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M138,7927.1047 C136.896,7927.1047 136,7927.9997 136,7929.1047 C136,7930.2097 136.896,7931.1047 138,7931.1047 C139.104,7931.1047 140,7930.2097 140,7929.1047 C140,7927.9997 139.104,7927.1047 138,7927.1047 L138,7927.1047 Z M130,7927.1047 C128.896,7927.1047 128,7927.9997 128,7929.1047 C128,7930.2097 128.896,7931.1047 130,7931.1047 C131.104,7931.1047 132,7930.2097 132,7929.1047 C132,7927.9997 131.104,7927.1047 130,7927.1047 L130,7927.1047 Z M142,7921.9997 C142,7921.4477 141.552,7920.9997 141,7920.9997 L127,7920.9997 C126.448,7920.9997 126,7921.4477 126,7921.9997 L126,7935.9997 C126,7936.5527 126.448,7936.9997 127,7936.9997 L141,7936.9997 C141.552,7936.9997 142,7936.5527 142,7935.9997 L142,7921.9997 Z M144,7920.9997 L144,7936.9997 C144,7938.1047 143.104,7938.9997 142,7938.9997 L126,7938.9997 C124.896,7938.9997 124,7938.1047 124,7936.9997 L124,7920.9997 C124,7919.8957 124.896,7918.9997 126,7918.9997 L142,7918.9997 C143.104,7918.9997 144,7919.8957 144,7920.9997 L144,7920.9997 Z M130,7922.1047 C128.896,7922.1047 128,7922.9997 128,7924.1047 C128,7925.2097 128.896,7926.1047 130,7926.1047 C131.104,7926.1047 132,7925.2097 132,7924.1047 C132,7922.9997 131.104,7922.1047 130,7922.1047 L130,7922.1047 Z M130,7932.1047 C128.896,7932.1047 128,7932.9997 128,7934.1047 C128,7935.2097 128.896,7936.1047 130,7936.1047 C131.104,7936.1047 132,7935.2097 132,7934.1047 C132,7932.9997 131.104,7932.1047 130,7932.1047 L130,7932.1047 Z M138,7922.1047 C136.896,7922.1047 136,7922.9997 136,7924.1047 C136,7925.2097 136.896,7926.1047 138,7926.1047 C139.104,7926.1047 140,7925.2097 140,7924.1047 C140,7922.9997 139.104,7922.1047 138,7922.1047 L138,7922.1047 Z M140,7934.1047 C140,7935.2097 139.104,7936.1047 138,7936.1047 C136.896,7936.1047 136,7935.2097 136,7934.1047 C136,7932.9997 136.896,7932.1047 138,7932.1047 C139.104,7932.1047 140,7932.9997 140,7934.1047 L140,7934.1047 Z" id="dice-[#25]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<path d="M437.034,437.002C483.352,390.676,512,326.683,512,256c0-70.601-28.583-134.526-74.806-180.839
c-0.059-0.061-0.109-0.127-0.169-0.188c-0.06-0.06-0.127-0.11-0.188-0.17C390.523,28.582,326.599,0,256,0
S121.477,28.582,75.164,74.804c-0.061,0.06-0.128,0.11-0.188,0.17c-0.06,0.06-0.11,0.127-0.169,0.188
C28.583,121.474,0,185.399,0,256c0,70.683,28.648,134.676,74.966,181.002c0.005,0.005,0.009,0.01,0.014,0.015
c0.005,0.005,0.01,0.009,0.015,0.013C121.321,483.351,185.315,512,256,512s134.679-28.649,181.005-74.969
c0.005-0.005,0.01-0.009,0.015-0.013C437.025,437.013,437.029,437.007,437.034,437.002z M43.726,277.333h107.744
c2.765,13.617,8.127,26.288,15.542,37.483l-76.188,76.188C64.959,359.398,47.986,320.237,43.726,277.333z M90.825,120.993
l76.188,76.188c-7.415,11.196-12.779,23.868-15.544,37.486H43.726C47.986,191.762,64.96,152.6,90.825,120.993z M468.274,234.667
H360.531c-2.765-13.618-8.128-26.29-15.544-37.486l76.188-76.187C447.04,152.6,464.014,191.762,468.274,234.667z M301.25,301.248
c-0.022,0.022-0.04,0.046-0.062,0.068C289.611,312.861,273.639,320,256,320s-33.611-7.139-45.188-18.684
c-0.022-0.022-0.04-0.046-0.062-0.068c-0.022-0.022-0.046-0.039-0.067-0.061C199.139,289.61,192,273.638,192,256
c0-35.343,28.657-64,64-64s64,28.657,64,64c0,17.638-7.139,33.61-18.683,45.187C301.296,301.209,301.272,301.226,301.25,301.248z
M314.817,167.011c-11.195-7.414-23.867-12.777-37.483-15.542V43.726c42.904,4.26,82.065,21.234,113.671,47.098L314.817,167.011z
M234.667,151.469c-13.617,2.765-26.288,8.127-37.483,15.542l-76.188-76.188c31.606-25.864,70.767-42.838,113.671-47.098V151.469z
M197.181,344.987c11.196,7.415,23.868,12.779,37.486,15.544v107.744c-42.905-4.26-82.067-21.235-113.674-47.1L197.181,344.987z
M277.333,360.531c13.618-2.765,26.29-8.128,37.486-15.544l76.188,76.188c-31.607,25.865-70.769,42.839-113.674,47.1V360.531z
M344.989,314.817c7.414-11.195,12.777-23.867,15.542-37.483h107.744c-4.26,42.904-21.234,82.065-47.098,113.671L344.989,314.817z
"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -13,6 +13,7 @@ export function CompactDropdown({
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const internalPointerDownRef = useRef(false);
useEffect(() => {
if (!open) return undefined;
@ -48,7 +49,14 @@ export function CompactDropdown({
onMouseEnter={openOnHover ? () => setOpen(true) : undefined}
onMouseLeave={closeOnMouseLeave ? close : undefined}
onFocusCapture={openOnHover ? () => setOpen(true) : undefined}
onPointerDownCapture={() => {
internalPointerDownRef.current = true;
window.setTimeout(() => {
internalPointerDownRef.current = false;
}, 0);
}}
onBlurCapture={(event) => {
if (internalPointerDownRef.current) return;
if (!event.currentTarget.contains(event.relatedTarget)) close();
}}
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}

View file

@ -0,0 +1,40 @@
# Agent Instructions
Cette zone contient les outils toolbox, leur stockage local et leurs comportements partagés.
## Références obligatoires
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
- `../../../../docs/STORAGE_SCHEMA.md`
- `../../../../docs/FEATURE_CHECKLIST.md`
Après changement de format ou de workflow, mettre ces documents à jour.
## Modules toolbox
Chaque outil toolbox doit rester dans son propre fichier dans :
```text
website/src/features/toolboxes/modules/
```
Quand un outil est ajouté ou modifié :
- déclarer l'outil dans `modules/index.jsx` ;
- ajouter les textes dans `../../../public/data/site.json` ;
- mettre à jour la validation dans `../../../../tests/helpers/data-validation.mjs` ;
- mettre à jour `../../../../docs/STORAGE_SCHEMA.md` si la structure IndexedDB change ;
- vérifier l'affichage page toolbox et panneau latéral.
Quand la structure de données d'un outil change, ne pas prévoir de compatibilité legacy, de migration ou de tolérance d'anciens formats, sauf demande explicite. Le format documenté dans `STORAGE_SCHEMA.md` reste le format cible strict.
## Hooks et comportements partagés
Quand un comportement est déjà couvert par un hook ou un helper partagé, privilégier son utilisation plutôt qu'une réimplémentation locale.
- Pour la réorganisation par drag & drop, utiliser `useGroupedReorder` dès qu'il s'agit d'items, groupes, catégories, boundaries, parent/enfant ou listes horizontales/verticales.
- Réserver `usePointerReorder` au moteur bas niveau ou aux cas DOM très spécifiques qui ne correspondent pas au modèle applicatif de `useGroupedReorder`.
- Si une variation est nécessaire, vérifier d'abord si elle doit devenir une option du hook partagé.
- Demander ou expliciter le choix uniquement quand la variation est réellement métier et pourrait alourdir le hook.
- Éviter de dupliquer dans un outil une règle de reorder générique déjà prise en charge par le hook.

View file

@ -26,6 +26,7 @@ import {
normalizeMemosData,
normalizeModuleOrder,
normalizeNotepadData,
normalizeRandomizerData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
@ -200,6 +201,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,

View file

@ -0,0 +1,873 @@
// Rôle : fournit l'outil Randomizer avec dé, roue pondérée et équipes équilibrées.
import { useEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
RANDOMIZER_LIMITS,
RANDOMIZER_TABS,
addRandomizerHistory,
buildWheelSegments,
clampNumber,
createBalancedTeams,
drawWeightedEntries,
filterExclusionsForPlayers,
getDiceShape,
rollDice
} from "./randomizerUtils.js";
const WHEEL_COLORS = ["#8b5cf6", "#f6c453", "#22d3ee", "#d946ef", "#6366f1", "#22c55e"];
const DRAW_PAUSE_MS = 500;
const DICE_ROLL_DELAYS = [62, 70, 82, 98, 120, 148, 182, 224, 276, 338, 410];
const TEAM_REEL_DELAYS = [58, 66, 78, 94, 116, 144, 180, 226, 286, 360];
const WHEEL_SPIN_MS = 1120;
const DEFAULT_EXCLUSION_DRAFT = { playerAId: "", playerBIds: [], mode: "group" };
function hasReducedMotion() {
return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
function polarToCartesian(radius, angle) {
const radians = (angle * Math.PI) / 180;
return {
x: 50 + radius * Math.cos(radians),
y: 50 + radius * Math.sin(radians)
};
}
function describeWheelArc(startAngle, endAngle) {
const start = polarToCartesian(48, startAngle);
const end = polarToCartesian(48, endAngle);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
return `M 50 50 L ${start.x} ${start.y} A 48 48 0 ${largeArc} 1 ${end.x} ${end.y} Z`;
}
function formatDateTime(value) {
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "";
}
function getWheelTargetRotation(currentRotation, targetAngle) {
const pointerAngle = -90;
const targetRotation = pointerAngle - targetAngle;
const currentModulo = ((currentRotation % 360) + 360) % 360;
const targetModulo = ((targetRotation % 360) + 360) % 360;
const delta = (targetModulo - currentModulo + 360) % 360;
return currentRotation + 1080 + delta;
}
function pickRandomItems(items, count) {
const nextItems = [...items];
for (let index = nextItems.length - 1; index > 0; index -= 1) {
const targetIndex = Math.floor(Math.random() * (index + 1));
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
}
return nextItems.slice(0, count);
}
export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.randomizer || {};
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData);
}
function updateSection(section, nextSection) {
save({ ...data, [section]: nextSection });
}
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>
)}
<div className="randomizer-tool">
<Tabs
tabs={RANDOMIZER_TABS.map((tab) => ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))}
activeTab={data.activeTab}
onChange={(activeTab) => save({ ...data, activeTab })}
className="randomizer-tabs"
size="md"
/>
{data.activeTab === "dice" && <DiceRandomizer data={data.dice} textContent={textContent} onChange={(dice) => updateSection("dice", dice)} context={context} />}
{data.activeTab === "wheel" && <WheelRandomizer data={data.wheel} textContent={textContent} onChange={(wheel) => updateSection("wheel", wheel)} context={context} />}
{data.activeTab === "teams" && <TeamsRandomizer data={data.teams} textContent={textContent} onChange={(teams) => updateSection("teams", teams)} context={context} />}
</div>
</>
);
}
function DiceRandomizer({ data, textContent, onChange, context }) {
const [displayValue, setDisplayValue] = useState(data.results[0] || 1);
const [displayResults, setDisplayResults] = useState(data.results);
const [rolling, setRolling] = useState(false);
const [rollingIndex, setRollingIndex] = useState(-1);
const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]);
const timersRef = useRef([]);
const skipDiceSyncRef = useRef(false);
const shape = getDiceShape(data.faces);
useEffect(() => {
if (rolling) return;
if (skipDiceSyncRef.current) {
skipDiceSyncRef.current = false;
return;
}
setDisplayValue(data.results[0] || 1);
setDisplayResults(data.results);
}, [data.results, rolling]);
useEffect(() => () => {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}, []);
function queueTimer(callback, delay) {
const timerId = window.setTimeout(callback, delay);
timersRef.current.push(timerId);
return timerId;
}
function clearDiceTimers() {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}
function stopDice() {
clearDiceTimers();
skipDiceSyncRef.current = true;
setRolling(false);
setRollingIndex(-1);
}
function updateDiceField(field, value) {
const limitMin = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMin : RANDOMIZER_LIMITS.diceRollsMin;
const limitMax = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMax : RANDOMIZER_LIMITS.diceRollsMax;
onChange({ ...data, [field]: clampNumber(value, limitMin, limitMax, data[field]) });
}
function launchDice() {
clearDiceTimers();
skipDiceSyncRef.current = false;
const results = rollDice(data.faces, data.rolls);
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
if (hasReducedMotion()) {
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
return;
}
setRolling(true);
setRollingIndex(0);
setDisplayResults([]);
function animateRoll(index) {
let ticks = 0;
const tick = () => {
ticks += 1;
const isFinalTick = ticks >= DICE_ROLL_DELAYS.length;
const tickDelay = DICE_ROLL_DELAYS[Math.min(ticks, DICE_ROLL_DELAYS.length - 1)];
setDisplayValue(isFinalTick ? results[index] : rollDice(data.faces, 1)[0]);
setRollDuration(Math.max(54, Math.round(tickDelay * 0.86)));
if (!isFinalTick) {
queueTimer(tick, tickDelay);
return;
}
setDisplayResults((current) => [...current, results[index]]);
if (index < results.length - 1) {
setRollingIndex(index + 1);
queueTimer(() => animateRoll(index + 1), DRAW_PAUSE_MS);
return;
}
setRolling(false);
setRollingIndex(-1);
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
};
tick();
}
animateRoll(0);
}
function clearDiceHistory() {
onChange({ ...data, history: [] });
}
function removeDiceHistoryEntry(entryId) {
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
}
return (
<section className="randomizer-panel randomizer-dice-panel">
<div className="randomizer-dice-stage">
<RandomizerDie shape={shape} rolling={rolling} value={displayValue} duration={rollDuration} faces={data.faces} />
<div className="randomizer-live-results" aria-live="polite">
<ResultChips results={displayResults} />
</div>
</div>
<div className="randomizer-dice-grid">
<div className="randomizer-dice-form">
<div className="randomizer-controls">
<label>
<span>{textContent.facesLabel || "Faces"}</span>
<input className="nds-input" data-size="sm" type="number" min="2" max="999" value={data.faces} onChange={(event) => updateDiceField("faces", event.target.value)} />
</label>
<label>
<span>{textContent.rollsLabel || "Lancers"}</span>
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.rolls} onChange={(event) => updateDiceField("rolls", event.target.value)} />
</label>
<button className="nds-button" data-size="sm" data-variant={rolling ? "danger" : "primary"} type="button" onClick={rolling ? stopDice : launchDice}>
<Icon name={rolling ? "stop-circle" : "play-circle"} />
<span>{rolling ? `${textContent.stopButton || "Arrêter"} ${rollingIndex + 1}/${data.rolls}` : textContent.rollButton || "Lancer"}</span>
</button>
</div>
</div>
<RandomizerHistory
title={textContent.historyTitle || "Historique"}
history={data.history}
renderEntry={(entry) => `${entry.results.join(" - ")} · ${textContent.totalLabel || "Total"} ${entry.results.reduce((sum, result) => sum + result, 0)}`}
onClear={clearDiceHistory}
onRemove={removeDiceHistoryEntry}
disabled={rolling}
textContent={textContent}
showEmpty
/>
</div>
</section>
);
}
function RandomizerDie({ shape, rolling, value, duration, faces }) {
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
const valueIndex = clampNumber(value, 1, cleanFaces, 1) - 1;
const values = useMemo(() => Array.from({ length: cleanFaces }, (_, index) => index + 1), [cleanFaces]);
return (
<div className={`randomizer-die is-${shape} ${rolling ? "is-rolling" : ""}`} aria-live="polite">
<svg className="randomizer-die-shape" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
{shape === "circle" && <circle cx="60" cy="60" r="52" />}
{shape === "triangle" && <path d="M60 9 Q66 9 70 17 L111 96 Q115 105 106 110 L14 110 Q5 105 9 96 L50 17 Q54 9 60 9 Z" />}
{shape === "square" && <rect x="10" y="10" width="100" height="100" rx="16" />}
{shape === "hexagon" && <path d="M36 9 L84 9 Q92 9 96 16 L115 52 Q119 60 115 68 L96 104 Q92 111 84 111 L36 111 Q28 111 24 104 L5 68 Q1 60 5 52 L24 16 Q28 9 36 9 Z" />}
</svg>
<span className="randomizer-die-value">
<span className="randomizer-die-value-track" style={{ "--randomizer-die-index": valueIndex, "--randomizer-die-duration": `${duration}ms` }} aria-hidden="true">
{values.map((item) => <span key={item}>{item}</span>)}
</span>
<span className="sr-only">{value}</span>
</span>
</div>
);
}
function WheelRandomizer({ data, textContent, onChange, context }) {
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [drawing, setDrawing] = useState(false);
const [displayResults, setDisplayResults] = useState(data.results);
const [entriesText, setEntriesText] = useState("");
const timersRef = useRef([]);
const skipWheelSyncRef = useRef(false);
const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]);
useEffect(() => {
if (drawing) return;
if (skipWheelSyncRef.current) {
skipWheelSyncRef.current = false;
return;
}
setDisplayResults(data.results);
}, [data.results, drawing]);
useEffect(() => () => {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}, []);
function queueTimer(callback, delay) {
const timerId = window.setTimeout(callback, delay);
timersRef.current.push(timerId);
return timerId;
}
function clearWheelTimers() {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}
function stopWheel() {
clearWheelTimers();
skipWheelSyncRef.current = true;
setSpinning(false);
setDrawing(false);
}
function addEntries(event) {
event.preventDefault();
const availableSlots = RANDOMIZER_LIMITS.wheelEntriesMax - data.entries.length;
const labels = entriesText
.split(/\r?\n/)
.map((label) => label.trim())
.filter(Boolean)
.slice(0, availableSlots);
if (!labels.length) return;
onChange({
...data,
entries: [
...data.entries,
...labels.map((label) => ({ id: context.uid("randomizer"), label, weight: 1 }))
]
});
setEntriesText("");
}
function updateEntry(entryId, patch) {
onChange({ ...data, entries: data.entries.map((entry) => entry.id === entryId ? { ...entry, ...patch } : entry) });
}
function removeEntry(entryId) {
onChange({ ...data, entries: data.entries.filter((entry) => entry.id !== entryId) });
}
function clearEntries() {
onChange({ ...data, entries: [], results: [] });
}
function clearWheelHistory() {
onChange({ ...data, history: [] });
}
function removeWheelHistoryEntry(entryId) {
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
}
function launchWheel() {
clearWheelTimers();
skipWheelSyncRef.current = false;
const drawnEntries = drawWeightedEntries(data.entries, data.draws);
if (!drawnEntries.length) {
context.notify?.(textContent.wheelInvalid || "Ajoutez au moins deux valeurs.");
return;
}
const results = drawnEntries.map((entry) => entry.label);
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
if (hasReducedMotion()) {
setDisplayResults(results);
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
return;
}
setDrawing(true);
setDisplayResults([]);
let rotationCursor = rotation;
function animateDraw(index) {
const drawnEntry = drawnEntries[index];
const winnerSegment = segments.find((segment) => segment.entry.id === drawnEntry.id) || segments[0];
rotationCursor = getWheelTargetRotation(rotationCursor, winnerSegment.midAngle);
setSpinning(true);
setRotation(rotationCursor);
queueTimer(() => {
setDisplayResults((current) => [...current, drawnEntry.label]);
setSpinning(false);
if (index < drawnEntries.length - 1) {
queueTimer(() => animateDraw(index + 1), DRAW_PAUSE_MS);
return;
}
setDrawing(false);
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
}, WHEEL_SPIN_MS);
}
animateDraw(0);
}
return (
<section className="randomizer-panel randomizer-wheel-panel">
<div className="randomizer-wheel-stage" aria-live="polite">
<span className="randomizer-wheel-spacer" aria-hidden="true" />
<div className="randomizer-wheel-center">
<span className="randomizer-wheel-pointer" aria-hidden="true" />
<svg className={`randomizer-wheel ${spinning ? "is-spinning" : ""}`} viewBox="0 0 100 100" style={{ "--randomizer-wheel-rotation": `${rotation}deg` }}>
{segments.length ? segments.map((segment) => (
<path key={segment.entry.id} d={describeWheelArc(segment.startAngle, segment.endAngle)} fill={WHEEL_COLORS[segment.index % WHEEL_COLORS.length]} />
)) : <circle cx="50" cy="50" r="48" fill="rgba(139, 92, 246, 0.18)" />}
<circle cx="50" cy="50" r="12" fill="rgba(7, 10, 24, 0.92)" />
</svg>
</div>
<div className="randomizer-wheel-legend nds-scrollbox" data-axis="vertical" aria-label={textContent.valuesTitle || "Valeurs"}>
{segments.length ? segments.map((segment) => (
<div className="randomizer-wheel-legend-row" key={segment.entry.id}>
<span className="randomizer-wheel-swatch" style={{ "--randomizer-swatch": WHEEL_COLORS[segment.index % WHEEL_COLORS.length] }} aria-hidden="true" />
<span className="randomizer-wheel-label">{segment.entry.label}</span>
</div>
)) : <p className="randomizer-empty">{textContent.wheelInvalid || "Ajoutez au moins deux valeurs."}</p>}
</div>
</div>
<div className="randomizer-live-results randomizer-wheel-results" aria-live="polite">
<ResultChips results={displayResults} />
</div>
<div className="randomizer-wheel-grid">
<section className="randomizer-wheel-form">
<form className="randomizer-entry-form" onSubmit={addEntries}>
<label className="randomizer-field">
<span>{textContent.valuesImportLabel || "Liste de valeurs"}</span>
<textarea className="nds-textarea" data-size="sm" value={entriesText} onChange={(event) => setEntriesText(event.target.value)} placeholder={textContent.valuesImportPlaceholder || "Une valeur par ligne"} />
</label>
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.addButton || "Ajouter"}</button>
</form>
<div className="randomizer-controls">
<label>
<span>{textContent.drawsLabel || "Tirages"}</span>
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.draws} onChange={(event) => onChange({ ...data, draws: clampNumber(event.target.value, 1, 100, data.draws) })} />
</label>
<button className="nds-button" data-size="sm" data-variant={drawing ? "danger" : "primary"} type="button" onClick={drawing ? stopWheel : launchWheel}>
<Icon name={drawing ? "stop-circle" : "play-circle"} />
<span>{drawing ? textContent.stopButton || "Arrêter" : textContent.drawButton || "Tirer"}</span>
</button>
</div>
</section>
<section className="randomizer-wheel-values-section">
<div className="randomizer-list-header randomizer-value-row randomizer-wheel-value-row" aria-hidden="true">
<span>{textContent.savedValuesLabel || "Valeurs enregistrées"}</span>
<span>{textContent.weightLabel || "Poids"}</span>
<span />
</div>
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost" aria-label={textContent.valuesTitle || "Valeurs"}>
{data.entries.map((entry) => (
<div className="randomizer-value-row randomizer-wheel-value-row" key={entry.id}>
<input className="nds-input" data-size="sm" value={entry.label} onChange={(event) => updateEntry(entry.id, { label: event.target.value })} aria-label={textContent.valuePlaceholder || "Valeur"} />
<input className="nds-input" data-size="sm" type="number" min="1" max="999" value={entry.weight} onChange={(event) => updateEntry(entry.id, { weight: clampNumber(event.target.value, 1, 999, entry.weight) })} aria-label={`${textContent.weightLabel || "Poids"} ${entry.label}`} />
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeEntry(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
</div>
<button className="nds-button" data-size="sm" type="button" onClick={clearEntries} disabled={!data.entries.length || drawing}>{textContent.clearValuesButton || "Supprimer toutes les valeurs"}</button>
</section>
<RandomizerHistory
title={textContent.historyTitle || "Historique"}
history={data.history}
renderEntry={(entry) => entry.results.join(" - ")}
onClear={clearWheelHistory}
onRemove={removeWheelHistoryEntry}
disabled={drawing}
textContent={textContent}
showEmpty
/>
</div>
</section>
);
}
function TeamsRandomizer({ data, textContent, onChange, context }) {
const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT);
const [animating, setAnimating] = useState(false);
const [animationTeams, setAnimationTeams] = useState([]);
const [reelPlayers, setReelPlayers] = useState([]);
const [reelIndex, setReelIndex] = useState(0);
const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]);
const [namesText, setNamesText] = useState("");
const timersRef = useRef([]);
const activePlayers = data.players.filter((player) => player.active);
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
const displayedTeams = animating ? animationTeams : data.teams;
const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group";
useEffect(() => {
setSelectionCountDraft(String(storedSelectionCount));
}, [storedSelectionCount]);
useEffect(() => () => {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}, []);
function queueTimer(callback, delay) {
const timerId = window.setTimeout(callback, delay);
timersRef.current.push(timerId);
return timerId;
}
function clearTeamTimers() {
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
timersRef.current = [];
}
function commitSelectionCount(value = selectionCountDraft) {
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
setSelectionCountDraft(String(nextSelectionCount));
onChange({ ...data, playerSelectionCount: nextSelectionCount });
}
function importPlayers(event) {
event.preventDefault();
const availableSlots = RANDOMIZER_LIMITS.teamPlayersMax - data.players.length;
const names = namesText
.split(/\r?\n/)
.map((name) => name.trim())
.filter(Boolean)
.slice(0, availableSlots);
if (!names.length) return;
onChange({
...data,
players: [
...data.players,
...names.map((name) => ({ id: context.uid("randomizer"), name, score: 0, active: true }))
]
});
setNamesText("");
}
function updatePlayer(playerId, patch) {
onChange({ ...data, players: data.players.map((player) => player.id === playerId ? { ...player, ...patch } : player) });
}
function removePlayer(playerId) {
onChange({
...data,
players: data.players.filter((player) => player.id !== playerId),
exclusions: data.exclusions
.filter((exclusion) => exclusion.playerAId !== playerId)
.map((exclusion) => ({ ...exclusion, playerBIds: (exclusion.playerBIds || []).filter((targetId) => targetId !== playerId) }))
.filter((exclusion) => exclusion.playerBIds.length)
});
}
function clearPlayers() {
onChange({ ...data, players: [], exclusions: [], teams: [] });
}
function toggleExclusionTarget(playerId) {
const currentIds = exclusionDraft.values.playerBIds || [];
exclusionDraft.setField(
"playerBIds",
currentIds.includes(playerId) ? currentIds.filter((targetId) => targetId !== playerId) : [...currentIds, playerId]
);
}
function addExclusion(event) {
exclusionDraft.handleSubmit(event, (draft, { reset }) => {
const mode = draft.mode === "source" ? "source" : "group";
const selectedIds = [...new Set(Array.isArray(draft.playerBIds) ? draft.playerBIds.filter(Boolean) : [])];
const playerAId = mode === "group" ? selectedIds[0] : draft.playerAId;
const playerBIds = mode === "group" ? selectedIds.slice(1) : selectedIds.filter((playerId) => playerId !== draft.playerAId);
if (!playerAId || !playerBIds.length) return;
const exists = data.exclusions.some((exclusion) => exclusion.playerAId === playerAId && (exclusion.mode || "source") === mode && [...(exclusion.playerBIds || [])].sort().join(":") === [...playerBIds].sort().join(":"));
if (exists) return;
onChange({ ...data, exclusions: [...data.exclusions, { id: context.uid("randomizer"), playerAId, playerBIds, mode }] });
reset();
});
}
function launchTeams() {
clearTeamTimers();
const selectedPlayers = data.playerSelectionMode === "count" ? pickRandomItems(activePlayers, Math.min(selectionCount, activePlayers.length)) : activePlayers;
const selectedExclusions = filterExclusionsForPlayers(data.exclusions, selectedPlayers);
const result = createBalancedTeams(selectedPlayers, data.teamCount, selectedExclusions);
if (!result.ok) {
context.notify?.(result.error === "not_enough_players" ? textContent.notEnoughPlayers || "Pas assez de joueurs actifs." : textContent.constraintsImpossible || "Contraintes impossibles à satisfaire.");
return;
}
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), teams: result.teams };
if (hasReducedMotion()) {
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
return;
}
const emptyTeams = result.teams.map((team) => ({ ...team, score: 0, players: [] }));
const assignments = result.teams.flatMap((team) => team.players.map((player) => ({ teamId: team.id, player })));
setAnimating(true);
setAnimationTeams(emptyTeams);
setReelPlayers(selectedPlayers);
setReelIndex(0);
setReelDuration(TEAM_REEL_DELAYS[0]);
function animateAssignment(index) {
const assignment = assignments[index];
if (!assignment) {
setAnimating(false);
setReelPlayers([]);
setReelIndex(0);
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
return;
}
let ticks = 0;
const tick = () => {
ticks += 1;
const isFinalTick = ticks >= TEAM_REEL_DELAYS.length;
const tickDelay = TEAM_REEL_DELAYS[Math.min(ticks, TEAM_REEL_DELAYS.length - 1)];
const nextIndex = isFinalTick
? Math.max(0, selectedPlayers.findIndex((player) => player.id === assignment.player.id))
: Math.floor(Math.random() * selectedPlayers.length);
setReelIndex(nextIndex);
setReelDuration(Math.max(54, Math.round(tickDelay * 0.86)));
if (!isFinalTick) {
queueTimer(tick, tickDelay);
return;
}
queueTimer(() => {
setAnimationTeams((currentTeams) => currentTeams.map((team) => {
if (team.id !== assignment.teamId) return team;
return {
...team,
score: team.score + assignment.player.score,
players: [...team.players, { ...assignment.player, arriving: true }]
};
}));
queueTimer(() => animateAssignment(index + 1), 260);
}, 220);
};
tick();
}
animateAssignment(0);
}
function clearTeamsHistory() {
onChange({ ...data, history: [] });
}
function removeTeamsHistoryEntry(entryId) {
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
}
return (
<section className="randomizer-panel randomizer-teams-panel">
<div className="randomizer-team-stage">
<div className={`randomizer-team-reel ${animating ? "is-spinning" : ""}`} aria-live="polite">
{reelPlayers.length ? (
<>
<div className="randomizer-team-reel-track" style={{ "--randomizer-reel-index": reelIndex, "--randomizer-reel-duration": `${reelDuration}ms` }} aria-hidden="true">
{reelPlayers.map((player) => <span key={player.id}>{player.name}</span>)}
</div>
<span className="sr-only">{reelPlayers[reelIndex]?.name || ""}</span>
</>
) : <span className="randomizer-team-reel-placeholder" />}
</div>
<div className={`randomizer-teams ${displayedTeams.length === 1 ? "is-single-team" : ""} ${animating ? "is-animating" : ""}`} aria-live="polite">
{displayedTeams.length ? displayedTeams.map((team) => (
<article className="randomizer-team" key={team.id}>
<h3>{team.name}</h3>
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
{team.players.map((player) => <b className={player.arriving ? "is-arriving" : ""} key={player.id}>{player.name}</b>)}
</article>
)) : <p className="randomizer-empty">{textContent.emptyTeams ?? ""}</p>}
</div>
</div>
<div className="randomizer-team-settings-grid">
<section className="randomizer-team-basic">
<form className="randomizer-player-import-form" onSubmit={importPlayers}>
<label className="randomizer-field">
<span>{textContent.namesImportLabel || "Liste de noms"}</span>
<textarea className="nds-textarea" data-size="sm" value={namesText} onChange={(event) => setNamesText(event.target.value)} placeholder={textContent.namesImportPlaceholder || "Un nom par ligne"} />
</label>
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.importNamesButton || "Ajouter"}</button>
</form>
<div className="randomizer-controls">
<label>
<span>{textContent.teamCountLabel || "Équipes"}</span>
<input className="nds-input" data-size="sm" type="number" min={RANDOMIZER_LIMITS.teamCountMin} max={RANDOMIZER_LIMITS.teamCountMax} value={data.teamCount} onChange={(event) => onChange({ ...data, teamCount: clampNumber(event.target.value, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, data.teamCount) })} />
</label>
<label>
<span>{textContent.playerSelectionModeLabel || "Répartition"}</span>
<select className="nds-select" data-size="sm" value={data.playerSelectionMode} onChange={(event) => onChange({ ...data, playerSelectionMode: event.target.value })}>
<option value="all">{textContent.playerSelectionAll || "Tous"}</option>
<option value="count">{textContent.playerSelectionCount || "Nombre défini"}</option>
</select>
</label>
{data.playerSelectionMode === "count" && (
<label>
<span>{textContent.playerSelectionCountLabel || "Nombre"}</span>
<input className="nds-input" data-size="sm" type="number" min={data.teamCount} max={RANDOMIZER_LIMITS.teamPlayersMax} value={selectionCountDraft} onChange={(event) => setSelectionCountDraft(event.target.value)} onBlur={(event) => commitSelectionCount(event.target.value)} />
</label>
)}
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={launchTeams} disabled={animating}>
<Icon name="play-circle" />
<span>{textContent.createTeamsButton || "Créer les équipes"}</span>
</button>
</div>
</section>
<div className="randomizer-team-advanced">
<section className="randomizer-team-advanced-section">
<div className="randomizer-list-header randomizer-value-row randomizer-player-row" aria-hidden="true">
<span>{textContent.activeLabel || "Actif"}</span>
<span>{textContent.nameLabel || "Nom"}</span>
<span>{textContent.scoreLabel || "Score"}</span>
<span />
</div>
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost">
{data.players.map((player) => (
<div className="randomizer-value-row randomizer-player-row" key={player.id}>
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={player.active} onChange={(event) => updatePlayer(player.id, { active: event.target.checked })} aria-label={`${textContent.activeLabel || "Actif"} ${player.name}`} />
<input className="nds-input" data-size="sm" value={player.name} onChange={(event) => updatePlayer(player.id, { name: event.target.value })} aria-label={textContent.nameLabel || "Nom"} />
<input className="nds-input" data-size="sm" type="number" min="0" max="9999" value={player.score} onChange={(event) => updatePlayer(player.id, { score: clampNumber(event.target.value, 0, 9999, player.score) })} aria-label={textContent.scoreLabel || "Score"} />
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removePlayer(player.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${player.name}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
))}
</div>
<button className="nds-button" data-size="sm" type="button" onClick={clearPlayers} disabled={!data.players.length || animating}>{textContent.clearNamesButton || "Supprimer tous les noms"}</button>
</section>
<section className="randomizer-team-advanced-section randomizer-exclusion-block">
<form className={`inline-form randomizer-exclusion-form ${exclusionMode === "group" ? "is-group-mode" : ""}`} onSubmit={addExclusion}>
<label className="randomizer-field randomizer-exclusion-mode-field">
<span>{textContent.exclusionModeLabel || "Type"}</span>
<select className="nds-select" data-size="sm" value={exclusionMode} onChange={(event) => exclusionDraft.updateValues({ mode: event.target.value, playerAId: event.target.value === "group" ? "" : exclusionDraft.values.playerAId })} aria-label={textContent.exclusionModeLabel || "Type"}>
<option value="group">{textContent.exclusionModeGroup || "Tous séparés"}</option>
<option value="source">{textContent.exclusionModeSource || "Exclusions granulaires"}</option>
</select>
</label>
{exclusionMode === "source" && (
<label className="randomizer-field">
<span>{textContent.firstPlayerLabel || "Nom source"}</span>
<select className="nds-select" data-size="sm" value={exclusionDraft.values.playerAId} onChange={(event) => exclusionDraft.updateValues({ playerAId: event.target.value, playerBIds: (exclusionDraft.values.playerBIds || []).filter((playerId) => playerId !== event.target.value) })} aria-label={textContent.firstPlayerLabel || "Nom source"}>
<option value="">{textContent.selectNamesButton || "Choisir"}</option>
{activePlayers.map((player) => <option key={player.id} value={player.id}>{player.name}</option>)}
</select>
</label>
)}
<div className="randomizer-field">
<span>{textContent.secondPlayerLabel || "Noms exclus"}</span>
<CompactDropdown
className="randomizer-checkbox-dropdown"
menuClassName="randomizer-checkbox-menu nds-scrollbox"
label={textContent.secondPlayerLabel || "Noms exclus"}
renderTrigger={({ open, toggle }) => (
<button className={`randomizer-multi-trigger nds-select-trigger ${open ? "active" : ""}`} data-size="sm" type="button" onClick={toggle} aria-expanded={open}>
<span className="randomizer-multi-trigger-label">{exclusionDraft.values.playerBIds?.length ? `${exclusionDraft.values.playerBIds.length} ${textContent.selectedNamesLabel || "noms"}` : textContent.selectNamesButton || "Choisir"}</span>
</button>
)}
>
{() => (
<>
{activePlayers.map((player) => (
<label className="randomizer-checkbox-option" key={player.id}>
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={(exclusionDraft.values.playerBIds || []).includes(player.id)} disabled={exclusionMode === "source" && player.id === exclusionDraft.values.playerAId} onChange={() => toggleExclusionTarget(player.id)} />
<span>{player.name}</span>
</label>
))}
</>
)}
</CompactDropdown>
</div>
<button className="nds-button" data-size="sm" type="submit">{textContent.addExclusionButton || "Exclure"}</button>
</form>
<span className="randomizer-section-label">{textContent.exclusionsListLabel || "Combinaisons exclues"}</span>
<div className="randomizer-exclusions">
{data.exclusions.map((exclusion) => {
const playerA = data.players.find((player) => player.id === exclusion.playerAId);
const playerBs = (exclusion.playerBIds || []).map((playerId) => data.players.find((player) => player.id === playerId)).filter(Boolean);
if (!playerA || !playerBs.length) return null;
const exclusionLabel = exclusion.mode === "group"
? [playerA, ...playerBs].map((player) => player.name).join(" / ")
: `${playerA.name} / ${playerBs.map((player) => player.name).join(" ou ")}`;
return (
<span className="randomizer-chip" key={exclusion.id}>
<span className="randomizer-chip-label">{exclusionLabel}</span>
<button className="nds-icon-button" data-size="xxs" data-variant="danger" type="button" onClick={() => onChange({ ...data, exclusions: data.exclusions.filter((item) => item.id !== exclusion.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${playerA.name}`}>
<Icon name="close" />
</button>
</span>
);
})}
</div>
</section>
</div>
</div>
<RandomizerHistory
title={textContent.historyTitle || "Historique"}
history={data.history}
renderEntry={(entry) => `${entry.teams.length} ${entry.teams.length > 1 ? textContent.teamsPluralLabel || "équipes" : textContent.teamsSingularLabel || "équipe"}`}
renderDetails={(entry) => (
<div className={`randomizer-history-teams ${entry.teams.length > 4 ? "has-team-rows" : ""}`}>
{entry.teams.map((team) => (
<div className="randomizer-history-team" key={team.id}>
<strong>{team.name}</strong>
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
<ul>
{team.players.map((player) => <li key={player.id}>{player.name}</li>)}
</ul>
</div>
))}
</div>
)}
onClear={clearTeamsHistory}
onRemove={removeTeamsHistoryEntry}
disabled={animating}
textContent={textContent}
showEmpty
/>
</section>
);
}
function ResultChips({ results, onRemove, disabled = false, textContent = {} }) {
if (!results?.length) return null;
return (
<div className="randomizer-results">
{results.map((result, index) => (
<span className="randomizer-chip" key={`${result}-${index}`}>
{result}
{onRemove && (
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => onRemove(index)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${result}`}>
<Icon name="close" />
</button>
)}
</span>
))}
</div>
);
}
function RandomizerHistory({ title, history, renderEntry, renderDetails, onClear, onRemove, disabled = false, textContent = {}, showEmpty = false }) {
const hasHistory = Boolean(history?.length);
if (!hasHistory && !showEmpty) return null;
return (
<section className="randomizer-history nds-scrollbox" data-axis="vertical">
<header>
<h3>{title}</h3>
<button className="nds-button randomizer-history-clear" data-size="xs" type="button" onClick={onClear} disabled={disabled || !hasHistory}>{textContent.clearHistoryButton || "Effacer tout"}</button>
</header>
{hasHistory ? (
<div className="randomizer-list">
{history.map((entry, index) => (
<div className={`randomizer-history-row ${index === 0 ? "is-latest" : ""} ${onRemove ? "has-action" : ""}`} key={entry.id}>
{renderDetails ? (
<details className="randomizer-history-main randomizer-history-accordion">
<summary>
<Icon name="chevron-down" />
<span className="randomizer-history-value">{renderEntry(entry)}</span>
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
</summary>
{renderDetails(entry)}
</details>
) : (
<div className="randomizer-history-main">
<span className="randomizer-history-value">{renderEntry(entry)}</span>
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
</div>
)}
{onRemove && (
<button className="nds-icon-button" data-size="md" data-variant="danger" type="button" onClick={() => onRemove(entry.id)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${formatDateTime(entry.at)}`}>
<Icon name="trash" />
</button>
)}
</div>
))}
</div>
) : <p className="randomizer-empty">{textContent.emptyHistory || "Aucun historique."}</p>}
</section>
);
}

View file

@ -0,0 +1,182 @@
// Rôle : fournit les calculs de tirage et d'équilibrage pour l'outil Randomizer.
export const RANDOMIZER_TABS = [
{ id: "dice", label: "Dé", icon: "dice" },
{ id: "wheel", label: "Roue", icon: "wheel" },
{ id: "teams", label: "Équipes", icon: "parent" }
];
export const RANDOMIZER_HISTORY_LIMIT = 20;
export const RANDOMIZER_EXCLUSION_MODES = new Set(["source", "group"]);
export const RANDOMIZER_LIMITS = {
diceFacesMin: 2,
diceFacesMax: 999,
diceRollsMin: 1,
diceRollsMax: 100,
wheelEntriesMax: 60,
wheelWeightMin: 1,
wheelWeightMax: 999,
teamCountMin: 1,
teamCountMax: 12,
teamPlayersMax: 200,
teamScoreMin: 0,
teamScoreMax: 9999
};
export function clampNumber(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
export function getDiceShape(faces) {
if (faces === 2) return "circle";
if (faces === 3) return "triangle";
if (faces >= 7) return "hexagon";
return "square";
}
export function rollDice(faces, rolls, random = Math.random) {
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
const cleanRolls = clampNumber(rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
return Array.from({ length: cleanRolls }, () => Math.floor(random() * cleanFaces) + 1);
}
export function getValidWheelEntries(entries) {
return (Array.isArray(entries) ? entries : [])
.map((entry) => ({
...entry,
label: String(entry?.label || "").trim(),
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
}))
.filter((entry) => entry.label)
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
}
export function drawWeightedEntries(entries, draws, random = Math.random) {
const validEntries = getValidWheelEntries(entries);
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
const cleanDraws = clampNumber(draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
if (validEntries.length < 2 || totalWeight <= 0) return [];
return Array.from({ length: cleanDraws }, () => {
let cursor = random() * totalWeight;
return validEntries.find((entry) => {
cursor -= entry.weight;
return cursor < 0;
}) || validEntries.at(-1);
});
}
export function buildWheelSegments(entries) {
const validEntries = getValidWheelEntries(entries);
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
let startAngle = -90;
if (!totalWeight) return [];
return validEntries.map((entry, index) => {
const angle = (entry.weight / totalWeight) * 360;
const segment = {
entry,
index,
startAngle,
endAngle: startAngle + angle,
midAngle: startAngle + angle / 2
};
startAngle += angle;
return segment;
});
}
function shuffleItems(items, random) {
const nextItems = [...items];
for (let index = nextItems.length - 1; index > 0; index -= 1) {
const targetIndex = Math.floor(random() * (index + 1));
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
}
return nextItems;
}
function getExcludedPlayerIds(playerId, exclusions) {
return new Set((exclusions || []).flatMap((exclusion) => {
const playerBIds = Array.isArray(exclusion.playerBIds) ? exclusion.playerBIds : [exclusion.playerBId];
if (exclusion.mode === "group") {
const groupIds = [exclusion.playerAId, ...playerBIds].filter(Boolean);
return groupIds.includes(playerId) ? groupIds.filter((groupId) => groupId !== playerId) : [];
}
if (exclusion.playerAId === playerId) return playerBIds;
if (playerBIds.includes(playerId)) return [exclusion.playerAId];
return [];
}));
}
function canAddPlayerToTeam(player, team, exclusions) {
const excludedIds = getExcludedPlayerIds(player.id, exclusions);
return !team.players.some((teamPlayer) => excludedIds.has(teamPlayer.id));
}
export function createBalancedTeams(players, teamCount, exclusions, random = Math.random, attempts = 120) {
const activePlayers = (Array.isArray(players) ? players : [])
.filter((player) => player?.active !== false && String(player?.name || "").trim())
.map((player) => ({
...player,
name: String(player.name).trim(),
score: clampNumber(player.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
}))
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
const cleanTeamCount = clampNumber(teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2);
if (activePlayers.length < cleanTeamCount) return { ok: false, teams: [], error: "not_enough_players" };
let bestTeams = null;
let bestSpread = Number.POSITIVE_INFINITY;
for (let attempt = 0; attempt < attempts; attempt += 1) {
const teams = Array.from({ length: cleanTeamCount }, (_, index) => ({ id: `team-${index + 1}`, name: `Équipe ${index + 1}`, score: 0, players: [] }));
const orderedPlayers = shuffleItems(activePlayers, random).sort((a, b) => b.score - a.score || random() - 0.5);
let failed = false;
orderedPlayers.forEach((player) => {
if (failed) return;
const candidates = teams
.filter((team) => canAddPlayerToTeam(player, team, exclusions))
.sort((a, b) => a.score - b.score || a.players.length - b.players.length || random() - 0.5);
const targetTeam = candidates[0];
if (!targetTeam) {
failed = true;
return;
}
targetTeam.players.push(player);
targetTeam.score += player.score;
});
if (!failed) {
const scores = teams.map((team) => team.score);
const sizes = teams.map((team) => team.players.length);
const spread = Math.max(...scores) - Math.min(...scores) + (Math.max(...sizes) - Math.min(...sizes)) * 10000;
if (spread < bestSpread) {
bestTeams = teams;
bestSpread = spread;
}
if (spread === 0) break;
}
}
return bestTeams ? { ok: true, teams: bestTeams, error: "" } : { ok: false, teams: [], error: "constraints_impossible" };
}
export function filterExclusionsForPlayers(exclusions, players) {
const playerIds = new Set((Array.isArray(players) ? players : []).map((player) => player.id));
return (Array.isArray(exclusions) ? exclusions : [])
.map((exclusion) => {
if (exclusion?.mode === "group") {
const groupIds = [exclusion.playerAId, ...(exclusion.playerBIds || [])].filter((playerId) => playerIds.has(playerId));
return groupIds.length > 1 ? { ...exclusion, playerAId: groupIds[0], playerBIds: groupIds.slice(1) } : null;
}
if (!playerIds.has(exclusion?.playerAId)) return null;
const playerBIds = (exclusion.playerBIds || [exclusion.playerBId]).filter((playerId) => playerIds.has(playerId));
return playerBIds.length ? { ...exclusion, playerBIds } : null;
})
.filter(Boolean);
}
export function addRandomizerHistory(history, entry) {
return [entry, ...(Array.isArray(history) ? history : [])].slice(0, RANDOMIZER_HISTORY_LIMIT);
}

View file

@ -19,6 +19,7 @@ import { exportCombosText, importCombosText } from "./textImport/combos.js";
import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textImport/equipment.js";
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
import { fail, parseColonImportLines } from "./textImport/shared.js";
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
@ -50,6 +51,11 @@ const TEXT_EXCHANGE_HANDLERS = {
exportText: exportCountersText,
importText: importCountersText
},
randomizer: {
hasContent: (data) => Boolean(data?.dice?.history?.length || data?.wheel?.entries?.length || data?.teams?.players?.length),
exportText: exportRandomizerText,
importText: importRandomizerText
},
combos: {
hasContent: (data) => Boolean(data?.combos?.length),
exportText: exportCombosText,

View file

@ -0,0 +1,99 @@
// Rôle : gère l'import/export texte des configurations Randomizer.
import { lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
function findPlayerIdByName(players, name) {
const cleanName = String(name || "").trim().toLowerCase();
return players.find((player) => player.name.toLowerCase() === cleanName)?.id || "";
}
export function exportRandomizerText(data) {
const lines = [`@activeTab: ${data.activeTab || "dice"}`];
lines.push("[dice]", `faces: ${data.dice?.faces || 6}`, `rolls: ${data.dice?.rolls || 1}`);
lines.push("", "[wheel]", `draws: ${data.wheel?.draws || 1}`);
(data.wheel?.entries || []).forEach((entry) => {
lines.push(`- ${entry.label} | weight=${entry.weight || 1}`);
});
lines.push(
"",
"[teams]",
`teamCount: ${data.teams?.teamCount || 2}`,
`playerSelectionMode: ${data.teams?.playerSelectionMode || "all"}`,
`playerSelectionCount: ${data.teams?.playerSelectionCount || 2}`
);
(data.teams?.players || []).forEach((player) => {
lines.push(`- ${player.name} | score=${player.score || 0}${player.active === false ? " | active=false" : ""}`);
});
(data.teams?.exclusions || []).forEach((exclusion) => {
const playerA = data.teams.players.find((player) => player.id === exclusion.playerAId);
const playerBs = (exclusion.playerBIds || [exclusion.playerBId])
.map((playerId) => data.teams.players.find((player) => player.id === playerId))
.filter(Boolean);
if (playerA && playerBs.length) lines.push(`! ${[playerA.name, ...playerBs.map((player) => player.name)].join(" / ")}${exclusion.mode === "group" ? " | mode=group" : ""}`);
});
return lines.join("\n").trim();
}
export function importRandomizerText(text, context) {
const data = {
activeTab: "dice",
dice: { faces: 6, rolls: 1 },
wheel: { draws: 1, entries: [] },
teams: { teamCount: 2, playerSelectionMode: "all", playerSelectionCount: 2, players: [], exclusions: [] }
};
let section = "";
splitLines(text).forEach((rawLine, index) => {
const cleanLine = rawLine.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta?.key === "activeTab") {
data.activeTab = meta.value;
return;
}
if (/^\[[a-z]+\]$/i.test(cleanLine)) {
section = cleanLine.slice(1, -1).toLowerCase();
return;
}
const separatorIndex = cleanLine.indexOf(":");
if (separatorIndex > 0 && !cleanLine.startsWith("-") && !cleanLine.startsWith("!")) {
const key = cleanLine.slice(0, separatorIndex).trim();
const value = cleanLine.slice(separatorIndex + 1).trim();
if (section === "dice" && key === "faces") data.dice.faces = parseInteger(value, 6);
if (section === "dice" && key === "rolls") data.dice.rolls = parseInteger(value, 1);
if (section === "wheel" && key === "draws") data.wheel.draws = parseInteger(value, 1);
if (section === "teams" && key === "teamCount") data.teams.teamCount = parseInteger(value, 2);
if (section === "teams" && key === "playerSelectionMode") data.teams.playerSelectionMode = value;
if (section === "teams" && key === "playerSelectionCount") data.teams.playerSelectionCount = parseInteger(value, 2);
return;
}
if (section === "wheel" && cleanLine.startsWith("-")) {
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
if (!title) throw lineError(index, "valeur de roue manquante.");
data.wheel.entries.push({ id: context.uid("randomizer"), label: title, weight: parseInteger(pipeMeta.weight, 1) });
return;
}
if (section === "teams" && cleanLine.startsWith("-")) {
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
if (!title) throw lineError(index, "joueur manquant.");
data.teams.players.push({
id: context.uid("randomizer"),
name: title,
score: parseInteger(pipeMeta.score, 0),
active: pipeMeta.active !== "false"
});
return;
}
if (section === "teams" && cleanLine.startsWith("!")) {
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^!\s*/, ""));
const names = title.split("/").map((name) => name.trim()).filter(Boolean);
const playerAId = findPlayerIdByName(data.teams.players, names[0]);
const playerBIds = names.slice(1).map((name) => findPlayerIdByName(data.teams.players, name)).filter(Boolean);
if (!playerAId || !playerBIds.length) throw lineError(index, "exclusion avec joueur inconnu.");
data.teams.exclusions.push({ id: context.uid("randomizer"), playerAId, playerBIds, mode: pipeMeta.mode === "group" ? "group" : "source" });
return;
}
throw lineError(index, `ligne randomizer invalide: ${cleanLine}`);
});
return context.normalizeRandomizerData(data);
}

View file

@ -10,6 +10,7 @@ import { ImagesModule } from "./ImagesModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { MemosModule } from "./MemosModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { RandomizerModule } from "./RandomizerModule.jsx";
import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
@ -26,6 +27,7 @@ export const MODULE_COMPONENTS = {
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },

View file

@ -10,6 +10,7 @@ import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } fro
import { compactLinksDataForStorage } from "./links.js";
import { compactMemosDataForStorage } from "./memos.js";
import { compactNotepadDataForStorage } from "./notepad.js";
import { compactRandomizerDataForStorage } from "./randomizer.js";
import { compactTableDataForStorage } from "./table.js";
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
import { compactTimerDataForStorage } from "./timer.js";
@ -22,6 +23,7 @@ export const MODULE_DATA_COMPACTORS = {
imageAnnotation: compactImageAnnotationDataForStorage,
links: compactLinksDataForStorage,
counters: compactCountersDataForStorage,
randomizer: compactRandomizerDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
calendar: compactCalendarDataForStorage,

View file

@ -0,0 +1,172 @@
// Rôle : normalise et compacte les configurations de l'outil Randomizer.
import { RANDOMIZER_EXCLUSION_MODES, RANDOMIZER_LIMITS, clampNumber } from "../../modules/randomizerUtils.js";
import { normalizeIsoDate, uid } from "./shared.js";
const TAB_IDS = new Set(["dice", "wheel", "teams"]);
const TEAM_SELECTION_MODES = new Set(["all", "count"]);
function normalizeHistoryEntry(entry, type) {
const at = normalizeIsoDate(entry?.at);
if (!at) return null;
if (type === "dice") {
const results = (Array.isArray(entry?.results) ? entry.results : [])
.map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 0))
.filter((result) => result > 0);
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
}
if (type === "wheel") {
const results = (Array.isArray(entry?.results) ? entry.results : [])
.map((result) => String(result || "").trim())
.filter(Boolean);
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
}
const teams = normalizeTeamResults(entry?.teams);
return teams.length ? { id: entry?.id || uid("randomizer"), at, teams } : null;
}
function normalizeHistory(history, type) {
return (Array.isArray(history) ? history : [])
.map((entry) => normalizeHistoryEntry(entry, type))
.filter(Boolean)
.slice(0, 20);
}
function normalizeDice(data) {
return {
faces: clampNumber(data?.faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6),
rolls: clampNumber(data?.rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
results: (Array.isArray(data?.results) ? data.results : []).map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 1)),
history: normalizeHistory(data?.history, "dice")
};
}
function normalizeWheelEntry(entry) {
const label = String(entry?.label || "").trim();
if (!label) return null;
return {
id: entry?.id || uid("randomizer"),
label,
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
};
}
function normalizeWheel(data) {
const entries = (Array.isArray(data?.entries) ? data.entries : [])
.map(normalizeWheelEntry)
.filter(Boolean)
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
return {
draws: clampNumber(data?.draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
entries,
results: (Array.isArray(data?.results) ? data.results : []).map((result) => String(result || "").trim()).filter(Boolean),
history: normalizeHistory(data?.history, "wheel")
};
}
function normalizePlayer(player) {
const name = String(player?.name || "").trim();
if (!name) return null;
return {
id: player?.id || uid("randomizer"),
name,
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0),
active: player?.active !== false
};
}
function normalizeExclusions(exclusions, players) {
const playerIds = new Set(players.map((player) => player.id));
const seenPairs = new Set();
const seenGroups = new Set();
return (Array.isArray(exclusions) ? exclusions : [])
.map((exclusion) => {
const playerAId = String(exclusion?.playerAId || "");
if (!playerIds.has(playerAId)) return null;
const mode = RANDOMIZER_EXCLUSION_MODES.has(exclusion?.mode) ? exclusion.mode : "source";
const playerBIds = (Array.isArray(exclusion?.playerBIds) ? exclusion.playerBIds : [exclusion?.playerBId])
.map((playerId) => String(playerId || ""))
.filter((playerId) => playerIds.has(playerId) && playerId !== playerAId);
let uniquePlayerBIds = [...new Set(playerBIds)];
if (mode === "group") {
const groupIds = [playerAId, ...uniquePlayerBIds];
const groupKey = [...groupIds].sort().join(":");
if (seenGroups.has(groupKey)) return null;
seenGroups.add(groupKey);
groupIds.forEach((sourceId, sourceIndex) => {
groupIds.slice(sourceIndex + 1).forEach((targetId) => {
seenPairs.add([sourceId, targetId].sort().join(":"));
});
});
} else {
uniquePlayerBIds = uniquePlayerBIds.filter((playerBId) => {
const pairKey = [playerAId, playerBId].sort().join(":");
if (seenPairs.has(pairKey)) return false;
seenPairs.add(pairKey);
return true;
});
}
if (!uniquePlayerBIds.length) return null;
return { id: exclusion?.id || uid("randomizer"), playerAId, playerBIds: uniquePlayerBIds, mode };
})
.filter(Boolean);
}
function normalizeTeamResults(teams) {
return (Array.isArray(teams) ? teams : [])
.map((team, index) => ({
id: team?.id || `team-${index + 1}`,
name: String(team?.name || `Équipe ${index + 1}`).trim() || `Équipe ${index + 1}`,
score: clampNumber(team?.score, 0, Number.MAX_SAFE_INTEGER, 0),
players: (Array.isArray(team?.players) ? team.players : [])
.map((player) => ({
id: player?.id || uid("randomizer"),
name: String(player?.name || "").trim(),
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
}))
.filter((player) => player.name)
}))
.filter((team) => team.players.length);
}
function normalizeTeams(data) {
const players = (Array.isArray(data?.players) ? data.players : [])
.map(normalizePlayer)
.filter(Boolean)
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
return {
teamCount: clampNumber(data?.teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2),
playerSelectionMode: TEAM_SELECTION_MODES.has(data?.playerSelectionMode) ? data.playerSelectionMode : "all",
playerSelectionCount: clampNumber(data?.playerSelectionCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamPlayersMax, 2),
players,
exclusions: normalizeExclusions(data?.exclusions, players),
teams: normalizeTeamResults(data?.teams),
history: normalizeHistory(data?.history, "teams")
};
}
export function normalizeRandomizerData(data) {
return {
activeTab: TAB_IDS.has(data?.activeTab) ? data.activeTab : "dice",
dice: normalizeDice(data?.dice),
wheel: normalizeWheel(data?.wheel),
teams: normalizeTeams(data?.teams)
};
}
export function compactRandomizerDataForStorage(value) {
const normalized = normalizeRandomizerData(value);
const compact = {};
if (normalized.activeTab !== "dice") compact.activeTab = normalized.activeTab;
if (normalized.dice.faces !== 6 || normalized.dice.rolls !== 1 || normalized.dice.results.length || normalized.dice.history.length) compact.dice = normalized.dice;
if (normalized.wheel.entries.length || normalized.wheel.draws !== 1 || normalized.wheel.results.length || normalized.wheel.history.length) compact.wheel = normalized.wheel;
if (
normalized.teams.players.length ||
normalized.teams.exclusions.length ||
normalized.teams.teamCount !== 2 ||
normalized.teams.playerSelectionMode !== "all" ||
normalized.teams.playerSelectionCount !== 2 ||
normalized.teams.teams.length ||
normalized.teams.history.length
) compact.teams = normalized.teams;
return Object.keys(compact).length ? compact : null;
}

View file

@ -24,7 +24,8 @@ export const ID_PREFIXES = {
material: "n",
socketItem: "j",
socketLink: "u",
socketBonus: "b"
socketBonus: "b",
randomizer: "x"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";

View file

@ -15,6 +15,7 @@ export { labelFromFileName, normalizeImageAnnotationData } from "./modules/image
export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js";
export { normalizeMemosData } from "./modules/memos.js";
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
export { normalizeRandomizerData } from "./modules/randomizer.js";
export { uid } from "./modules/shared.js";
export { normalizeTableData } from "./modules/table.js";
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
@ -216,6 +217,27 @@ function remapModuleDataForExport(type, data, nextId) {
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
}
if (type === "randomizer") {
const remapped = { ...compact };
if (compact.wheel?.entries) remapped.wheel = { ...compact.wheel, entries: compact.wheel.entries.map((entry) => ({ ...entry, id: nextId("randomizer") })) };
if (compact.teams?.players) {
const playerIdMap = new Map(compact.teams.players.map((player) => [player.id, nextId("randomizer")]));
remapped.teams = {
...compact.teams,
players: compact.teams.players.map((player) => ({ ...player, id: playerIdMap.get(player.id) })),
exclusions: (compact.teams.exclusions || []).map((exclusion) => ({
...exclusion,
id: nextId("randomizer"),
playerAId: playerIdMap.get(exclusion.playerAId),
playerBIds: (exclusion.playerBIds || [exclusion.playerBId])
.map((playerId) => playerIdMap.get(playerId))
.filter(Boolean)
})).filter((exclusion) => exclusion.playerAId && exclusion.playerBIds.length)
};
}
return remapped;
}
if (type === "memos") {
const remapped = {};
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;

View file

@ -55,6 +55,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
scrollable: true,
libraryCategory: "notesTracking"
},
randomizer: {
label: "Randomizer",
defaultTitle: "Randomizer",
mode: "Dé + roue + équipes",
icon: "dice",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},
combos: {
label: "Combos",
defaultTitle: "Combos",
@ -146,7 +156,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];

View file

@ -16,6 +16,7 @@ import {
normalizeLinksData,
normalizeMemosData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
@ -41,7 +42,8 @@ const MODULE_COMPONENTS = {
timer: lazy(() => import("../features/toolboxes/modules/TimerModule.jsx").then((module) => ({ default: module.TimerModule }))),
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
equipmentPlanner: lazy(() => import("../features/toolboxes/modules/EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule }))),
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule })))
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule }))),
randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule })))
};
function cloneData(value) {
@ -163,6 +165,7 @@ export function LibraryPage({ siteContent, actions }) {
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,

View file

@ -0,0 +1,21 @@
# Agent Instructions
Cette zone contient le style Sass de l'application.
## Référence visuelle
Avant de modifier le style, les composants UI, les layouts, les boutons, cards, panels, hovers, scrollbars ou notifications, lire :
- `../../../DESIGN_SYSTEM.md`
Après modification d'un pattern visuel, mettre `DESIGN_SYSTEM.md` à jour si le pattern réel change.
## Style
Privilégier :
- les tokens existants ;
- les classes de boutons existantes ;
- les patterns visuels déjà présents pour les cards, panels, badges, scrollbars et hovers.
Éviter les styles isolés qui ne réutilisent pas le thème.

View file

@ -1075,6 +1075,13 @@
color: var(--color-text-secondary);
}
.nds-scrollbox[data-variant="ghost"] {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.nds-scrollbox[data-axis="horizontal"] {
max-height: none;
overflow-x: auto;

View file

@ -24,6 +24,16 @@
-webkit-mask-image: url("/static/icons/calculator.svg");
}
.ui-icon-dice {
mask-image: url("/static/icons/dice.svg");
-webkit-mask-image: url("/static/icons/dice.svg");
}
.ui-icon-wheel {
mask-image: url("/static/icons/wheel.svg");
-webkit-mask-image: url("/static/icons/wheel.svg");
}
.ui-icon-clock {
mask-image: url("/static/icons/clock.svg");
-webkit-mask-image: url("/static/icons/clock.svg");

View file

@ -10,6 +10,7 @@
@use "equipment-planner";
@use "combos";
@use "counters";
@use "randomizer";
@use "calculator";
@use "goal-tree";
@use "timer";

View file

@ -244,6 +244,26 @@
-webkit-mask-image: url("/static/icons/cube.svg");
}
.module-icon-dice {
mask-image: url("/static/icons/dice.svg");
-webkit-mask-image: url("/static/icons/dice.svg");
}
.module-icon-wheel {
mask-image: url("/static/icons/wheel.svg");
-webkit-mask-image: url("/static/icons/wheel.svg");
}
.module-icon-parent {
mask-image: url("/static/icons/parent.svg");
-webkit-mask-image: url("/static/icons/parent.svg");
}
.module-icon-rotate {
mask-image: url("/static/icons/rotate.svg");
-webkit-mask-image: url("/static/icons/rotate.svg");
}
.module-icon-settings {
mask-image: url("/static/icons/settings.svg");
-webkit-mask-image: url("/static/icons/settings.svg");

File diff suppressed because it is too large Load diff