Split randomizer views and align NDS toolbox vocabulary
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-09-01 19:47:22 +02:00
parent 6b85e58765
commit fef7735a1d
57 changed files with 4932 additions and 4815 deletions

View file

@ -21,6 +21,7 @@ Lire le fichier d'instructions le plus proche du code modifié avant d'interveni
- Chaque nouveau fichier source ou test commence par un commentaire court `Rôle : ...` dans le style du langage. - Chaque nouveau fichier source ou test commence par un commentaire court `Rôle : ...` dans le style du langage.
- Ne pas lancer automatiquement un serveur sur `5174` si `5173` répond déjà. - Ne pas lancer automatiquement un serveur sur `5174` si `5173` répond déjà.
- Avant de terminer une modification significative, lancer `npm run check`. - Avant de terminer une modification significative, lancer `npm run check`.
- Quand l'utilisateur indique qu'il va push, traiter le message comme une demande implicite de pré-push check : relire le diff, lancer les validations pertinentes et fournir un message de commit. Ne pas exécuter `git commit`, `git push` ou `git sync` sauf demande explicite.
## Beads ## Beads

View file

@ -134,9 +134,9 @@ Caracteristiques :
- bordure gradient subtile dore/violet clair ; - bordure gradient subtile dore/violet clair ;
- `border-radius: var(--radius-xl)` ; - `border-radius: var(--radius-xl)` ;
- `box-shadow: var(--shadow-md)` ; - `box-shadow: var(--shadow-md)` ;
- pas de background inline sur les heroes de jeux. - les pages jeux peuvent fournir une image de hero via `--game-hero-image`.
Les pages jeux doivent heriter du hero commun. Si un jeu a une image, elle doit etre utilisee dans les cards ou les covers, pas comme remplacement complet du hero commun sauf decision explicite. Les pages jeux doivent heriter du hero commun. Une image de jeu peut enrichir le hero quand elle reste lisible avec l'overlay commun ; les cards et covers restent les emplacements prioritaires pour les images inspectables.
## 7. Cards ## 7. Cards
@ -152,6 +152,8 @@ Style courant :
La bordure haute/basse interne d'une cover doit rester doree quand elle separe une image du contenu. La bordure haute/basse interne d'une cover doit rester doree quand elle separe une image du contenu.
Les surfaces de cards reutilisent les mixins `card-surface`, `card-surface-hover` et `card-cover-divider` depuis `website/src/styles/design-system/_surfaces.scss`.
Eviter les cards dans des cards. Les sections de page doivent rester des layouts ou panneaux, pas des empilements de cartes decoratives. Eviter les cards dans des cards. Les sections de page doivent rester des layouts ou panneaux, pas des empilements de cartes decoratives.
## 8. Modules Toolbox ## 8. Modules Toolbox
@ -242,7 +244,7 @@ Regles UI :
- la copie et le collage d'entree utilisent les raccourcis clavier `Ctrl+C` et `Ctrl+V` ; - la copie et le collage d'entree utilisent les raccourcis clavier `Ctrl+C` et `Ctrl+V` ;
- le mini-switch repetable est actif par defaut ; une entree non repetable est supprimee quand son echeance de la semaine courante est terminee ; - le mini-switch repetable est actif par defaut ; une entree non repetable est supprimee quand son echeance de la semaine courante est terminee ;
- les alertes reutilisent le switch a 3 etats du timer : aucune, toolbox visible, globale site ; - les alertes reutilisent le switch a 3 etats du timer : aucune, toolbox visible, globale site ;
- le choix de couleur reprend le pattern de palette compacte du bloc-notes ; - le choix de couleur reprend le pattern de palette compacte du Bloc Note ;
- le choix initial s'affiche dans l'outil lui-meme ; la vue mensuelle affiche le nom du mois et des boutons mois precedent / courant / suivant ; - le choix initial s'affiche dans l'outil lui-meme ; la vue mensuelle affiche le nom du mois et des boutons mois precedent / courant / suivant ;
- en vue mensuelle, la règle mensuelle peut cibler le numero du jour ou le rang du jour de semaine via un switch 2 états compact, indépendamment du choix de répétition. - en vue mensuelle, la règle mensuelle peut cibler le numero du jour ou le rang du jour de semaine via un switch 2 états compact, indépendamment du choix de répétition.
- en vue mensuelle, les dates de debut et de fin utilisent un champ compact qui ouvre un mini-calendrier avec navigation ; - en vue mensuelle, les dates de debut et de fin utilisent un champ compact qui ouvre un mini-calendrier avec navigation ;
@ -312,7 +314,7 @@ Regles UI :
### Bloc Note Riche ### Bloc Note Riche
Le bloc note utilise une edition directe en `contentEditable`, sans mode preview separe. Le Bloc Note utilise une edition directe en `contentEditable`, sans mode preview separe.
Regles UI : Regles UI :
@ -329,19 +331,19 @@ Regles UI :
- placeholder integre dans la surface d'edition quand la note est vide ; - placeholder integre dans la surface d'edition quand la note est vide ;
- le HTML persiste doit rester nettoye via allowlist, jamais via HTML libre. - le HTML persiste doit rester nettoye via allowlist, jamais via HTML libre.
### Memos ### Mémos
L'outil Memos sert aux informations courtes type post-it. L'outil Mémos sert aux informations courtes type post-it.
Regles UI : Regles UI :
- cartes compactes, non imbriquees, avec accent couleur localise ; - cartes compactes, non imbriquees, avec accent couleur localise ;
- edition directe du texte court ; - edition directe du texte court ;
- zone de texte en hauteur automatique, sans resize manuel ni scrollbar interne ; - zone de texte en hauteur automatique, sans resize manuel ni scrollbar interne ;
- palette de couleurs compacte reprenant les swatches du bloc note ; - palette de couleurs compacte reprenant les swatches du Bloc Note ;
- la creation de memo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ; - 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 ; - réorganisation des cartes par poignée de drag & drop compacte ;
- bande deroulante visible uniquement quand elle est active et qu'au moins un memo existe ; elle affiche tous les memos. - bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos.
### Calque De Dessin ### Calque De Dessin
@ -679,7 +681,7 @@ Boutons icones :
- taille compacte : `34px`, icone `19px` ; - taille compacte : `34px`, icone `19px` ;
- taille dense `xs` : `30px`, icone `17px` ; - taille dense `xs` : `30px`, icone `17px` ;
- taille inline `xxs` : `26px`, icone `15px` ; - taille inline `xxs` : `26px`, icone `15px` ;
- tailles micro reservees aux noeuds denses : `24px`/`13px` et `22px`/`12px` ; - taille micro reservee aux noeuds denses : `22px`, icone `12px` ;
- fond compact sombre, rayon `--radius-md`, hover violet contenu ; - fond compact sombre, rayon `--radius-md`, hover violet contenu ;
- variant `primary` dore/violet pour les actions principales ; - variant `primary` dore/violet pour les actions principales ;
- variant `ghost` transparent pour les handles discrets ; - variant `ghost` transparent pour les handles discrets ;
@ -689,13 +691,13 @@ Boutons icones :
- variant de bordure `button-borderless` pour masquer toutes les bordures ; - variant de bordure `button-borderless` pour masquer toutes les bordures ;
- variant visuel `panel-toggle` pour les boutons icones accolés à un panneau, comme le bouton pages ; - variant visuel `panel-toggle` pour les boutons icones accolés à un panneau, comme le bouton pages ;
- etat toggler via `.active` ou `.is-active`, avec le mixin `temporary-toggle-active`. - etat toggler via `.active` ou `.is-active`, avec le mixin `temporary-toggle-active`.
- les barres d'outils compactes reutilisent la famille bouton icone ; `toolbar-button` reste un alias pratique pour les boutons courts du bloc-notes (`B`, `I`, `U`) et les boutons de dessin. - les barres d'outils compactes reutilisent la famille bouton icone ; `toolbar-button` reste un alias pratique pour les boutons courts du Bloc Note (`B`, `I`, `U`) et les boutons de dessin.
Boutons composes et toggles de bord : Boutons composes et toggles de bord :
- utiliser `button-edge` + `button-joined-border` pour les boutons groupés type ajout + chevron ; - utiliser `button-edge` + `button-joined-border` pour les boutons groupés type ajout + chevron ;
- utiliser `button-edge` pour la forme des toggles accoles au bord d'un module, et `icon-button-panel-toggle` pour leur rendu visuel quand ils reprennent le pattern du bouton `pages` ; - utiliser `button-edge` pour la forme des toggles accoles au bord d'un module, et `icon-button-panel-toggle` pour leur rendu visuel quand ils reprennent le pattern du bouton `pages` ;
- utiliser `list-row-button` pour les lignes selectionnables type pages du bloc-notes ; - utiliser `list-row-button` pour les lignes selectionnables type pages du Bloc Note ;
- utiliser `text-button-dashed` pour les actions d'ajout contextuelles encadrees en pointilles ; - utiliser `text-button-dashed` pour les actions d'ajout contextuelles encadrees en pointilles ;
- `split-button-start`, `split-button-end`, `edge-toggle` et `dashed-action-button` restent des alias de compatibilite internes, pas des familles visuelles separees ; - `split-button-start`, `split-button-end`, `edge-toggle` et `dashed-action-button` restent des alias de compatibilite internes, pas des familles visuelles separees ;
- les outils peuvent garder uniquement la position absolue, la largeur contextuelle et les icones ; - les outils peuvent garder uniquement la position absolue, la largeur contextuelle et les icones ;

View file

@ -16,7 +16,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans la page toolbox complète.
- Vérifier l'affichage dans le panneau latéral. - Vérifier l'affichage dans le panneau latéral.
- 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. - 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.
- Pour Memos, 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 le mode une colonne et deux colonnes. - Vérifier le mode une colonne et deux colonnes.
- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`. - Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`.
- Vérifier le quota de stockage si l'outil manipule des données lourdes. - Vérifier le quota de stockage si l'outil manipule des données lourdes.

View file

@ -128,7 +128,7 @@ Stockage compact :
- les dessins temporaires du bloc-note sont conservés dans `sessionStorage` par toolbox, outil et page, pas dans IndexedDB ; - les dessins temporaires du bloc-note sont conservés dans `sessionStorage` par toolbox, outil et page, pas dans IndexedDB ;
- si toutes les pages sont vides et que seule la page par défaut reste présente, l'entrée de module peut être supprimée. - si toutes les pages sont vides et que seule la page par défaut reste présente, l'entrée de module peut être supprimée.
## Outil Memos ## Outil Mémos
Type : `memos` Type : `memos`

View file

@ -1,13 +1,15 @@
// Rôle : vérifie en navigateur que le shell principal se charge pour les diagnostics visuels. // Rôle : vérifie en navigateur que la home charge les zones principales.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("home page renders the application shell", async ({ page }) => { test("home page renders the application shell", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.locator(".app-shell")).toBeVisible(); await expect(page.locator(".app-shell")).toBeVisible();
await expect(page.locator("main")).toBeVisible(); const main = page.getByRole("main");
await expect(page).toHaveScreenshot("home.png", { await expect(main).toBeVisible();
fullPage: true, await expect(main.getByRole("heading", { name: "Sokko G", exact: true })).toBeVisible();
maxDiffPixelRatio: 0.03 await expect(main.getByRole("link", { name: "Voir les toolboxes", exact: true })).toBeVisible();
}); await expect(main.getByRole("link", { name: "Voir les jeux", exact: true })).toBeVisible();
await expect(page.locator(".home-stats")).toBeVisible();
await expect(page.locator(".origin-section")).toBeVisible();
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -41,7 +41,7 @@ test("creates a toolbox and lazy-loads an added tool", async ({ page }) => {
await createToolbox(page, "E2E Toolbox"); await createToolbox(page, "E2E Toolbox");
await expect(page.getByRole("heading", { name: "E2E Toolbox" })).toBeVisible(); await expect(page.getByRole("heading", { name: "E2E Toolbox" })).toBeVisible();
await expect(page.locator("[data-module-type='notepad']")).toBeVisible(); await expect(page.locator("[data-module-type='memos']")).toBeVisible();
await expect(page.locator("[data-module-type='links']")).toBeVisible(); await expect(page.locator("[data-module-type='links']")).toBeVisible();
await addTool(page, "Arbre d'objectifs"); await addTool(page, "Arbre d'objectifs");
@ -102,6 +102,26 @@ test("creates a goal tree checklist with more than three visible lines", async (
await expect(previewRows.filter({ hasText: "Objectif ligne 5" })).toBeVisible(); await expect(previewRows.filter({ hasText: "Objectif ligne 5" })).toBeVisible();
}); });
test("loads randomizer tabs after module split", async ({ page }) => {
await createToolbox(page, "Randomizer E2E Toolbox");
await addTool(page, "Randomizer");
const randomizer = moduleByType(page, "randomizer");
await expect(randomizer).toBeVisible();
await expect(randomizer.getByRole("tab", { name: "Dé" })).toBeVisible();
await expect(randomizer.getByRole("button", { name: "Lancer" })).toBeVisible();
await randomizer.getByRole("tab", { name: "Roue" }).click();
await expect(randomizer.getByLabel("Liste de valeurs")).toBeVisible();
await randomizer.getByLabel("Liste de valeurs").fill("Rouge\nBleu");
await randomizer.getByRole("button", { name: "Ajouter" }).click();
await expect(randomizer.locator(".randomizer-wheel-value-row .nds-input").first()).toHaveValue("Rouge");
await randomizer.getByRole("tab", { name: "Équipes" }).click();
await expect(randomizer.getByLabel("Liste de noms")).toBeVisible();
await expect(randomizer.getByRole("button", { name: "Créer les équipes" })).toBeVisible();
});
test("creates a game-linked toolbox from the drawer", async ({ page }) => { test("creates a game-linked toolbox from the drawer", async ({ page }) => {
await page.goto("/#/games/mhwilds"); await page.goto("/#/games/mhwilds");
await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
@ -115,7 +135,8 @@ test("creates a game-linked toolbox from the drawer", async ({ page }) => {
await dialog.getByRole("button", { name: "Créer" }).click(); await dialog.getByRole("button", { name: "Créer" }).click();
await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
await expect(drawer.locator("[data-module-type='notepad']")).toBeVisible(); await expect(drawer.locator("[data-module-type='memos']")).toBeVisible();
await expect(drawer.locator("[data-module-type='links']")).toBeVisible();
await expect(page.getByRole("button", { name: /Ouvrir la toolbox Monster Hunter.*Wilds/ })).toBeVisible(); await expect(page.getByRole("button", { name: /Ouvrir la toolbox Monster Hunter.*Wilds/ })).toBeVisible();
}); });
@ -217,6 +238,7 @@ test("creates edits summarizes and deletes equipment", async ({ page }) => {
test("edits rich notepad content and persists it", async ({ page }) => { test("edits rich notepad content and persists it", async ({ page }) => {
await createToolbox(page, "Notepad E2E Toolbox"); await createToolbox(page, "Notepad E2E Toolbox");
await addTool(page, "Bloc Note");
const notepad = moduleByType(page, "notepad"); const notepad = moduleByType(page, "notepad");
await expect(notepad).toBeVisible(); await expect(notepad).toBeVisible();

View file

@ -19,6 +19,10 @@ test("site content json is complete", async () => {
test("library examples are available", async () => { test("library examples are available", async () => {
const library = JSON.parse(await readFile("website/public/data/library.json", "utf8")); const library = JSON.parse(await readFile("website/public/data/library.json", "utf8"));
validateLibraryData(library); validateLibraryData(library);
const memosModule = library.toolbox.modules.find((module) => module.type === "memos");
const notepadModule = library.toolbox.modules.find((module) => module.type === "notepad");
assert.equal(memosModule?.title, "Mémos");
assert.equal(notepadModule?.title, "Bloc Note");
}); });
test("mhwilds data and assets are available", async () => { test("mhwilds data and assets are available", async () => {

View file

@ -19,6 +19,10 @@ test("style entrypoint and theme layers are wired", async () => {
const styleMixins = await readFile("website/src/styles/_mixins.scss", "utf8"); const styleMixins = await readFile("website/src/styles/_mixins.scss", "utf8");
const styleShell = await readFile("website/src/styles/_shell.scss", "utf8"); const styleShell = await readFile("website/src/styles/_shell.scss", "utf8");
const styleCards = await readFile("website/src/styles/_cards.scss", "utf8"); const styleCards = await readFile("website/src/styles/_cards.scss", "utf8");
const styleSurfaces = await readFile("website/src/styles/design-system/_surfaces.scss", "utf8");
const styleControlsButtons = await readFile("website/src/styles/design-system/controls/_buttons.scss", "utf8");
const styleAttributesButtons = await readFile("website/src/styles/design-system/attributes/_buttons.scss", "utf8");
const goalTreeBranch = await readFile("website/src/features/toolboxes/modules/GoalTreeBranch.jsx", "utf8");
const styleHome = await readJoinedStyles([ const styleHome = await readJoinedStyles([
"website/src/styles/_home.scss", "website/src/styles/_home.scss",
"website/src/styles/home/_shared.scss", "website/src/styles/home/_shared.scss",
@ -216,10 +220,30 @@ test("style entrypoint and theme layers are wired", async () => {
assert.match(styleGames, /\.game-layout/); assert.match(styleGames, /\.game-layout/);
assert.match(styleGames, /\.game-grid/); assert.match(styleGames, /\.game-grid/);
assert.match(styleGames, /\.filter-panel/); assert.match(styleGames, /\.filter-panel/);
assert.match(styleSurfaces, /@mixin card-surface/);
assert.match(styleSurfaces, /@mixin card-surface-hover/);
assert.match(styleSurfaces, /@mixin card-cover-divider/);
assert.match(styleCards, /@use "design-system\/surfaces"/);
assert.match(styleCards, /surfaces\.card-surface/);
assert.match(styleGames, /@use "\.\.\/design-system\/surfaces"/);
assert.match(styleGames, /surfaces\.card-surface/);
assert.match(styleGames, /surfaces\.card-cover-divider/);
assert.match(styleMhwilds, /\.mhwilds-card/); assert.match(styleMhwilds, /\.mhwilds-card/);
assert.match(styleMhwilds, /\.mhwilds-flip-indicator/); assert.match(styleMhwilds, /\.mhwilds-flip-indicator/);
assert.match(styleMhwilds, /@use "design-system\/surfaces"/);
assert.match(styleMhwilds, /surfaces\.card-surface/);
assert.match(styleMhwilds, /surfaces\.card-cover-divider/);
assert.doesNotMatch(styleMhwilds, /\.game-(home-card|layout|grid)/); assert.doesNotMatch(styleMhwilds, /\.game-(home-card|layout|grid)/);
assert.match(styleCards, /compact-data-card/); assert.match(styleCards, /compact-data-card/);
assert.doesNotMatch(
[
goalTreeBranch,
styleToolboxGoalTree,
styleControlsButtons,
styleAttributesButtons
].join("\n"),
/(?:data-size="tiny"|icon-button(?:-icon)?\(tiny\)|\$size == tiny)/
);
assert.match(styleOverlays, /overscroll-behavior: contain/); assert.match(styleOverlays, /overscroll-behavior: contain/);
assert.match(styleResponsive, /@media \(max-width: 760px\)/); assert.match(styleResponsive, /@media \(max-width: 760px\)/);
}); });

View file

@ -243,13 +243,21 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8"); const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8");
const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8"); const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
const randomizerModule = await readFile("website/src/features/toolboxes/modules/RandomizerModule.jsx", "utf8");
const randomizerDice = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx", "utf8");
const randomizerWheel = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx", "utf8");
const randomizerTeams = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx", "utf8");
const randomizerShared = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerShared.jsx", "utf8");
const randomizerUiUtils = await readFile("website/src/features/toolboxes/modules/randomizer/randomizerUiUtils.js", "utf8");
const randomizerBundle = `${randomizerModule}\n${randomizerDice}\n${randomizerWheel}\n${randomizerTeams}\n${randomizerShared}\n${randomizerUiUtils}`;
[ [
["CalendarModule.jsx", calendarModule], ["CalendarModule.jsx", calendarModule],
["modules/index.jsx", moduleRegistry], ["modules/index.jsx", moduleRegistry],
["GoalTreeParts.jsx", goalTreeParts], ["GoalTreeParts.jsx", goalTreeParts],
["TimerParts.jsx", timerParts], ["TimerParts.jsx", timerParts],
["TaskPlannerItems.jsx", taskPlannerItems] ["TaskPlannerItems.jsx", taskPlannerItems],
["RandomizerModule.jsx", randomizerModule]
].forEach(([fileName, fileSource]) => { ].forEach(([fileName, fileSource]) => {
assert.ok(countLines(fileSource) <= 220, `${fileName} must remain a short orchestrator/barrel`); assert.ok(countLines(fileSource) <= 220, `${fileName} must remain a short orchestrator/barrel`);
}); });
@ -358,6 +366,19 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calculatorModule, /parentId/); assert.match(calculatorModule, /parentId/);
assert.match(calculatorModule, /MixTagsInput/); assert.match(calculatorModule, /MixTagsInput/);
assert.match(calculatorModule, /useGroupedReorder/); assert.match(calculatorModule, /useGroupedReorder/);
assert.match(moduleMetadataRegistry, /label: "Mémos"/);
assert.match(moduleMetadataRegistry, /defaultTitle: "Mémos"/);
assert.match(moduleMetadataRegistry, /label: "Bloc Note"/);
assert.match(moduleMetadataRegistry, /defaultTitle: "Bloc Note"/);
assert.match(randomizerModule, /RandomizerDice\.jsx/);
assert.match(randomizerModule, /RandomizerWheel\.jsx/);
assert.match(randomizerModule, /RandomizerTeams\.jsx/);
assert.match(randomizerDice, /export function DiceRandomizer/);
assert.match(randomizerWheel, /export function WheelRandomizer/);
assert.match(randomizerTeams, /export function TeamsRandomizer/);
assert.match(randomizerShared, /export function RandomizerHistory/);
assert.match(randomizerUiUtils, /export function hasReducedMotion/);
assert.match(randomizerBundle, /type="randomizer"/);
assert.match(calculatorEntries, /calculator-drag-handle/); assert.match(calculatorEntries, /calculator-drag-handle/);
assert.match(calculatorModule, /scrollResults/); assert.match(calculatorModule, /scrollResults/);
assert.match(calculatorModule, /tool-split-scroll-toggle/); assert.match(calculatorModule, /tool-split-scroll-toggle/);

View file

@ -51,7 +51,7 @@ Le check couvre la génération des index de listes, les tests et le build de pr
## Pré-push check ## Pré-push check
Quand l'utilisateur indique qu'il va push, faire une passe globale avant de conclure : Quand l'utilisateur indique qu'il va push, par exemple "je vais push", "je push", "go push" ou "prêt à push", traiter le message comme une demande implicite de pré-push check. Ne pas attendre de précision supplémentaire et ne pas exécuter `git commit`, `git push` ou `git sync` sauf demande explicite. Faire une passe globale avant de conclure :
- code mort, imports inutilisés, fonctions inutilisées, styles obsolètes ; - code mort, imports inutilisés, fonctions inutilisées, styles obsolètes ;
- duplication de code ou de style pouvant être factorisée sans complexifier ; - duplication de code ou de style pouvant être factorisée sans complexifier ;

View file

@ -6,7 +6,7 @@
{ {
"id": "m0", "id": "m0",
"type": "memos", "type": "memos",
"title": "Memos" "title": "Mémos"
}, },
{ {
"id": "m1", "id": "m1",

View file

@ -1557,7 +1557,7 @@
}, },
{ {
"title": "Ajoutez vos outils", "title": "Ajoutez vos outils",
"text": "Choisissez seulement ce dont vous avez besoin : Memos, Bloc Note, checklist, images, liens, timer ou compteur." "text": "Choisissez seulement ce dont vous avez besoin : Mémos, Bloc Note, checklist, images, liens, timer ou compteur."
}, },
{ {
"title": "Importez une sauvegarde si vous en avez une", "title": "Importez une sauvegarde si vous en avez une",
@ -1700,8 +1700,8 @@
{ {
"icon": "memos", "icon": "memos",
"title": "Notes et suivi", "title": "Notes et suivi",
"text": "Écrire des rappels courts, garder un bloc note multi-pages, suivre des objectifs simples ou compter des ressources.", "text": "Écrire des rappels courts, garder un Bloc Note multi-pages, suivre des objectifs simples ou compter des ressources.",
"examples": ["Memos", "Bloc Note", "Checklist", "Compteurs"] "examples": ["Mémos", "Bloc Note", "Checklist", "Compteurs"]
}, },
{ {
"icon": "picture", "icon": "picture",
@ -1798,7 +1798,7 @@
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.", "summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
"creationTemplate": { "creationTemplate": {
"memos": { "memos": {
"title": "Memos", "title": "Mémos",
"text": "Profite bien de ta nouvelle toolbox." "text": "Profite bien de ta nouvelle toolbox."
}, },
"links": { "links": {
@ -1836,7 +1836,7 @@
"target": "moduleTitle", "target": "moduleTitle",
"icon": "edit", "icon": "edit",
"title": "Renommer un outil", "title": "Renommer un outil",
"text": "Cliquez sur le titre d’un outil, comme Memos, pour l’adapter à votre usage." "text": "Cliquez sur le titre d’un outil, comme Mémos, pour l’adapter à votre usage."
}, },
{ {
"target": "deleteTool", "target": "deleteTool",
@ -1865,13 +1865,13 @@
"modules": { "modules": {
"notepad": { "notepad": {
"placeholder": "Bloc Note...", "placeholder": "Bloc Note...",
"pagesMenuLabel": "Pages du bloc note", "pagesMenuLabel": "Pages du Bloc Note",
"pagesToggleLabel": "Afficher les pages du bloc note", "pagesToggleLabel": "Afficher les pages du Bloc Note",
"pagesToggleText": "Afficher les pages du bloc note", "pagesToggleText": "Afficher les pages du Bloc Note",
"addPageLabel": "Ajouter une page", "addPageLabel": "Ajouter une page",
"renamePageLabel": "Renommer", "renamePageLabel": "Renommer",
"deletePageLabel": "Supprimer la page", "deletePageLabel": "Supprimer la page",
"toolbarLabel": "Mise en page du bloc note", "toolbarLabel": "Mise en page du Bloc Note",
"textFormatLabel": "Style du texte", "textFormatLabel": "Style du texte",
"boldTitle": "Gras", "boldTitle": "Gras",
"italicTitle": "Italique", "italicTitle": "Italique",
@ -1900,13 +1900,13 @@
"importPlaceholder": "@notepad\n@page: Page 1\n@html\n<h3>Route boss</h3>\n<p><strong style=\"color: #f6c453\">Préparer les consommables</strong></p>\n@endhtml\n@drawingMode: permanent\n@drawings: {\"strokes\":[]}", "importPlaceholder": "@notepad\n@page: Page 1\n@html\n<h3>Route boss</h3>\n<p><strong style=\"color: #f6c453\">Préparer les consommables</strong></p>\n@endhtml\n@drawingMode: permanent\n@drawings: {\"strokes\":[]}",
"importOpenButton": "Importer du texte", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte", "exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un bloc note", "importModalTitle": "Importer un Bloc Note",
"exportModalTitle": "Exporter un bloc note", "exportModalTitle": "Exporter un Bloc Note",
"importButton": "Importer le texte", "importButton": "Importer le texte",
"exportButton": "Copier le texte", "exportButton": "Copier le texte",
"importSuccess": "Import bloc note terminé.", "importSuccess": "Import Bloc Note terminé.",
"exportSuccess": "Export bloc note copié.", "exportSuccess": "Export Bloc Note copié.",
"importError": "Format de bloc note invalide.", "importError": "Format de Bloc Note invalide.",
"exportInfo": "Le HTML est nettoyé à l'import. Les dessins permanents sont exportés en JSON compact." "exportInfo": "Le HTML est nettoyé à l'import. Les dessins permanents sont exportés en JSON compact."
}, },
"memos": { "memos": {

View file

@ -49,10 +49,10 @@ export function GoalTreeBranch({ nodes, parentId, selectedNodeId, editing, reord
<article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}> <article className={`goal-tree-node-card ${editing ? "is-editing" : "is-viewing"} ${hasNodeActions ? "has-actions" : ""}`} data-diagram-node={node.id} onClick={selectFromCard}>
{!editing && ( {!editing && (
<div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}> <div className="goal-tree-node-hover-actions" aria-label={textContent.cardActionsLabel || "Actions de l'objectif"}>
<button className="nds-icon-button" data-size="tiny" type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}> <button className="nds-icon-button" data-size="xxs" type="button" onClick={() => onEditNode(node.id)} aria-label={`${textContent.editTitle || "Modifier"} ${node.title}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
<button className="nds-icon-button" data-size="tiny" data-variant="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}> <button className="nds-icon-button" data-size="xxs" data-variant="danger" type="button" onClick={() => onDeleteNode(node.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${node.title}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>

View file

@ -292,7 +292,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
<CompactDropdown <CompactDropdown
className="notepad-pages-dropdown" className="notepad-pages-dropdown"
menuClassName="notepad-pages-menu" menuClassName="notepad-pages-menu"
label={textContent.pagesMenuLabel || "Pages du bloc note"} label={textContent.pagesMenuLabel || "Pages du Bloc Note"}
renderTrigger={({ open, toggle }) => ( renderTrigger={({ open, toggle }) => (
<button <button
className={`notepad-pages-toggle nds-icon-button ${open ? "active" : ""}`} className={`notepad-pages-toggle nds-icon-button ${open ? "active" : ""}`}
@ -302,9 +302,9 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
data-border="joined" data-border="joined"
type="button" type="button"
onClick={toggle} onClick={toggle}
aria-label={textContent.pagesToggleLabel || "Afficher les pages du bloc note"} aria-label={textContent.pagesToggleLabel || "Afficher les pages du Bloc Note"}
aria-expanded={open} aria-expanded={open}
title={textContent.pagesToggleLabel || "Afficher les pages du bloc note"} title={textContent.pagesToggleLabel || "Afficher les pages du Bloc Note"}
> >
<Icon name="pages" /> <Icon name="pages" />
</button> </button>

View file

@ -1,4 +1,4 @@
// Rôle : rend la barre d'outils de mise en forme du bloc-notes. // Rôle : rend la barre d'outils de mise en forme du Bloc Note.
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { FormattingColorDropdown } from "./DrawingControls.jsx"; import { FormattingColorDropdown } from "./DrawingControls.jsx";
@ -15,7 +15,7 @@ export function NotepadToolbar({
onHighlightColorApply onHighlightColorApply
}) { }) {
return ( return (
<div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du bloc note"}> <div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du Bloc Note"}>
<div className="notepad-format-controls"> <div className="notepad-format-controls">
<div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}> <div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}>
<ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => onCommand("bold")}>B</ToolbarButton> <ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => onCommand("bold")}>B</ToolbarButton>

View file

@ -1,71 +1,10 @@
// Rôle : fournit l'outil Randomizer avec dé, roue pondérée et équipes équilibrées. // Rôle : orchestre les onglets de l'outil Randomizer sans porter les sous-vues.
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 { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { import { RANDOMIZER_TABS } from "./randomizerUtils.js";
RANDOMIZER_LIMITS, import { DiceRandomizer } from "./randomizer/RandomizerDice.jsx";
RANDOMIZER_TABS, import { TeamsRandomizer } from "./randomizer/RandomizerTeams.jsx";
addRandomizerHistory, import { WheelRandomizer } from "./randomizer/RandomizerWheel.jsx";
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 }) { export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {})); const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
@ -101,773 +40,3 @@ export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpe
</> </>
); );
} }
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,168 @@
// Rôle : gère l'onglet dé du Randomizer et son animation.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../../components/Icon.jsx";
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, getDiceShape, rollDice } from "../randomizerUtils.js";
import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx";
import { DICE_ROLL_DELAYS, DRAW_PAUSE_MS, hasReducedMotion } from "./randomizerUiUtils.js";
export 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>
);
}

View file

@ -0,0 +1,62 @@
// Rôle : fournit les composants partagés de résultats et historique du Randomizer.
import { Icon } from "../../../../components/Icon.jsx";
import { formatDateTime } from "./randomizerUiUtils.js";
export 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>
);
}
export 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,356 @@
// Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions.
import { useEffect, useRef, useState } from "react";
import { CompactDropdown } from "../../../../components/CompactDropdown.jsx";
import { Icon } from "../../../../components/Icon.jsx";
import { useDraftForm } from "../../../../hooks/useDraftForm.js";
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js";
import { RandomizerHistory } from "./RandomizerShared.jsx";
import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js";
export 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>
);
}

View file

@ -0,0 +1,207 @@
// Rôle : gère l'onglet roue pondérée du Randomizer et son animation.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../../components/Icon.jsx";
import { RANDOMIZER_LIMITS, addRandomizerHistory, buildWheelSegments, clampNumber, drawWeightedEntries } from "../randomizerUtils.js";
import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx";
import { DRAW_PAUSE_MS, WHEEL_COLORS, WHEEL_SPIN_MS, describeWheelArc, getWheelTargetRotation, hasReducedMotion } from "./randomizerUiUtils.js";
export 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>
);
}

View file

@ -0,0 +1,49 @@
// Rôle : regroupe les helpers UI et animation purs du Randomizer.
export const WHEEL_COLORS = ["#8b5cf6", "#f6c453", "#22d3ee", "#d946ef", "#6366f1", "#22c55e"];
export const DRAW_PAUSE_MS = 500;
export const DICE_ROLL_DELAYS = [62, 70, 82, 98, 120, 148, 182, 224, 276, 338, 410];
export const TEAM_REEL_DELAYS = [58, 66, 78, 94, 116, 144, 180, 226, 286, 360];
export const WHEEL_SPIN_MS = 1120;
export const DEFAULT_EXCLUSION_DRAFT = { playerAId: "", playerBIds: [], mode: "group" };
export 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)
};
}
export 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`;
}
export function formatDateTime(value) {
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "";
}
export 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;
}
export 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);
}

View file

@ -104,7 +104,7 @@ export function importNotepadText(text, context) {
if (currentPage.drawings.strokes.length) currentPage.drawingMode = "permanent"; if (currentPage.drawings.strokes.length) currentPage.drawingMode = "permanent";
return; return;
} }
throw lineError(index, `ligne bloc note invalide: ${cleanLine}`); throw lineError(index, `ligne Bloc Note invalide: ${cleanLine}`);
}); });
if (readingHtml) throw new Error("Bloc HTML non terminé."); if (readingHtml) throw new Error("Bloc HTML non terminé.");
flushPage(); flushPage();

View file

@ -11,8 +11,8 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
libraryCategory: "notesTracking" libraryCategory: "notesTracking"
}, },
memos: { memos: {
label: "Memos", label: "Mémos",
defaultTitle: "Memos", defaultTitle: "Mémos",
mode: "Post-it courts", mode: "Post-it courts",
icon: "memos", icon: "memos",
editable: true, editable: true,

View file

@ -41,7 +41,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
name: name.trim() || "Nouvelle toolbox", name: name.trim() || "Nouvelle toolbox",
updatedAt: now, updatedAt: now,
modules: [ modules: [
{ id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Memos" }, { id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Mémos" },
{ id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" } { id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" }
] ]
}; };

View file

@ -1,5 +1,6 @@
// Rôle : regroupe les styles de cards, panels et variantes compactes. // Rôle : regroupe les styles de cards, panels et variantes compactes.
@use "mixins"; @use "mixins";
@use "design-system/surfaces";
.section-grid, .section-grid,
.cards, .cards,
@ -31,12 +32,7 @@
} }
.card { .card {
border-color: transparent; @include surfaces.card-surface;
border-radius: var(--radius-md);
background:
linear-gradient(rgba(16, 20, 38, 0.88), rgba(16, 20, 38, 0.88)) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.42), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62) 0%, rgba(246, 196, 83, 0.42) 72%, rgba(34, 211, 238, 0.24) 100%) border-box;
} }
@supports (corner-shape: scoop) { @supports (corner-shape: scoop) {
@ -69,15 +65,8 @@
} }
.card:hover { .card:hover {
@include surfaces.card-surface-hover;
transform: none; transform: none;
border-color: transparent;
background:
linear-gradient(rgba(21, 26, 48, 0.96), rgba(21, 26, 48, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.9), rgba(196, 181, 253, 0.72)) border-box;
box-shadow:
var(--shadow-sm),
0 0 18px rgba(139, 92, 246, 0.22),
0 0 34px rgba(196, 181, 253, 0.12);
} }
.compact-data-card { .compact-data-card {
@ -136,7 +125,7 @@
.card-cover { .card-cover {
min-height: 124px; min-height: 124px;
border-bottom: 1px solid rgba(246, 196, 83, 0.42); @include surfaces.card-cover-divider;
} }
.card-body { .card-body {

View file

@ -1,4 +1,6 @@
// Rôle : contient les styles spécifiques aux pages Monster Hunter Wilds. // Rôle : contient les styles spécifiques aux pages Monster Hunter Wilds.
@use "design-system/surfaces";
.monster-grid { .monster-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
} }
@ -8,13 +10,9 @@
} }
.mhwilds-card { .mhwilds-card {
@include surfaces.card-surface;
overflow: hidden; overflow: hidden;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md);
background:
linear-gradient(rgba(16, 20, 38, 0.88), rgba(16, 20, 38, 0.88)) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.42), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62) 0%, rgba(246, 196, 83, 0.42) 72%, rgba(34, 211, 238, 0.24) 100%) border-box;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: transition:
transform var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard),
@ -29,15 +27,8 @@
} }
.mhwilds-card:hover { .mhwilds-card:hover {
@include surfaces.card-surface-hover;
transform: none; transform: none;
border-color: transparent;
background:
linear-gradient(rgba(21, 26, 48, 0.96), rgba(21, 26, 48, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.9), rgba(196, 181, 253, 0.72)) border-box;
box-shadow:
var(--shadow-sm),
0 0 18px rgba(139, 92, 246, 0.22),
0 0 34px rgba(196, 181, 253, 0.12);
} }
.monster-card { .monster-card {
@ -76,6 +67,7 @@
} }
.mhwilds-card-face { .mhwilds-card-face {
@include surfaces.card-surface(rgba(16, 20, 38, 0.92));
position: absolute; position: absolute;
inset: 0; inset: 0;
contain: paint; contain: paint;
@ -83,11 +75,6 @@
overflow: hidden; overflow: hidden;
flex-direction: column; flex-direction: column;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md);
background:
linear-gradient(rgba(16, 20, 38, 0.92), rgba(16, 20, 38, 0.92)) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.42), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62) 0%, rgba(246, 196, 83, 0.42) 72%, rgba(34, 211, 238, 0.24) 100%) border-box;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
backface-visibility: hidden; backface-visibility: hidden;
} }
@ -144,21 +131,20 @@
} }
.monster-card:hover .mhwilds-card-face { .monster-card:hover .mhwilds-card-face {
border-color: transparent; @include surfaces.card-surface-hover(
background: rgba(16, 20, 38, 0.95),
linear-gradient(rgba(16, 20, 38, 0.95), rgba(16, 20, 38, 0.95)) padding-box, rgba(139, 92, 246, 0.76),
linear-gradient(135deg, rgba(139, 92, 246, 0.76), rgba(196, 181, 253, 0.58)) border-box; rgba(196, 181, 253, 0.58),
box-shadow:
var(--shadow-sm),
0 0 14px rgba(139, 92, 246, 0.14), 0 0 14px rgba(139, 92, 246, 0.14),
0 0 24px rgba(196, 181, 253, 0.08); 0 0 24px rgba(196, 181, 253, 0.08)
);
} }
.mhwilds-card-art { .mhwilds-card-art {
display: grid; display: grid;
min-height: 112px; min-height: 112px;
place-items: center; place-items: center;
border-bottom: 1px solid rgba(246, 196, 83, 0.42); @include surfaces.card-cover-divider;
background: background:
radial-gradient(circle at 50% 40%, rgba(139, 92, 246, 0.16), transparent 46%), radial-gradient(circle at 50% 40%, rgba(139, 92, 246, 0.16), transparent 46%),
rgba(5, 7, 17, 0.36); rgba(5, 7, 17, 0.36);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
// Rôle : fournit les primitives de surfaces réutilisables du design system.
@mixin card-surface($fill: rgba(16, 20, 38, 0.88)) {
border-color: transparent;
border-radius: var(--radius-md);
background:
linear-gradient($fill, $fill) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.42), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62) 0%, rgba(246, 196, 83, 0.42) 72%, rgba(34, 211, 238, 0.24) 100%) border-box;
}
@mixin card-surface-hover(
$fill: rgba(21, 26, 48, 0.96),
$start: rgba(139, 92, 246, 0.9),
$end: rgba(196, 181, 253, 0.72),
$glow-primary: 0 0 18px rgba(139, 92, 246, 0.22),
$glow-secondary: 0 0 34px rgba(196, 181, 253, 0.12)
) {
border-color: transparent;
background:
linear-gradient($fill, $fill) padding-box,
linear-gradient(135deg, $start, $end) border-box;
box-shadow:
var(--shadow-sm),
$glow-primary,
$glow-secondary;
}
@mixin card-cover-divider {
border-bottom: 1px solid rgba(246, 196, 83, 0.42);
}

View file

@ -0,0 +1,369 @@
// Rôle : expose les classes NDS de boutons texte, icône et lignes.
@use "../controls";
.nds-button {
@include controls.text-button;
font-weight: 800;
}
.nds-button:hover:not(:disabled, [data-variant="primary"], [data-variant="danger"], [data-variant="ghost"], .active, .is-active, [aria-pressed="true"]) {
@include controls.text-button-hover;
}
.nds-button[data-size="sm"] {
@include controls.text-button(sm);
}
.nds-button[data-size="xs"] {
@include controls.text-button(xs);
}
.nds-button[data-variant="primary"] {
@include controls.text-button-primary;
}
.nds-button[data-variant="primary"]:hover {
@include controls.text-button-primary-hover;
}
.nds-button[data-variant="secondary"] {
@include controls.text-button-secondary;
}
.nds-button[data-variant="ghost"] {
@include controls.text-button-ghost;
}
.nds-button[data-variant="ghost"]:hover {
@include controls.text-button-ghost-hover;
}
.nds-button[data-variant="dashed"] {
@include controls.text-button-dashed;
}
.nds-button[data-variant="dashed"]:hover {
@include controls.text-button-dashed-hover;
}
.nds-button[data-variant="danger"] {
@include controls.text-button-danger;
}
.nds-button[data-variant="danger"]:hover {
@include controls.text-button-danger-hover;
}
.nds-button[data-edge="left"] {
@include controls.button-edge(left);
}
.nds-button[data-edge="middle"] {
@include controls.button-edge(middle);
}
.nds-button[data-edge="top"] {
@include controls.button-edge(top);
}
.nds-button[data-edge="center"] {
@include controls.button-edge(center);
}
.nds-button[data-edge="right"] {
@include controls.button-edge(right);
}
.nds-button[data-edge="bottom"] {
@include controls.button-edge(bottom);
}
.nds-button[data-edge="top-left"] {
@include controls.button-edge(top-left);
}
.nds-button[data-edge="top-right"] {
@include controls.button-edge(top-right);
}
.nds-button[data-edge="bottom-right"] {
@include controls.button-edge(bottom-right);
}
.nds-button[data-edge="bottom-left"] {
@include controls.button-edge(bottom-left);
}
.nds-button[data-border="joined"][data-edge="left"] {
@include controls.button-joined-border(left);
}
.nds-button[data-border="joined"][data-edge="middle"] {
@include controls.button-joined-border(middle);
}
.nds-button[data-border="joined"][data-edge="top"] {
@include controls.button-joined-border(top);
}
.nds-button[data-border="joined"][data-edge="center"] {
@include controls.button-joined-border(center);
}
.nds-button[data-border="joined"][data-edge="right"] {
@include controls.button-joined-border(right);
}
.nds-button[data-border="joined"][data-edge="bottom"] {
@include controls.button-joined-border(bottom);
}
.nds-button[data-border="joined"][data-edge="top-left"] {
@include controls.button-joined-border(top-left);
}
.nds-button[data-border="joined"][data-edge="top-right"] {
@include controls.button-joined-border(top-right);
}
.nds-button[data-border="joined"][data-edge="bottom-right"] {
@include controls.button-joined-border(bottom-right);
}
.nds-button[data-border="joined"][data-edge="bottom-left"] {
@include controls.button-joined-border(bottom-left);
}
.nds-button[data-border="none"] {
@include controls.button-borderless;
}
.nds-button.is-active,
.nds-button.active,
.nds-button.active,
.nds-button.is-active,
.nds-button[aria-pressed="true"] {
@include controls.text-button-active;
}
.nds-icon-button {
@include controls.icon-button;
display: inline-grid;
place-items: center;
box-shadow: none;
}
.nds-icon-button .ui-icon {
@include controls.icon-button-icon;
}
.nds-icon-button[class*="drag-handle"] {
cursor: grab;
touch-action: none;
user-select: none;
}
.nds-icon-button[class*="drag-handle"]:active {
cursor: grabbing;
}
.nds-icon-button[class*="drag-handle"]:disabled {
cursor: not-allowed;
}
.nds-icon-button:hover:not([data-variant="primary"], [data-variant="danger"], [data-variant="ghost"], .active, .is-active, [aria-pressed="true"]) {
@include controls.icon-button-hover;
}
.nds-icon-button:hover:not([data-variant="primary"], [data-variant="danger"], [data-variant="ghost"], .active, .is-active, [aria-pressed="true"]) .ui-icon {
@include controls.icon-button-hover-icon;
}
.nds-icon-button[data-size="sm"] {
@include controls.icon-button(sm);
}
.nds-icon-button[data-size="sm"] .ui-icon {
@include controls.icon-button-icon(sm);
}
.nds-icon-button[data-size="xs"] {
@include controls.icon-button(xs);
}
.nds-icon-button[data-size="xs"] .ui-icon {
@include controls.icon-button-icon(xs);
}
.nds-icon-button[data-size="xxs"] {
@include controls.icon-button(xxs);
}
.nds-icon-button[data-size="xxs"] .ui-icon {
@include controls.icon-button-icon(xxs);
}
.nds-icon-button[data-size="micro"] {
@include controls.icon-button(micro);
}
.nds-icon-button[data-size="micro"] .ui-icon {
@include controls.icon-button-icon(micro);
}
.nds-icon-button[data-variant="primary"] {
@include controls.icon-button-primary;
}
.nds-icon-button[data-variant="primary"]:hover {
@include controls.icon-button-primary-hover;
}
.nds-icon-button[data-variant="primary"]:hover .ui-icon {
@include controls.icon-button-primary-hover-icon;
}
.nds-icon-button[data-variant="ghost"] {
@include controls.icon-button-ghost;
}
.nds-icon-button[data-variant="panel-toggle"] {
@include controls.icon-button-panel-toggle;
}
.nds-icon-button[data-variant="panel-toggle"]:hover,
.nds-icon-button[data-variant="panel-toggle"].active,
.nds-icon-button[data-variant="panel-toggle"].is-active,
.nds-icon-button[data-variant="panel-toggle"][aria-pressed="true"] {
@include controls.icon-button-panel-toggle-hover;
}
.nds-icon-button[data-variant="danger"] {
@include controls.icon-button-danger;
}
.nds-icon-button[data-variant="danger"]:hover {
@include controls.icon-button-danger-hover;
}
.nds-icon-button[data-variant="danger"]:hover .ui-icon {
@include controls.icon-button-danger-hover-icon;
}
.nds-icon-button[class*="drag-handle"]:hover {
cursor: grab;
}
.nds-icon-button[class*="drag-handle"]:active,
.nds-icon-button[class*="drag-handle"]:hover:active {
cursor: grabbing;
}
.nds-icon-button[data-edge="left"] {
@include controls.button-edge(left);
}
.nds-icon-button[data-edge="middle"] {
@include controls.button-edge(middle);
}
.nds-icon-button[data-edge="top"] {
@include controls.button-edge(top);
}
.nds-icon-button[data-edge="center"] {
@include controls.button-edge(center);
}
.nds-icon-button[data-edge="right"] {
@include controls.button-edge(right);
}
.nds-icon-button[data-edge="bottom"] {
@include controls.button-edge(bottom);
}
.nds-icon-button[data-edge="top-left"] {
@include controls.button-edge(top-left);
}
.nds-icon-button[data-edge="top-right"] {
@include controls.button-edge(top-right);
}
.nds-icon-button[data-edge="bottom-right"] {
@include controls.button-edge(bottom-right);
}
.nds-icon-button[data-edge="bottom-left"] {
@include controls.button-edge(bottom-left);
}
.nds-icon-button[data-border="joined"][data-edge="left"] {
@include controls.button-joined-border(left);
}
.nds-icon-button[data-border="joined"][data-edge="middle"] {
@include controls.button-joined-border(middle);
}
.nds-icon-button[data-border="joined"][data-edge="top"] {
@include controls.button-joined-border(top);
}
.nds-icon-button[data-border="joined"][data-edge="center"] {
@include controls.button-joined-border(center);
}
.nds-icon-button[data-border="joined"][data-edge="right"] {
@include controls.button-joined-border(right);
}
.nds-icon-button[data-border="joined"][data-edge="bottom"] {
@include controls.button-joined-border(bottom);
}
.nds-icon-button[data-border="joined"][data-edge="top-left"] {
@include controls.button-joined-border(top-left);
}
.nds-icon-button[data-border="joined"][data-edge="top-right"] {
@include controls.button-joined-border(top-right);
}
.nds-icon-button[data-border="joined"][data-edge="bottom-right"] {
@include controls.button-joined-border(bottom-right);
}
.nds-icon-button[data-border="joined"][data-edge="bottom-left"] {
@include controls.button-joined-border(bottom-left);
}
.nds-icon-button[data-border="none"] {
@include controls.button-borderless;
}
.nds-icon-button.active,
.nds-icon-button.is-active,
.nds-icon-button[aria-pressed="true"] {
@include controls.icon-button-active;
}
.nds-list-row {
@include controls.list-row-button;
}
.nds-list-row:hover,
.nds-list-row.is-active,
.nds-list-row.active {
@include controls.list-row-button-hover;
}
.nds-list-row.is-active,
.nds-list-row.active {
@include controls.list-row-button-active;
}
.nds-list-row button {
@include controls.list-row-button-content;
}

View file

@ -0,0 +1,46 @@
// Rôle : expose les classes NDS de cases à cocher.
@use "../controls";
.nds-checkbox {
@include controls.checkbox-control;
}
.nds-checkbox[data-size="xs"] {
@include controls.checkbox-control(xs);
}
.nds-checkbox[data-size="sm"] {
@include controls.checkbox-control(sm);
}
.nds-checkbox::before {
@include controls.checkbox-check;
}
.nds-checkbox[data-size="xs"]::before {
@include controls.checkbox-check(xs);
}
.nds-checkbox[data-size="sm"]::before {
@include controls.checkbox-check(sm);
}
.nds-checkbox:hover:not(:disabled) {
@include controls.checkbox-hover;
}
.nds-checkbox:checked {
@include controls.checkbox-checked;
}
.nds-checkbox:checked::before {
@include controls.checkbox-checked-check;
}
.nds-checkbox:disabled {
@include controls.checkbox-disabled;
}
.nds-checkbox:disabled::before {
opacity: 0.54;
}

View file

@ -0,0 +1,110 @@
// Rôle : expose les classes NDS de surfaces événement.
@use "../controls";
.nds-event-surface {
--nds-event-color: #8b5cf6;
box-sizing: border-box;
width: min(210px, 100%);
min-height: 44px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--nds-event-color) 24%, rgba(255, 255, 255, 0.22));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--nds-event-color) 82%, transparent);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.22);
}
.nds-event-surface[data-size="md"] {
min-height: 44px;
}
.nds-event-surface[data-size="sm"] {
min-height: 22px;
}
.nds-event-surface[data-size="xs"] {
min-height: 10px;
border-width: 0;
border-radius: 4px;
}
.nds-event-surface[data-variant="dark"] {
border-color: color-mix(in srgb, var(--nds-event-color) 42%, rgba(11, 14, 27, 0.28));
background: color-mix(in srgb, var(--nds-event-color) 88%, transparent);
}
.nds-event-surface button {
@include controls.event-surface-button;
@include controls.event-surface-contrast-default;
}
.nds-event-surface button:hover,
.nds-event-surface button:focus-visible {
@include controls.event-surface-button-hover;
}
.nds-event-surface[data-size="sm"] button {
display: block;
padding: 4px 7px;
text-align: center;
}
.nds-event-surface[data-size="xs"] button {
position: relative;
display: block;
padding: 0 4px;
text-align: center;
}
.nds-event-surface[data-size="xs"] button:hover,
.nds-event-surface[data-size="xs"] button:focus-visible {
background: rgba(0, 0, 0, 0.18);
}
.nds-event-surface[data-size="sm"] button > span {
display: none;
}
.nds-event-surface[data-size="xs"] button > span {
display: none;
}
.nds-event-surface strong,
.nds-event-surface span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nds-event-surface[data-variant="dark"] button {
@include controls.event-surface-contrast-dark;
}
.nds-event-surface strong {
font-size: 11px;
line-height: 14px;
}
.nds-event-surface[data-size="sm"] strong {
max-width: 100%;
line-height: 14px;
}
.nds-event-surface[data-size="xs"] strong {
position: absolute;
top: 0;
right: 4px;
left: 4px;
max-width: 100%;
height: 10px;
font-size: 8px;
line-height: 10px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.45);
}
.nds-event-surface span {
font-size: 10px;
line-height: 12px;
opacity: 0.86;
}

View file

@ -0,0 +1,107 @@
// Rôle : expose les classes NDS de champs, select et textarea.
@use "../controls";
.nds-input,
.nds-time,
.nds-select,
.nds-textarea {
@include controls.field-control;
}
.nds-input::placeholder,
.nds-textarea::placeholder {
@include controls.field-placeholder;
}
.nds-input[data-size="sm"],
.nds-textarea[data-size="sm"] {
@include controls.field-control(sm);
}
.nds-input[data-size="xs"],
.nds-textarea[data-size="xs"] {
@include controls.field-control(xs);
}
.nds-time {
@include controls.time-control;
}
.nds-time[data-size="sm"] {
@include controls.time-control(sm);
}
.nds-time[data-size="xs"] {
@include controls.time-control(xs);
}
.nds-select {
@include controls.select-control;
}
.nds-select[data-size="sm"] {
@include controls.select-control(sm);
}
.nds-select[data-size="xs"] {
@include controls.select-control(xs);
}
.nds-select:hover,
.nds-select:focus {
@include controls.select-control-hover;
box-shadow: none;
}
.nds-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}
.nds-select option {
@include controls.select-option;
}
.nds-textarea {
@include controls.field-textarea;
@include controls.scrollbar-resizer;
}
.nds-textarea[data-size="sm"] {
@include controls.field-textarea(sm);
}
.nds-textarea[data-size="xs"] {
@include controls.field-textarea(xs);
}
.nds-textarea[data-variant="ghost"] {
@include controls.field-textarea-ghost;
}
.nds-input:hover,
.nds-time:hover,
.nds-textarea:hover {
@include controls.field-hover;
}
.nds-textarea[data-variant="ghost"]:hover {
@include controls.inline-field-hover;
}
.nds-input:focus,
.nds-time:focus,
.nds-select:focus,
.nds-select-trigger:focus,
.nds-textarea:focus {
@include controls.field-focus;
}
.nds-inline-input {
@include controls.inline-field;
}
.nds-inline-input:hover,
.nds-inline-input:focus {
@include controls.inline-field-hover;
}

View file

@ -0,0 +1,189 @@
// Rôle : expose les classes NDS de swatches, triggers select et icon pickers.
@use "../controls";
.nds-swatch {
@include controls.color-swatch;
}
.nds-swatch[data-variant="rainbow"] {
@include controls.color-swatch-rainbow;
}
.nds-swatch.active,
.nds-swatch.is-active,
.nds-swatch[aria-pressed="true"] {
@include controls.color-swatch-active;
}
.nds-select-trigger,
.nds-date-trigger {
@include controls.select-trigger;
}
.nds-select-trigger[data-size="sm"],
.nds-date-trigger[data-size="sm"] {
@include controls.select-trigger(sm);
}
.nds-select-trigger[data-size="xs"],
.nds-date-trigger[data-size="xs"] {
@include controls.select-trigger(xs);
}
.nds-select-trigger[data-variant="icon-picker"] {
grid-template-columns: minmax(18px, 1fr);
justify-items: center;
min-width: 52px;
padding-inline: 10px 30px;
}
.nds-select-trigger[data-variant="icon-picker"]:has(> span:not([aria-hidden="true"])) {
grid-template-columns: 18px minmax(0, 1fr);
justify-items: start;
padding-inline: 10px 34px;
}
.nds-select-trigger[data-size="sm"][data-variant="icon-picker"] {
grid-template-columns: minmax(18px, 1fr);
min-width: 50px;
padding-inline: 10px 30px;
}
.nds-select-trigger[data-size="sm"][data-variant="icon-picker"]:has(> span:not([aria-hidden="true"])) {
grid-template-columns: 18px minmax(0, 1fr);
padding-inline: 10px 34px;
}
.nds-select-trigger[data-size="xs"][data-variant="icon-picker"] {
grid-template-columns: minmax(16px, 1fr);
min-width: 46px;
padding-inline: 9px 28px;
}
.nds-select-trigger[data-size="xs"][data-variant="icon-picker"]:has(> span:not([aria-hidden="true"])) {
grid-template-columns: 16px minmax(0, 1fr);
padding-inline: 8px 30px;
}
.nds-select-trigger:hover:not(:disabled),
.nds-select-trigger.is-active,
.nds-select-trigger.active,
.nds-select-trigger[aria-pressed="true"],
.nds-date-trigger:hover:not(:disabled),
.nds-date-trigger.is-active,
.nds-date-trigger.active,
.nds-date-trigger[aria-pressed="true"] {
@include controls.select-trigger-hover;
}
.nds-select-trigger:focus,
.nds-date-trigger:focus {
@include controls.select-trigger-hover;
@include controls.field-focus;
outline: 2px solid var(--color-accent-gold);
outline-offset: 3px;
}
.nds-select-trigger[data-size="sm"]:hover:not(:disabled),
.nds-select-trigger[data-size="sm"].is-active,
.nds-select-trigger[data-size="sm"].active,
.nds-select-trigger[data-size="sm"][aria-pressed="true"],
.nds-date-trigger[data-size="sm"]:hover:not(:disabled),
.nds-date-trigger[data-size="sm"].is-active,
.nds-date-trigger[data-size="sm"].active,
.nds-date-trigger[data-size="sm"][aria-pressed="true"] {
@include controls.select-trigger-hover(sm);
}
.nds-select-trigger[data-size="sm"]:focus,
.nds-date-trigger[data-size="sm"]:focus {
@include controls.select-trigger-hover(sm);
@include controls.field-focus;
outline: 2px solid var(--color-accent-gold);
outline-offset: 3px;
}
.nds-select-trigger[data-size="xs"]:hover:not(:disabled),
.nds-select-trigger[data-size="xs"].is-active,
.nds-select-trigger[data-size="xs"].active,
.nds-select-trigger[data-size="xs"][aria-pressed="true"],
.nds-date-trigger[data-size="xs"]:hover:not(:disabled),
.nds-date-trigger[data-size="xs"].is-active,
.nds-date-trigger[data-size="xs"].active,
.nds-date-trigger[data-size="xs"][aria-pressed="true"] {
@include controls.select-trigger-hover(xs);
}
.nds-select-trigger[data-size="xs"]:focus,
.nds-date-trigger[data-size="xs"]:focus {
@include controls.select-trigger-hover(xs);
@include controls.field-focus;
outline: 2px solid var(--color-accent-gold);
outline-offset: 3px;
}
.nds-select-trigger .ui-icon,
.nds-date-trigger .ui-icon {
@include controls.select-trigger-icon;
}
.nds-select-trigger[data-size="sm"] .ui-icon,
.nds-date-trigger[data-size="sm"] .ui-icon {
@include controls.select-trigger-icon(sm);
}
.nds-select-trigger[data-size="xs"] .ui-icon,
.nds-date-trigger[data-size="xs"] .ui-icon {
@include controls.select-trigger-icon(xs);
}
.nds-select-trigger .ui-icon-chevron-down,
.nds-date-trigger .ui-icon-chevron-down {
@include controls.date-picker-trigger-chevron;
}
.nds-select-trigger[data-variant="icon-picker"] > .ui-icon:not(.ui-icon-chevron-down) {
width: 18px;
height: 18px;
}
.nds-select-trigger[data-variant="icon-picker"]:has(> span:not([aria-hidden="true"])) > .ui-icon:not(.ui-icon-chevron-down) {
justify-self: center;
}
.nds-select-trigger[data-size="xs"][data-variant="icon-picker"] > .ui-icon:not(.ui-icon-chevron-down) {
width: 16px;
height: 16px;
}
.nds-select-trigger span,
.nds-date-trigger span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nds-large-icon-picker-trigger {
@include controls.large-icon-picker-trigger;
}
.nds-large-icon-picker-trigger:hover {
@include controls.large-icon-picker-trigger-hover;
}
.nds-large-icon-picker-trigger.active,
.nds-large-icon-picker-trigger.is-active,
.nds-large-icon-picker-trigger[aria-pressed="true"] {
@include controls.large-icon-picker-trigger-hover;
border-color: rgba(246, 196, 83, 0.62);
box-shadow:
0 0 0 1px rgba(246, 196, 83, 0.22),
0 0 22px rgba(139, 92, 246, 0.24);
}
.nds-large-icon-picker-trigger img,
.nds-large-icon-picker-trigger .ui-icon {
width: 72px;
height: 72px;
object-fit: contain;
}

View file

@ -0,0 +1,63 @@
// Rôle : expose les scrollboxes, focus et états désactivés NDS.
@use "../controls";
.nds-scrollbox,
.nds-textarea {
width: 100%;
@include controls.scrollbar-control;
}
.nds-scrollbox {
max-height: 400px;
overflow: auto;
padding: 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.34);
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;
overflow-y: hidden;
white-space: nowrap;
}
.nds-input:focus-visible,
.nds-time:focus-visible,
.nds-select:focus-visible,
.nds-textarea:focus-visible,
.nds-inline-input:focus-visible,
.nds-button:focus-visible,
.nds-icon-button:focus-visible,
.nds-switch:focus-visible,
.nds-radio-button-group button:focus-visible,
.nds-checkbox:focus-visible,
.nds-select-trigger:focus-visible,
.nds-date-trigger:focus-visible,
.nds-large-icon-picker-trigger:focus-visible {
@include controls.field-focus;
outline: 2px solid var(--color-accent-gold);
outline-offset: 3px;
}
.nds-button:disabled,
.nds-icon-button:disabled,
.nds-quantity-stepper button:disabled,
.nds-select:disabled,
.nds-select-trigger:disabled,
.nds-input:disabled,
.nds-time:disabled,
.nds-textarea:disabled,
.nds-date-trigger:disabled,
.nds-large-icon-picker-trigger:disabled {
@include controls.control-disabled;
}

View file

@ -0,0 +1,56 @@
// Rôle : expose les classes NDS de steppers numériques.
@use "../controls";
.nds-quantity-stepper {
@include controls.quantity-stepper;
}
.nds-quantity-stepper[data-size="sm"] {
@include controls.quantity-stepper(sm);
}
.nds-quantity-stepper[data-size="xs"] {
@include controls.quantity-stepper(xs);
}
.nds-quantity-stepper button {
@include controls.quantity-stepper-button;
}
.nds-quantity-stepper[data-size="sm"] button {
@include controls.quantity-stepper-button(sm);
}
.nds-quantity-stepper[data-size="xs"] button {
@include controls.quantity-stepper-button(xs);
}
.nds-quantity-stepper button:hover {
@include controls.quantity-stepper-button-hover;
}
.nds-quantity-stepper .ui-icon {
@include controls.quantity-stepper-button-icon;
}
.nds-quantity-stepper[data-size="sm"] .ui-icon {
@include controls.quantity-stepper-button-icon(sm);
}
.nds-quantity-stepper[data-size="xs"] .ui-icon {
@include controls.quantity-stepper-button-icon(xs);
}
.nds-quantity-stepper-value {
@include controls.quantity-stepper-value;
}
.nds-quantity-stepper-input {
@include controls.quantity-stepper-input;
@include controls.number-spinner-reset;
}
.nds-quantity-stepper-input:hover,
.nds-quantity-stepper-input:focus {
@include controls.quantity-stepper-input-hover;
}

View file

@ -0,0 +1,186 @@
// Rôle : expose les classes NDS de switchs, radio groups et toggles composés.
@use "../controls";
.nds-switch {
@include controls.switch-control;
}
.nds-switch[data-size="lg"] {
@include controls.switch-control(lg);
}
.nds-switch[data-size="sm"] {
@include controls.switch-control(sm);
}
.nds-switch[data-size="xs"] {
@include controls.switch-control(xs);
}
.nds-switch:has(> span:not(.ui-icon)) {
grid-template-columns: var(--nds-switch-icon-size) minmax(0, 1fr) var(--nds-switch-track-width);
min-width: max-content;
}
.nds-switch:has(> span:not(.ui-icon)) > span:not(.ui-icon) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nds-switch .ui-icon {
@include controls.switch-icon;
}
.nds-switch i {
@include controls.switch-track;
}
.nds-switch i::before {
@include controls.switch-thumb;
}
.nds-switch:hover {
@include controls.switch-hover;
}
.nds-switch.active,
.nds-switch.is-active,
.nds-switch[aria-pressed="true"] {
@include controls.switch-active;
}
.nds-switch.active i,
.nds-switch.is-active i,
.nds-switch[aria-pressed="true"] i {
@include controls.switch-active-track;
}
.nds-switch.active i::before,
.nds-switch.is-active i::before,
.nds-switch[aria-pressed="true"] i::before {
@include controls.switch-active-thumb;
}
.nds-native-switch {
@include controls.native-switch-label;
}
.nds-native-switch input {
@include controls.native-switch-input;
}
.nds-native-switch > span:first-of-type {
--nds-switch-track-width: 36px;
--nds-switch-track-height: 20px;
--nds-switch-thumb-size: 12px;
--nds-switch-thumb-left: 3px;
--nds-switch-active-left: 19px;
@include controls.switch-track;
min-width: 36px;
}
.nds-native-switch > span:first-of-type::before {
@include controls.switch-thumb;
}
.nds-native-switch input:checked + span {
@include controls.switch-active-track;
}
.nds-native-switch input:checked + span::before {
@include controls.switch-active-thumb;
}
.nds-native-switch:has(input:disabled) {
@include controls.control-disabled;
cursor: not-allowed;
}
.nds-native-switch:has(input:disabled) > span:first-of-type,
.nds-native-switch:has(input:disabled) b {
cursor: not-allowed;
}
.nds-radio-button-group {
@include controls.radio-button-group;
}
.nds-radio-button-group[data-size="sm"] {
@include controls.radio-button-group(sm);
}
.nds-radio-button-group[data-size="xs"] {
@include controls.radio-button-group(xs);
}
.nds-radio-button-group > button,
.nds-radio-button-group > span {
@include controls.radio-button-group-option;
}
.nds-radio-button-group[data-size="sm"] > button,
.nds-radio-button-group[data-size="sm"] > span {
@include controls.radio-button-group-option(sm);
}
.nds-radio-button-group[data-size="xs"] > button,
.nds-radio-button-group[data-size="xs"] > span {
@include controls.radio-button-group-option(xs);
}
.nds-radio-button-group > button:hover,
.nds-radio-button-group > span:hover {
@include controls.radio-button-group-option-hover;
}
.nds-radio-button-group > button.active,
.nds-radio-button-group > button.is-active,
.nds-radio-button-group > span.active,
.nds-radio-button-group > span.is-active {
@include controls.radio-button-group-option-active;
}
.nds-radio-button-group .ui-icon {
@include controls.radio-button-group-option-icon;
}
.nds-radio-button-group[data-size="sm"] .ui-icon {
@include controls.radio-button-group-option-icon(sm);
}
.nds-radio-button-group[data-size="xs"] .ui-icon {
@include controls.radio-button-group-option-icon(xs);
}
.nds-radio-button-group[aria-disabled="true"] {
@include controls.control-disabled;
}
.nds-compound-toggle-field {
@include controls.compound-toggle-field;
}
.nds-compound-toggle-field button {
@include controls.compound-toggle-field-option;
}
.nds-compound-toggle-field button:hover:not(:disabled) {
@include controls.radio-button-group-option-hover;
}
.nds-compound-toggle-field button.is-active,
.nds-compound-toggle-field button.active,
.nds-compound-toggle-field button[aria-pressed="true"] {
@include controls.radio-button-group-option-active;
}
.nds-compound-toggle-field-input {
@include controls.compound-toggle-field-input;
}
.nds-compound-toggle-field-input:hover,
.nds-compound-toggle-field-input:focus {
@include controls.inline-field-hover;
}

View file

@ -0,0 +1,429 @@
// Rôle : regroupe les mixins de boutons texte, icône, bords joints et toolbar.
@use "../../mixins";
@mixin text-button($size: md) {
$height: 42px;
$padding: 0 18px;
$font-size: inherit;
@if $size == sm {
$height: 34px;
$padding: 0 12px;
$font-size: var(--font-size-sm);
} @else if $size == xs {
$height: 30px;
$padding: 0 10px;
$font-size: var(--font-size-xs);
}
display: inline-flex;
min-height: $height;
align-items: center;
justify-content: center;
gap: 8px;
padding: $padding;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.92);
color: var(--color-text-primary);
font: inherit;
font-size: $font-size;
cursor: pointer;
transition:
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
@mixin text-button-hover {
border-color: rgba(196, 181, 253, 0.34);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.96), rgba(22, 26, 51, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.32), rgba(196, 181, 253, 0.2)) border-box;
box-shadow:
0 0 14px rgba(139, 92, 246, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
@mixin text-button-primary {
border-color: rgba(246, 196, 83, 0.34);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.66), rgba(139, 92, 246, 0.36)) border-box;
box-shadow:
0 8px 22px rgba(5, 7, 17, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
color: #fff;
font-weight: 700;
}
@mixin text-button-primary-hover {
border-color: rgba(246, 196, 83, 0.72);
background:
radial-gradient(circle at 100% 0, rgba(246, 196, 83, 0.24), transparent 42%) padding-box,
linear-gradient(135deg, rgba(125, 62, 232, 0.98), rgba(62, 72, 184, 0.98)) padding-box,
linear-gradient(135deg, rgba(255, 219, 128, 0.98), rgba(139, 92, 246, 0.82), rgba(34, 211, 238, 0.42)) border-box;
box-shadow:
0 0 0 1px rgba(246, 196, 83, 0.18),
0 0 18px rgba(139, 92, 246, 0.26),
0 0 24px rgba(246, 196, 83, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -2px 0 rgba(246, 196, 83, 0.24);
}
@mixin text-button-secondary {
border-color: rgba(196, 181, 253, 0.18);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.72), rgba(15, 23, 42, 0.72)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(34, 211, 238, 0.14)) border-box;
color: var(--color-text-primary);
}
@mixin text-button-ghost {
border-color: transparent;
background: transparent;
color: var(--color-text-secondary);
box-shadow: none;
}
@mixin text-button-ghost-hover {
border-color: rgba(196, 181, 253, 0.18);
background: rgba(139, 92, 246, 0.08);
color: var(--color-text-primary);
box-shadow: none;
}
@mixin text-button-dashed {
border-style: dashed;
border-color: rgba(246, 196, 83, 0.28);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.04), rgba(139, 92, 246, 0.06)),
rgba(7, 10, 24, 0.72);
color: var(--color-text-secondary);
font-weight: 900;
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.16);
}
@mixin text-button-dashed-hover {
border-color: rgba(246, 196, 83, 0.54);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.1), rgba(139, 92, 246, 0.1)),
rgba(13, 17, 34, 0.82);
color: var(--color-text-primary);
box-shadow:
0 0 16px rgba(246, 196, 83, 0.12),
0 10px 18px rgba(0, 0, 0, 0.16);
}
@mixin text-button-danger {
color: var(--color-danger);
}
@mixin text-button-danger-hover {
border-color: rgba(251, 113, 133, 0.48);
background:
radial-gradient(circle at 100% 0, rgba(251, 113, 133, 0.22), transparent 42%) padding-box,
linear-gradient(135deg, rgba(96, 22, 45, 0.94), rgba(58, 16, 34, 0.96)) padding-box,
linear-gradient(135deg, rgba(251, 113, 133, 0.82), rgba(244, 114, 182, 0.42), rgba(196, 181, 253, 0.18)) border-box;
color: #fff;
box-shadow:
0 0 0 1px rgba(251, 113, 133, 0.12),
0 0 16px rgba(251, 113, 133, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -2px 0 rgba(251, 113, 133, 0.24);
}
@mixin text-button-active {
@include mixins.temporary-toggle-active;
}
@mixin list-row-button {
overflow: hidden;
border: 1px solid rgba(150, 165, 205, 0.12);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.36);
}
@mixin list-row-button-hover {
border-color: rgba(150, 165, 205, 0.24);
background: rgba(5, 7, 17, 0.5);
}
@mixin list-row-button-active {
border-color: rgba(246, 196, 83, 0.42);
background: rgba(246, 196, 83, 0.08);
}
@mixin list-row-button-content {
display: inline-flex;
width: 100%;
min-height: 32px;
align-items: center;
justify-content: flex-start;
overflow: hidden;
padding: 0 8px;
border-color: transparent;
background: transparent;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin control-disabled {
box-shadow: none;
cursor: not-allowed;
opacity: 0.48;
}
@mixin icon-button($size: md) {
$control-size: 40px;
@if $size == sm {
$control-size: 34px;
} @else if $size == xs {
$control-size: 30px;
} @else if $size == xxs {
$control-size: 26px;
} @else if $size == micro {
$control-size: 22px;
}
display: inline-flex;
width: $control-size;
height: $control-size;
min-width: $control-size;
min-height: $control-size;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.88);
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
transition:
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
@mixin icon-button-icon($size: md) {
$icon-size: 21px;
@if $size == sm {
$icon-size: 19px;
} @else if $size == xs {
$icon-size: 17px;
} @else if $size == xxs {
$icon-size: 15px;
} @else if $size == micro {
$icon-size: 12px;
}
width: $icon-size;
height: $icon-size;
transition:
background-color var(--duration-fast) var(--ease-standard),
filter var(--duration-fast) var(--ease-standard);
}
@mixin icon-button-hover {
border-color: rgba(196, 181, 253, 0.32);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(196, 181, 253, 0.2)) border-box;
color: var(--color-text-primary);
box-shadow:
0 0 12px rgba(139, 92, 246, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
@mixin icon-button-hover-icon {
background-color: #c4b5fd;
filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.28));
}
@mixin icon-button-primary {
border-color: rgba(246, 196, 83, 0.34);
background:
linear-gradient(135deg, rgba(72, 43, 160, 0.96), rgba(45, 52, 146, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.66), rgba(139, 92, 246, 0.36)) border-box;
color: #fff;
box-shadow:
0 8px 22px rgba(5, 7, 17, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
@mixin icon-button-primary-hover {
border-color: rgba(246, 196, 83, 0.72);
background:
radial-gradient(circle at 100% 0, rgba(246, 196, 83, 0.24), transparent 42%) padding-box,
linear-gradient(135deg, rgba(125, 62, 232, 0.98), rgba(62, 72, 184, 0.98)) padding-box,
linear-gradient(135deg, rgba(255, 219, 128, 0.98), rgba(139, 92, 246, 0.82), rgba(34, 211, 238, 0.42)) border-box;
color: #fff;
box-shadow:
0 0 0 1px rgba(246, 196, 83, 0.18),
0 0 18px rgba(139, 92, 246, 0.26),
0 0 24px rgba(246, 196, 83, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -2px 0 rgba(246, 196, 83, 0.24);
}
@mixin icon-button-primary-hover-icon {
background-color: #fff;
filter: drop-shadow(0 0 6px rgba(246, 196, 83, 0.3));
}
@mixin icon-button-ghost {
border-color: transparent;
background: transparent;
color: var(--color-text-muted);
box-shadow: none;
}
@mixin icon-button-danger {
border-color: rgba(251, 113, 133, 0.2);
color: var(--color-danger);
}
@mixin icon-button-danger-hover {
border-color: rgba(251, 113, 133, 0.56);
background:
radial-gradient(circle at 100% 0, rgba(251, 113, 133, 0.22), transparent 42%) padding-box,
linear-gradient(135deg, rgba(96, 22, 45, 0.94), rgba(58, 16, 34, 0.96)) padding-box,
linear-gradient(135deg, rgba(251, 113, 133, 0.82), rgba(244, 114, 182, 0.42), rgba(196, 181, 253, 0.18)) border-box;
color: #fff;
box-shadow:
0 0 0 1px rgba(251, 113, 133, 0.12),
0 0 16px rgba(251, 113, 133, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -2px 0 rgba(251, 113, 133, 0.24);
}
@mixin icon-button-danger-hover-icon {
background-color: #fff;
filter: drop-shadow(0 0 6px rgba(251, 113, 133, 0.26));
}
@mixin icon-button-active {
@include mixins.temporary-toggle-active;
}
@mixin button-edge($edge: right) {
@if $edge == left {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
} @else if $edge == middle {
border-radius: 0;
} @else if $edge == top {
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
} @else if $edge == center {
border-radius: 0;
} @else if $edge == bottom {
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
} @else if $edge == top-left {
border-radius: var(--radius-sm) 0 0 0;
} @else if $edge == top-right {
border-radius: 0 var(--radius-sm) 0 0;
} @else if $edge == bottom-right {
border-radius: 0 0 var(--radius-sm) 0;
} @else if $edge == bottom-left {
border-radius: 0 0 0 var(--radius-sm);
} @else {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
}
@mixin button-joined-border($edge: right) {
@if $edge == left {
border-right-color: transparent;
} @else if $edge == middle {
border-right-color: transparent;
border-left-color: transparent;
} @else if $edge == right {
border-left-color: transparent;
} @else if $edge == top {
border-bottom-color: transparent;
} @else if $edge == center {
border-top-color: transparent;
border-bottom-color: transparent;
} @else if $edge == bottom {
border-top-color: transparent;
} @else if $edge == top-left {
border-right-color: transparent;
border-bottom-color: transparent;
} @else if $edge == top-right {
border-left-color: transparent;
border-bottom-color: transparent;
} @else if $edge == bottom-right {
border-left-color: transparent;
border-top-color: transparent;
} @else if $edge == bottom-left {
border-right-color: transparent;
border-top-color: transparent;
}
}
@mixin button-borderless {
border-color: transparent;
}
@mixin icon-button-edge($edge: right) {
@include button-edge($edge);
}
@mixin icon-button-panel-toggle {
background: rgba(39, 18, 69, 0.86);
color: var(--color-text-primary);
}
@mixin icon-button-panel-toggle-hover {
border-color: rgba(246, 196, 83, 0.44);
background: rgba(56, 28, 92, 0.94);
color: var(--color-accent-gold);
}
@mixin button-split($position: start, $axis: horizontal) {
@if $axis == vertical {
@if $position == start {
@include button-edge(top);
} @else {
@include button-edge(bottom);
border-top-color: transparent;
}
} @else {
@if $position == start {
@include button-edge(left);
} @else {
@include button-edge(right);
border-left-color: transparent;
}
}
}
@mixin toolbar-button($size: sm) {
@include icon-button($size);
display: inline-grid;
place-items: center;
font-size: var(--font-size-sm);
font-weight: 900;
box-shadow: none;
}
@mixin toolbar-button-icon($size: sm) {
@include icon-button-icon($size);
}
@mixin toolbar-button-hover {
@include icon-button-hover;
}
@mixin toolbar-button-active {
@include icon-button-active;
}

View file

@ -0,0 +1,90 @@
// Rôle : regroupe les mixins de cases à cocher.
@mixin checkbox-control($size: md) {
$control-size: 20px;
$radius: 3px;
@if $size == xs {
$control-size: 14px;
} @else if $size == sm {
$control-size: 18px;
}
appearance: none;
-webkit-appearance: none;
display: inline-grid;
width: $control-size;
min-width: $control-size;
max-width: $control-size;
inline-size: $control-size;
min-inline-size: $control-size;
max-inline-size: $control-size;
aspect-ratio: 1;
height: $control-size;
min-height: $control-size;
max-height: $control-size;
block-size: $control-size;
min-block-size: $control-size;
max-block-size: $control-size;
place-content: center;
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: $radius;
line-height: 0;
background:
linear-gradient(135deg, rgba(27, 34, 66, 0.92), rgba(16, 20, 44, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.5), rgba(139, 92, 246, 0.44)) border-box;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 0 0 1px rgba(5, 7, 17, 0.42);
cursor: pointer;
}
@mixin checkbox-check($size: md) {
$check-size: 11px;
@if $size == xs {
$check-size: 8px;
} @else if $size == sm {
$check-size: 10px;
}
content: "";
width: $check-size;
height: $check-size;
background: #fff;
transform: scale(0);
transition: transform var(--duration-fast) var(--ease-standard);
clip-path: polygon(14% 52%, 0 66%, 38% 100%, 100% 18%, 84% 5%, 34% 68%);
}
@mixin checkbox-hover {
border-color: rgba(246, 196, 83, 0.6);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.04),
0 0 14px rgba(246, 196, 83, 0.14);
}
@mixin checkbox-checked {
border-color: rgba(246, 196, 83, 0.88);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.96), rgba(139, 92, 246, 0.86)) padding-box,
linear-gradient(135deg, var(--color-accent-gold), rgba(196, 181, 253, 0.88)) border-box;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
0 0 16px rgba(246, 196, 83, 0.2);
}
@mixin checkbox-checked-check {
transform: scale(1);
}
@mixin checkbox-disabled {
border-color: rgba(165, 180, 252, 0.08);
background:
linear-gradient(135deg, rgba(148, 163, 184, 0.08), rgba(51, 65, 85, 0.12)) padding-box,
linear-gradient(135deg, rgba(148, 163, 184, 0.16), rgba(99, 102, 241, 0.08)) border-box;
box-shadow: none;
cursor: not-allowed;
opacity: 0.48;
}

View file

@ -0,0 +1,40 @@
// Rôle : regroupe les mixins de surfaces événement calendrier.
@mixin event-surface-button {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
align-content: start;
padding: 5px 7px;
border: 0;
border-radius: 0;
background: transparent;
color: white;
cursor: pointer;
text-align: left;
}
@mixin event-surface-contrast-default {
color: white;
span {
color: rgba(255, 255, 255, 0.86);
opacity: 1;
}
}
@mixin event-surface-contrast-dark {
color: rgba(11, 14, 27, 0.92);
span {
color: rgba(11, 14, 27, 0.76);
opacity: 1;
}
}
@mixin event-surface-button-hover {
background: rgba(0, 0, 0, 0.14);
box-shadow: none;
}

View file

@ -0,0 +1,453 @@
// Rôle : regroupe les mixins de champs texte, select, textarea et champs inline.
@mixin select-control($size: md) {
$height: 44px;
$padding-right: 40px;
$padding-left: 14px;
$border-alpha: 0.12;
$chevron-offset: 14px;
@if $size == sm {
$height: 36px;
$padding-right: 34px;
$padding-left: 12px;
$border-alpha: 0.14;
$chevron-offset: 10px;
} @else if $size == xs {
$height: 32px;
$padding-right: 30px;
$padding-left: 10px;
$border-alpha: 0.14;
$chevron-offset: 9px;
}
min-height: $height;
overflow: hidden;
padding: 0 $padding-right 0 $padding-left;
border: 1px solid rgba(165, 180, 252, $border-alpha);
border-radius: var(--radius-md);
appearance: none;
-webkit-appearance: none;
color: var(--color-text-primary);
font: inherit;
text-overflow: ellipsis;
background-color: rgba(21, 26, 48, 0.86);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.42), rgba(21, 26, 48, 0.42));
background-position:
calc(100% - #{$chevron-offset}) 50%,
0 0,
0 0;
background-size:
11px 11px,
auto,
auto;
background-repeat: no-repeat;
}
@mixin select-control-hover {
border-color: rgba(196, 181, 253, 0.22);
background-color: rgba(21, 26, 48, 0.92);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
color: var(--color-text-primary);
}
@mixin select-trigger($size: md) {
$height: 44px;
$padding-right: 40px;
$padding-left: 12px;
$icon-size: 16px;
$font-size: inherit;
$border-alpha: 0.12;
$chevron-offset: 14px;
$chevron-size: 11px;
@if $size == sm {
$height: 36px;
$padding-right: 34px;
$padding-left: 10px;
$icon-size: 14px;
$font-size: var(--font-size-sm);
$border-alpha: 0.14;
$chevron-offset: 10px;
} @else if $size == xs {
$height: 32px;
$padding-right: 30px;
$padding-left: 8px;
$icon-size: 13px;
$font-size: var(--font-size-xs);
$border-alpha: 0.14;
$chevron-offset: 9px;
}
position: relative;
display: grid;
grid-template-columns: $icon-size minmax(0, 1fr);
align-items: center;
gap: 6px;
width: 100%;
min-height: $height;
padding: 0 $padding-right 0 $padding-left;
border: 1px solid rgba(165, 180, 252, $border-alpha);
border-radius: var(--radius-md);
background-color: rgba(21, 26, 48, 0.86);
background-image:
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.42), rgba(21, 26, 48, 0.42));
background-position:
0 0,
0 0;
background-size:
auto,
auto;
background-repeat: no-repeat;
color: var(--color-text-primary);
font: inherit;
font-size: $font-size;
font-weight: 900;
font-variant-numeric: tabular-nums;
text-align: left;
transition:
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
&::after {
content: "";
position: absolute;
top: 50%;
right: $chevron-offset;
width: $chevron-size;
height: $chevron-size;
background-color: #c4b5fd;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E") center / contain no-repeat;
pointer-events: none;
transform: translateY(-50%);
transition: background-color var(--duration-fast) var(--ease-standard);
}
}
@mixin select-trigger-hover($size: md) {
border-color: rgba(196, 181, 253, 0.22);
background-color: rgba(21, 26, 48, 0.92);
background-image:
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
background-position: 0 0, 0 0;
background-size: auto, auto;
background-repeat: no-repeat;
color: var(--color-text-primary);
&::after {
background-color: var(--color-accent-gold);
}
}
@mixin select-trigger-icon($size: md) {
$icon-size: 16px;
@if $size == sm {
$icon-size: 14px;
} @else if $size == xs {
$icon-size: 13px;
}
width: $icon-size;
height: $icon-size;
color: var(--color-text-secondary);
}
@mixin select-option {
background: #11182f;
color: var(--color-text-primary);
}
@mixin field-control($size: md) {
$height: 44px;
$padding: 0 14px;
$font-size: inherit;
@if $size == sm {
$height: 36px;
$padding: 0 10px;
$font-size: var(--font-size-sm);
} @else if $size == xs {
$height: 32px;
$padding: 0 8px;
$font-size: var(--font-size-xs);
}
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: $height;
padding: $padding;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
rgba(21, 26, 48, 0.38);
color: var(--color-text-primary);
font: inherit;
font-size: $font-size;
transition:
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
@mixin field-textarea($size: md) {
$min-height: 108px;
$padding: 14px 20px 20px 14px;
@if $size == sm {
$min-height: 88px;
$padding: 12px 16px 16px 10px;
} @else if $size == xs {
$min-height: 72px;
$padding: 10px 14px 14px 8px;
}
min-height: $min-height;
padding: $padding;
overflow: auto;
resize: vertical;
scrollbar-gutter: stable;
}
@mixin field-textarea-ghost {
border-color: transparent;
background: transparent;
box-shadow: none;
color: inherit;
}
@mixin field-placeholder {
color: var(--color-text-muted);
opacity: 0.58;
}
@mixin field-hover {
border-color: rgba(196, 181, 253, 0.22);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.04), rgba(139, 92, 246, 0.08)),
rgba(21, 26, 48, 0.5);
}
@mixin field-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}
@mixin time-control($size: md) {
@include field-control($size);
padding-right: 8px;
color-scheme: dark;
font-variant-numeric: tabular-nums;
&::-webkit-calendar-picker-indicator {
width: 16px;
height: 16px;
margin-left: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
filter: invert(77%) sepia(14%) saturate(810%) hue-rotate(209deg) brightness(99%) contrast(92%);
opacity: 0.82;
transition:
background-color var(--duration-fast) var(--ease-standard),
filter var(--duration-fast) var(--ease-standard),
opacity var(--duration-fast) var(--ease-standard);
}
&::-webkit-datetime-edit {
min-width: 0;
color: var(--color-text-primary);
}
&::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
&::-webkit-datetime-edit-hour-field,
&::-webkit-datetime-edit-minute-field,
&::-webkit-datetime-edit-ampm-field {
border-radius: 4px;
}
&::-webkit-datetime-edit-hour-field:focus,
&::-webkit-datetime-edit-minute-field:focus,
&::-webkit-datetime-edit-ampm-field:focus {
background: rgba(139, 92, 246, 0.28);
color: var(--color-text-primary);
}
&:hover::-webkit-calendar-picker-indicator,
&:focus::-webkit-calendar-picker-indicator {
background-color: rgba(246, 196, 83, 0.12);
filter: invert(80%) sepia(77%) saturate(455%) hue-rotate(346deg) brightness(101%) contrast(93%);
opacity: 1;
}
&:disabled::-webkit-calendar-picker-indicator {
cursor: not-allowed;
opacity: 0.42;
}
}
@mixin field-disabled {
border-color: rgba(165, 180, 252, 0.08);
background:
linear-gradient(135deg, rgba(148, 163, 184, 0.05), transparent 62%),
rgba(15, 23, 42, 0.42);
box-shadow: none;
color: rgba(203, 213, 225, 0.66);
cursor: not-allowed;
}
@mixin inline-field($size: md) {
$height: 30px;
$padding: 5px 8px;
$radius: var(--radius-md);
@if $size == sm {
$height: 28px;
$padding: 4px 7px;
$radius: var(--radius-sm);
} @else if $size == xs {
$height: 26px;
$padding: 3px 6px;
$radius: var(--radius-sm);
}
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: $height;
padding: $padding;
overflow: hidden;
border: 1px solid transparent;
border-radius: $radius;
background: transparent;
color: var(--color-text-primary);
font: inherit;
text-overflow: ellipsis;
white-space: nowrap;
transition:
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
@mixin inline-field-hover {
border-color: rgba(246, 196, 83, 0.34);
background: rgba(21, 26, 48, 0.42);
}
@mixin inline-textarea {
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
resize: vertical;
}
@mixin table-header-field {
width: 100%;
padding: 0 8px;
border-top: 0;
border-left: 0;
border-radius: 0;
text-align: center;
}
@mixin table-header-field-focus {
z-index: 5;
border-color: rgba(246, 196, 83, 0.38);
background: rgba(21, 26, 48, 0.8);
color: var(--color-text-primary);
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18);
outline: 0;
}
@mixin table-cell-field {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 6px 8px;
border: 0;
border-right: 1px solid rgba(165, 180, 252, 0.08);
border-bottom: 1px solid rgba(165, 180, 252, 0.08);
border-radius: 0;
background: rgba(16, 20, 38, 0.34);
color: var(--color-text-primary);
caret-color: transparent;
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
@mixin table-cell-field-focus {
position: relative;
z-index: 4;
border-color: rgba(34, 211, 238, 0.42);
background:
linear-gradient(135deg, rgba(34, 211, 238, 0.08), rgba(139, 92, 246, 0.06)),
rgba(21, 26, 48, 0.58);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.58);
outline: 0;
}
@mixin table-cell-field-editing-focus {
border-color: rgba(246, 196, 83, 0.38);
background: rgba(21, 26, 48, 0.72);
caret-color: auto;
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18);
}
@mixin time-parts-control($size: md) {
$height: 44px;
$part-height: 42px;
$column-min: 34px;
@if $size == sm {
$height: 38px;
$part-height: 36px;
$column-min: 22px;
}
display: grid;
grid-template-columns: minmax($column-min, 1fr) 4px minmax($column-min, 1fr) 4px minmax($column-min, 1fr);
align-items: center;
min-height: $height;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(15, 23, 42, 0.64);
input {
width: auto;
min-width: 0;
min-height: $part-height;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
text-align: center;
box-shadow: none;
}
}
@mixin time-parts-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}

View file

@ -0,0 +1,254 @@
// Rôle : regroupe les mixins de menus compacts, swatches et pickers.
@use "buttons";
@use "fields";
@mixin date-picker-button($size: md) {
$height: 22px;
$padding: 0;
$font-size: 11px;
@if $size == header {
$height: 24px;
$padding: 0 6px;
$font-size: 10px;
} @else if $size == weekday {
$height: 26px;
$padding: 0 3px;
$font-size: 10px;
}
display: grid;
min-width: 0;
min-height: $height;
place-items: center;
padding: $padding;
overflow: hidden;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: rgba(16, 20, 38, 0.58);
color: var(--color-text-secondary);
font-size: $font-size;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin date-picker-button-hover {
border-color: rgba(246, 196, 83, 0.58);
background: rgba(139, 92, 246, 0.3);
color: var(--color-text-primary);
box-shadow: none;
}
@mixin date-picker-trigger {
@include fields.select-trigger(sm);
}
@mixin date-picker-trigger-hover {
@include fields.select-trigger-hover;
}
@mixin date-picker-trigger-icon {
@include fields.select-trigger-icon(sm);
}
@mixin date-picker-trigger-chevron {
display: none;
}
@mixin compact-menu-button($size: md) {
$button-size: 42px;
$icon-size: 26px;
@if $size == sm {
$button-size: 34px;
$icon-size: 21px;
}
@include buttons.icon-button;
width: $button-size;
min-width: $button-size;
height: $button-size;
min-height: $button-size;
img,
.ui-icon {
width: $icon-size;
height: $icon-size;
}
}
@mixin compact-menu-button-hover {
@include buttons.icon-button-hover;
}
@mixin floating-control-menu($columns: null, $item-size: 34px) {
display: grid;
gap: 6px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.99);
box-shadow: var(--shadow-lg);
@if $columns {
grid-template-columns: repeat($columns, $item-size);
}
}
@mixin color-swatch($size: 34px) {
@include buttons.icon-button(sm);
width: $size;
min-width: $size;
min-height: $size;
padding: 0;
box-shadow: none;
&::before {
content: "";
width: max(12px, $size - 16px);
height: max(12px, $size - 16px);
border: 1px solid rgba(245, 247, 255, 0.22);
border-radius: 50%;
background: var(--swatch-color, var(--goal-tree-swatch-color, var(--color-accent-gold)));
box-shadow: inset 0 0 0 2px rgba(5, 7, 17, 0.36);
}
}
@mixin color-swatch-rainbow($size: 34px) {
@include color-swatch($size);
&::before {
width: max(14px, $size - 14px);
height: max(14px, $size - 14px);
border-color: rgba(245, 247, 255, 0.28);
background: conic-gradient(
from 20deg,
#f6c453,
#22d3ee,
#8b5cf6,
#d946ef,
#f5f7ff,
#f6c453
);
box-shadow:
inset 0 0 0 3px rgba(5, 7, 17, 0.34),
0 0 10px rgba(139, 92, 246, 0.16);
}
}
@mixin color-swatch-active {
@include buttons.icon-button-active;
}
@mixin large-icon-picker-trigger {
display: grid;
width: 100px;
min-width: 100px;
min-height: 100px;
place-items: center;
padding: 0;
border: 1px solid var(--color-primary-border);
border-radius: var(--radius-md);
background:
radial-gradient(circle at 50% 35%, rgba(246, 196, 83, 0.18), transparent 44%),
rgba(21, 26, 48, 0.86);
color: var(--color-text-primary);
}
@mixin large-icon-picker-trigger-hover {
border-color: rgba(246, 196, 83, 0.42);
background:
radial-gradient(circle at 50% 35%, rgba(246, 196, 83, 0.24), transparent 44%),
rgba(31, 37, 68, 0.94);
box-shadow: 0 0 18px rgba(139, 92, 246, 0.18);
}
@mixin position-toggle {
position: relative;
display: inline-grid;
grid-template-columns: repeat(2, 32px);
width: 74px;
min-height: 38px;
align-items: center;
justify-items: center;
gap: 0;
padding: 3px 4px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.32);
color: var(--color-text-secondary);
&::before {
content: "";
position: absolute;
top: 3px;
bottom: 3px;
left: 4px;
width: 30px;
border: 1px solid rgba(246, 196, 83, 0.3);
border-radius: 50%;
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.1), rgba(139, 92, 246, 0.08)),
rgba(21, 26, 48, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
transition: transform var(--duration-fast) var(--ease-standard);
}
&[data-position="right"]::before {
transform: translateX(32px);
}
span {
position: relative;
z-index: 1;
display: inline-grid;
width: 30px;
height: 30px;
place-items: center;
justify-self: center;
align-self: center;
border-radius: 50%;
}
.ui-icon {
width: 16px;
height: 16px;
}
}
@mixin position-toggle-hover {
border-color: rgba(196, 181, 253, 0.24);
color: var(--color-text-primary);
}
@mixin edge-toggle {
@include buttons.icon-button;
@include buttons.icon-button-panel-toggle;
@include buttons.button-edge(right);
}
@mixin edge-toggle-hover {
@include buttons.icon-button-panel-toggle-hover;
}
@mixin split-button-start {
@include buttons.button-split(start);
}
@mixin split-button-end {
justify-content: center;
padding: 0;
@include buttons.button-split(end);
}
@mixin dashed-action-button {
@include buttons.text-button;
@include buttons.text-button-dashed;
font-size: var(--font-size-sm);
}
@mixin dashed-action-button-hover {
@include buttons.text-button-dashed-hover;
}

View file

@ -0,0 +1,41 @@
// Rôle : regroupe les mixins de scrollbar et resize handle.
@mixin scrollbar-control {
scrollbar-color: rgb(88, 55, 174) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
border-radius: 0;
background-color: transparent;
}
&::-webkit-scrollbar-track {
border-radius: 0;
background-color: transparent;
box-shadow: none;
}
&::-webkit-scrollbar-thumb {
border-radius: var(--radius-pill);
background: linear-gradient(180deg, rgb(88, 55, 174), rgb(48, 30, 118));
}
&::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(246, 196, 83, 0.9), rgb(88, 55, 174));
}
&::-webkit-scrollbar-corner {
background: transparent;
}
}
@mixin scrollbar-resizer {
&::-webkit-resizer {
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M4 10L10 4M7 10L10 7' fill='none' stroke='%23f6c453' stroke-width='1.5' stroke-linecap='round' opacity='.82'/%3E%3Cpath d='M2 10L10 2' fill='none' stroke='%23c4b5fd' stroke-width='1' stroke-linecap='round' opacity='.34'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: calc(50% - 2px) calc(50% - 2px);
background-size: 12px 12px;
}
}

View file

@ -0,0 +1,116 @@
// Rôle : regroupe les mixins de steppers numériques et reset de spinner.
@mixin quantity-stepper($size: md) {
$button-size: 28px;
$value-min: 52px;
$gap: 6px;
$font-size: var(--font-size-sm);
@if $size == sm {
$button-size: 18px;
$value-min: 34px;
$gap: 2px;
$font-size: 0.68rem;
} @else if $size == xs {
$button-size: 16px;
$value-min: 30px;
$gap: 2px;
$font-size: 0.64rem;
}
display: inline-grid;
grid-template-columns: $button-size minmax($value-min, auto) $button-size;
flex: 0 0 auto;
align-items: center;
gap: $gap;
color: var(--color-text-secondary);
font-size: $font-size;
font-weight: 800;
white-space: nowrap;
}
@mixin quantity-stepper-button($size: md) {
$button-size: 28px;
@if $size == sm {
$button-size: 18px;
} @else if $size == xs {
$button-size: 16px;
}
display: inline-grid;
width: $button-size;
min-width: $button-size;
height: $button-size;
min-height: $button-size;
place-items: center;
padding: 0;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.88);
color: var(--color-text-secondary);
line-height: 1;
}
@mixin quantity-stepper-button-hover {
border-color: rgba(196, 181, 253, 0.32);
background:
linear-gradient(135deg, rgba(31, 37, 68, 0.94), rgba(18, 22, 44, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(196, 181, 253, 0.2)) border-box;
color: var(--color-text-primary);
box-shadow:
0 0 10px rgba(139, 92, 246, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
@mixin quantity-stepper-button-icon($size: md) {
$icon-size: 16px;
@if $size == sm {
$icon-size: 10px;
} @else if $size == xs {
$icon-size: 9px;
}
width: $icon-size;
height: $icon-size;
flex: 0 0 $icon-size;
}
@mixin quantity-stepper-value {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--color-text-secondary);
text-align: center;
}
@mixin quantity-stepper-input {
box-sizing: border-box;
width: max(18px, calc((var(--qty-current-digits, 1) * 0.72rem) + 8px));
min-height: 28px;
padding: 0 4px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-primary);
font: inherit;
text-align: right;
-moz-appearance: textfield;
}
@mixin quantity-stepper-input-hover {
border-color: rgba(246, 196, 83, 0.34);
background: rgba(21, 26, 48, 0.42);
}
@mixin number-spinner-reset {
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
margin: 0;
appearance: none;
-webkit-appearance: none;
}
}

View file

@ -0,0 +1,275 @@
// Rôle : regroupe les mixins de switchs, radio groups et toggles composés.
@use "../../mixins";
@use "buttons";
@use "fields";
@use "steppers";
@mixin switch-control($size: md) {
$icon-size: 16px;
$track-width: 34px;
$track-height: 18px;
$thumb-size: 12px;
$thumb-left: 3px;
$active-left: 17px;
$height: 34px;
$gap: 8px;
$padding: 0 6px 0 10px;
$radius: var(--radius-pill);
$font-size: var(--font-size-sm);
@if $size == lg {
$track-width: 30px;
$track-height: 16px;
$thumb-size: 10px;
$active-left: 15px;
$height: 40px;
$gap: 7px;
$padding: 0 7px 0 10px;
} @else if $size == sm {
$icon-size: 13px;
$track-width: 24px;
$track-height: 14px;
$thumb-size: 8px;
$thumb-left: 2px;
$active-left: 12px;
$height: 28px;
$gap: 5px;
$padding: 0 5px 0 8px;
$font-size: var(--font-size-xs);
} @else if $size == xs {
$icon-size: 13px;
$track-width: 18px;
$track-height: 12px;
$thumb-size: 8px;
$thumb-left: 2px;
$active-left: 7px;
$height: 26px;
$gap: 3px;
$padding: 0 4px;
$radius: var(--radius-sm);
$font-size: var(--font-size-xs);
}
--nds-switch-icon-size: #{$icon-size};
--nds-switch-track-width: #{$track-width};
--nds-switch-track-height: #{$track-height};
--nds-switch-thumb-size: #{$thumb-size};
--nds-switch-thumb-left: #{$thumb-left};
--nds-switch-active-left: #{$active-left};
display: inline-grid;
grid-template-columns: $icon-size $track-width;
width: fit-content;
min-height: $height;
align-items: center;
gap: $gap;
padding: $padding;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: $radius;
background: rgba(5, 7, 17, 0.24);
color: var(--color-text-secondary);
font-size: $font-size;
font-weight: 800;
}
@mixin switch-icon {
width: var(--nds-switch-icon-size);
height: var(--nds-switch-icon-size);
}
@mixin switch-track {
position: relative;
display: block;
width: var(--nds-switch-track-width);
height: var(--nds-switch-track-height);
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.72);
}
@mixin switch-thumb {
content: "";
position: absolute;
top: 50%;
left: var(--nds-switch-thumb-left);
width: var(--nds-switch-thumb-size);
height: var(--nds-switch-thumb-size);
border-radius: 50%;
background: var(--color-text-muted);
transform: translateY(-50%);
transition:
left var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
@mixin switch-hover {
@include buttons.icon-button-hover;
}
@mixin switch-active {
@include mixins.temporary-toggle-active;
}
@mixin switch-active-track {
border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.12);
}
@mixin switch-active-thumb {
left: var(--nds-switch-active-left);
background: var(--color-accent-gold);
box-shadow: 0 0 10px rgba(246, 196, 83, 0.28);
}
@mixin native-switch-label {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
cursor: pointer;
}
@mixin native-switch-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
@mixin radio-button-group-surface($size: md) {
$column-min: 36px;
$gap: 4px;
$padding: 4px;
$radius: var(--radius-md);
$background: rgba(5, 7, 17, 0.38);
@if $size == sm {
$column-min: 30px;
$gap: 3px;
$padding: 3px;
} @else if $size == xs {
$column-min: 24px;
$gap: 0;
$padding: 2px;
$radius: var(--radius-sm);
$background: rgba(5, 7, 17, 0.26);
}
--nds-radio-button-group-column-min: #{$column-min};
gap: $gap;
max-width: 100%;
padding: $padding;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: $radius;
background: $background;
}
@mixin radio-button-group($size: md) {
@include radio-button-group-surface($size);
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: minmax(var(--nds-radio-button-group-column-min), 1fr);
}
@mixin radio-button-group-option($size: md) {
$height: 34px;
$padding: 0 10px;
$radius: calc(var(--radius-md) - 2px);
$font-size: var(--font-size-sm);
$icon-size: 16px;
@if $size == sm {
$height: 28px;
$padding: 0;
$font-size: var(--font-size-xs);
$icon-size: 19px;
} @else if $size == xs {
$height: 26px;
$padding: 0;
$radius: var(--radius-sm);
$font-size: var(--font-size-xs);
$icon-size: 13px;
}
--nds-radio-button-group-option-icon-size: #{$icon-size};
display: inline-flex;
min-width: 0;
min-height: $height;
align-items: center;
justify-content: center;
gap: 8px;
padding: $padding;
border: 1px solid transparent;
border-radius: $radius;
background: transparent;
color: var(--color-text-secondary);
font-size: $font-size;
font-weight: 900;
> .ui-icon {
width: var(--nds-radio-button-group-option-icon-size);
height: var(--nds-radio-button-group-option-icon-size);
min-width: var(--nds-radio-button-group-option-icon-size);
min-height: var(--nds-radio-button-group-option-icon-size);
flex: 0 0 var(--nds-radio-button-group-option-icon-size);
}
}
@mixin radio-button-group-option-compact-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin radio-button-group-option-hover {
border-color: rgba(196, 181, 253, 0.22);
background: rgba(21, 26, 48, 0.5);
color: var(--color-text-primary);
box-shadow: none;
}
@mixin radio-button-group-option-active {
@include mixins.temporary-toggle-active;
}
@mixin radio-button-group-option-icon($size: md) {
$icon-size: 16px;
@if $size == sm {
$icon-size: 19px;
} @else if $size == xs {
$icon-size: 13px;
}
--nds-radio-button-group-option-icon-size: #{$icon-size};
width: var(--nds-radio-button-group-option-icon-size);
height: var(--nds-radio-button-group-option-icon-size);
min-width: var(--nds-radio-button-group-option-icon-size);
min-height: var(--nds-radio-button-group-option-icon-size);
flex: 0 0 var(--nds-radio-button-group-option-icon-size);
}
@mixin compound-toggle-field {
@include radio-button-group(xs);
align-items: center;
grid-auto-columns: max-content;
}
@mixin compound-toggle-field-option {
@include radio-button-group-option(xs);
min-width: 50px;
}
@mixin compound-toggle-field-input {
@include fields.field-control(xs);
@include steppers.number-spinner-reset;
width: 34px;
min-height: 26px;
padding: 0 4px;
border-color: transparent;
border-radius: var(--radius-sm);
background: transparent;
text-align: center;
}

View file

@ -1,4 +1,5 @@
// Rôle : regroupe les cartes et surfaces d'entrée des pages jeux. // Rôle : regroupe les cartes et surfaces d'entrée des pages jeux.
@use "../design-system/surfaces";
.game-home-grid { .game-home-grid {
display: grid; display: grid;
@ -9,15 +10,10 @@
} }
.game-home-card { .game-home-card {
@include surfaces.card-surface;
overflow: hidden; overflow: hidden;
border-color: transparent;
border-radius: var(--radius-md);
padding: 0; padding: 0;
min-height: 230px; min-height: 230px;
background:
linear-gradient(rgba(16, 20, 38, 0.88), rgba(16, 20, 38, 0.88)) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.42), transparent 28%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.62) 0%, rgba(246, 196, 83, 0.42) 72%, rgba(34, 211, 238, 0.24) 100%) border-box;
} }
@supports (corner-shape: scoop) { @supports (corner-shape: scoop) {
@ -27,15 +23,8 @@
} }
.game-home-card:hover { .game-home-card:hover {
@include surfaces.card-surface-hover;
transform: none; transform: none;
border-color: transparent;
background:
linear-gradient(rgba(21, 26, 48, 0.96), rgba(21, 26, 48, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.9), rgba(196, 181, 253, 0.72)) border-box;
box-shadow:
var(--shadow-sm),
0 0 18px rgba(139, 92, 246, 0.22),
0 0 34px rgba(196, 181, 253, 0.12);
} }
.game-home-card img { .game-home-card img {
@ -44,7 +33,7 @@
height: 120px; height: 120px;
object-fit: contain; object-fit: contain;
padding: var(--space-4); padding: var(--space-4);
border-bottom: 1px solid rgba(246, 196, 83, 0.42); @include surfaces.card-cover-divider;
background: rgba(5, 7, 17, 0.28); background: rgba(5, 7, 17, 0.28);
} }
@ -53,7 +42,7 @@
width: 100%; width: 100%;
height: 120px; height: 120px;
place-items: center; place-items: center;
border-bottom: 1px solid rgba(246, 196, 83, 0.42); @include surfaces.card-cover-divider;
background: rgba(5, 7, 17, 0.28); background: rgba(5, 7, 17, 0.28);
color: #fff; color: #fff;
} }

View file

@ -1,4 +1,4 @@
// Rôle : stylise l'outil Memos, ses post-it et sa bande déroulante. // Rôle : stylise l'outil Mémos, ses post-it et sa bande déroulante.
.memos-module { .memos-module {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,7 @@
} }
.goal-tree-node-hover-actions button { .goal-tree-node-hover-actions button {
@include controls.icon-button(tiny); @include controls.icon-button(xxs);
pointer-events: none; pointer-events: none;
} }
@ -90,7 +90,7 @@
} }
.goal-tree-node-hover-actions .ui-icon { .goal-tree-node-hover-actions .ui-icon {
@include controls.icon-button-icon(tiny); @include controls.icon-button-icon(xxs);
} }
.goal-tree-node.is-text, .goal-tree-node.is-text,

View file

@ -0,0 +1,161 @@
// Rôle : définit la structure commune de l'outil Randomizer.
.randomizer-tool {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.randomizer-tabs {
justify-self: stretch;
width: min(100%, 560px);
}
.randomizer-tabs.tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.randomizer-tabs button {
min-height: 42px;
padding-inline: 14px;
font-size: var(--font-size-sm);
}
.randomizer-tabs .module-icon-svg {
width: 18px;
height: 18px;
}
.randomizer-panel {
display: grid;
gap: var(--space-4);
}
.randomizer-dice-panel,
.randomizer-wheel-panel {
gap: var(--space-5);
}
.randomizer-wheel-panel {
gap: var(--space-3);
}
.randomizer-controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
}
.randomizer-controls label,
.randomizer-field {
display: grid;
gap: 6px;
min-width: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.randomizer-controls label {
min-width: 96px;
}
.randomizer-field > .nds-input,
.randomizer-field > .nds-select,
.randomizer-field > .nds-textarea {
width: 100%;
min-width: 0;
}
.randomizer-wheel-stage {
display: grid;
justify-items: center;
gap: var(--space-4);
min-width: 0;
}
.randomizer-dice-stage,
.randomizer-wheel-stage {
display: grid;
justify-items: center;
gap: var(--space-3);
min-width: 0;
}
.randomizer-live-results {
display: grid;
align-items: start;
justify-items: center;
width: min(100%, 560px);
min-height: 46px;
padding-block: 4px;
}
.randomizer-live-results .randomizer-results {
justify-content: center;
max-height: 84px;
}
.randomizer-wheel-results {
width: 100%;
min-height: 38px;
padding-block: 0;
}
.randomizer-dice-stage .randomizer-live-results .randomizer-chip {
width: 44px;
justify-content: center;
padding-inline: 0;
font-variant-numeric: tabular-nums;
}
.randomizer-wheel-results .randomizer-chip {
width: 112px;
justify-content: center;
}
.randomizer-dice-grid,
.randomizer-wheel-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
width: 100%;
gap: var(--space-4);
align-items: start;
min-width: 0;
}
.randomizer-dice-grid > .randomizer-dice-form,
.randomizer-dice-grid > .randomizer-history,
.randomizer-wheel-grid > .randomizer-wheel-form,
.randomizer-wheel-grid > .randomizer-wheel-values-section,
.randomizer-wheel-grid > .randomizer-history {
width: 100%;
justify-self: stretch;
}
.randomizer-wheel-grid > .randomizer-history {
grid-column: 1 / -1;
}
.randomizer-dice-form,
.randomizer-wheel-form,
.randomizer-wheel-values-section {
display: grid;
gap: var(--space-3);
align-content: start;
min-width: 0;
padding: 12px;
}
.randomizer-dice-form .randomizer-controls,
.randomizer-wheel-form .randomizer-controls {
display: grid;
gap: var(--space-3);
}
.randomizer-dice-form .randomizer-controls label,
.randomizer-dice-form .randomizer-controls .nds-button,
.randomizer-wheel-form .randomizer-controls label,
.randomizer-wheel-form .randomizer-controls .nds-button {
width: 100%;
}

View file

@ -0,0 +1,82 @@
// Rôle : porte la visualisation du dé du Randomizer.
.randomizer-die {
position: relative;
display: grid;
width: 116px;
aspect-ratio: 1;
place-items: center;
color: var(--color-text-primary);
font-size: 42px;
font-weight: 900;
line-height: 1;
}
.randomizer-die::before {
position: absolute;
inset: 12px;
border-radius: 999px;
background: radial-gradient(circle, rgba(246, 196, 83, 0.22), transparent 62%);
content: "";
filter: blur(10px);
}
.randomizer-die-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.3));
}
.randomizer-die-shape circle,
.randomizer-die-shape path,
.randomizer-die-shape rect {
fill: rgba(16, 20, 38, 0.92);
stroke: rgba(246, 196, 83, 0.42);
stroke-width: 1.2;
}
.randomizer-die-value {
position: relative;
z-index: 1;
display: block;
width: 3ch;
height: 52px;
min-width: 2ch;
overflow: hidden;
font-variant-numeric: tabular-nums;
text-align: center;
}
.randomizer-die-value-track {
display: grid;
transform: translateY(calc(var(--randomizer-die-index, 0) * -52px));
transition: transform var(--randomizer-die-duration, 120ms) cubic-bezier(0.2, 0.82, 0.25, 1);
will-change: transform;
}
.randomizer-die-value-track span {
display: block;
height: 52px;
line-height: 52px;
}
.randomizer-die.is-rolling {
color: var(--color-accent-gold);
}
.randomizer-die.is-rolling .randomizer-die-shape {
filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.3)) drop-shadow(0 0 18px rgba(246, 196, 83, 0.18));
}
.randomizer-die.is-rolling .randomizer-die-shape circle,
.randomizer-die.is-rolling .randomizer-die-shape path,
.randomizer-die.is-rolling .randomizer-die-shape rect {
stroke: rgba(246, 196, 83, 0.68);
}
.randomizer-die.is-rolling .randomizer-die-value {
color: var(--color-accent-gold);
text-shadow: 0 0 18px rgba(246, 196, 83, 0.28);
}

View file

@ -0,0 +1,209 @@
// Rôle : porte l'historique détaillé du Randomizer.
.randomizer-history {
display: grid;
gap: var(--space-2);
align-content: start;
min-width: 0;
min-height: 132px;
max-height: 420px;
overflow: auto;
padding: 12px;
}
.randomizer-history.nds-scrollbox {
max-height: 420px;
}
.randomizer-history > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 8px;
}
.randomizer-history > header .randomizer-history-clear {
display: inline-flex;
flex: 0 0 auto;
width: auto;
min-width: 0;
justify-content: center;
padding-inline: 10px;
}
.randomizer-history h3 {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.randomizer-history .randomizer-list {
max-height: none;
overflow: visible;
padding: 0;
}
.randomizer-history-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
align-items: start;
min-height: 38px;
min-width: 0;
padding: 0;
overflow: visible;
}
.randomizer-history-row.has-action {
grid-template-columns: minmax(0, 1fr) auto;
}
.randomizer-history-main {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: var(--space-3);
min-width: 0;
min-height: 40px;
padding: 7px 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-sm);
background: rgba(12, 15, 30, 0.72);
}
.randomizer-history-value {
display: block;
min-width: 0;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 800;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
line-height: 1.22;
}
.randomizer-history-row.is-latest .randomizer-history-main {
border-color: rgba(246, 196, 83, 0.68);
background:
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 36%),
rgba(12, 15, 30, 0.78);
}
.randomizer-history-row.is-latest .randomizer-history-value {
color: var(--color-accent-gold);
}
.randomizer-history-row time {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.22;
white-space: nowrap;
}
.randomizer-history-accordion {
display: block;
padding: 0;
}
.randomizer-history-accordion > summary {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) max-content;
align-items: center;
gap: var(--space-3);
min-width: 0;
min-height: 40px;
padding: 7px 12px;
cursor: pointer;
list-style: none;
}
.randomizer-history-accordion > summary .ui-icon {
width: 16px;
height: 16px;
background: currentColor;
color: var(--color-text-muted);
transition: transform 160ms var(--ease-standard);
}
.randomizer-history-accordion[open] > summary .ui-icon {
transform: rotate(180deg);
}
.randomizer-history-accordion > summary::-webkit-details-marker {
display: none;
}
.randomizer-history-teams {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 0 12px 12px;
}
.randomizer-history-team {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
gap: 4px 8px;
min-width: 0;
padding-left: 10px;
border-left: 1px solid rgba(165, 180, 252, 0.18);
}
.randomizer-history-team:nth-child(4n + 1) {
padding-left: 0;
border-left: 0;
}
.randomizer-history-teams.has-team-rows .randomizer-history-team:nth-child(4n + 5)::before {
content: "";
position: absolute;
top: -4px;
left: 0;
width: calc(400% + 24px);
border-top: 1px solid rgba(165, 180, 252, 0.14);
}
.randomizer-history-team strong {
min-width: 0;
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-history-team span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
white-space: nowrap;
}
.randomizer-history-team ul {
grid-column: 1 / -1;
margin: 0;
padding: 0;
list-style: none;
}
.randomizer-history-team li {
min-width: 0;
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes randomizer-player-drop {
from {
opacity: 0;
transform: translateY(-34px) scale(1.04);
}
65% {
opacity: 1;
transform: translateY(4px) scale(1);
}
}

View file

@ -0,0 +1,122 @@
// Rôle : regroupe les adaptations responsive et mouvement du Randomizer.
@media (max-width: 920px) {
.randomizer-wheel-stage {
grid-template-columns: 1fr;
justify-items: center;
}
.randomizer-wheel-spacer {
display: none;
}
.randomizer-wheel-legend {
width: min(100%, 320px);
min-width: 0;
}
.randomizer-history-teams {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.randomizer-history-team:nth-child(4n + 1) {
padding-left: 10px;
border-left: 1px solid rgba(165, 180, 252, 0.18);
}
.randomizer-history-team:nth-child(2n + 1) {
padding-left: 0;
border-left: 0;
}
.randomizer-history-teams.has-team-rows .randomizer-history-team:nth-child(4n + 5)::before {
content: none;
}
.randomizer-history-teams.has-team-rows .randomizer-history-team:nth-child(2n + 3)::before {
content: "";
position: absolute;
top: -4px;
left: 0;
width: calc(200% + 8px);
border-top: 1px solid rgba(165, 180, 252, 0.14);
}
}
@media (max-width: 720px) {
.randomizer-dice-grid,
.randomizer-wheel-grid,
.randomizer-team-settings-grid,
.randomizer-entry-form,
.randomizer-exclusion-form {
grid-template-columns: 1fr;
}
.randomizer-player-row {
grid-template-columns: 1fr;
}
.randomizer-teams.is-single-team {
grid-template-columns: 1fr;
}
.randomizer-history-row.has-action {
grid-template-columns: minmax(0, 1fr) auto;
}
.randomizer-history-main {
grid-template-columns: 1fr;
gap: 4px;
}
.randomizer-history-accordion > summary {
grid-template-columns: 18px minmax(0, 1fr);
}
.randomizer-history-row time {
justify-self: start;
}
.randomizer-history-teams {
grid-template-columns: 1fr;
}
.randomizer-history-team,
.randomizer-history-team:nth-child(4n + 1),
.randomizer-history-team:nth-child(2n + 1) {
padding-left: 0;
border-left: 0;
}
.randomizer-history-teams.has-team-rows .randomizer-history-team:nth-child(2n + 3)::before {
content: none;
}
.randomizer-history-teams.has-team-rows .randomizer-history-team:nth-child(n + 2)::before {
content: "";
position: absolute;
top: -4px;
left: 0;
width: 100%;
border-top: 1px solid rgba(165, 180, 252, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.randomizer-teams.is-animating .randomizer-team b {
animation: none;
}
.randomizer-wheel {
transition: none;
}
.randomizer-die-value,
.randomizer-team b.is-arriving {
animation: none;
}
.randomizer-die-value-track,
.randomizer-team-reel-track {
transition: none;
}
}

View file

@ -0,0 +1,100 @@
// Rôle : regroupe les cartes et listes partagées du Randomizer.
.randomizer-dice-form,
.randomizer-wheel-form,
.randomizer-wheel-values-section,
.randomizer-team-basic,
.randomizer-team-advanced-section,
.randomizer-history {
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.3);
}
.randomizer-results,
.randomizer-exclusions {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
max-width: 100%;
max-height: 168px;
overflow: auto;
padding: 2px;
}
.randomizer-history > .randomizer-empty {
align-self: center;
margin: 0;
padding: 8px 2px;
}
.randomizer-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
max-width: 100%;
overflow: hidden;
padding: 4px 8px;
border: 1px solid rgba(165, 180, 252, 0.16);
border-radius: var(--radius-pill);
background: rgba(16, 20, 38, 0.72);
color: var(--color-text-primary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.randomizer-value-list {
display: grid;
gap: 8px;
min-width: 0;
max-height: 210px;
overflow: auto;
}
.randomizer-value-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-width: 0;
}
.randomizer-list-header {
min-height: auto;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.randomizer-section-label {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.randomizer-player-row {
grid-template-columns: auto minmax(0, 1fr) minmax(68px, 82px) auto;
}
.randomizer-wheel-value-row {
grid-template-columns: minmax(0, 1fr) minmax(68px, 82px) auto;
}
.randomizer-value-row > .nds-input,
.randomizer-value-row > .nds-select {
width: 100%;
min-width: 0;
}
.randomizer-value-row > .nds-checkbox {
align-self: center;
justify-self: center;
}

View file

@ -0,0 +1,250 @@
// Rôle : porte la création d'équipes et les exclusions du Randomizer.
.randomizer-player-import-form > .randomizer-field,
.randomizer-exclusion-form > .randomizer-field {
width: 100%;
}
.randomizer-player-import-form {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.randomizer-player-import-form > .nds-button {
width: 100%;
}
.randomizer-exclusion-form {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: end;
min-width: 0;
}
.randomizer-exclusion-mode-field {
grid-column: 1 / -1;
}
.randomizer-exclusion-form.is-group-mode > .randomizer-field:not(.randomizer-exclusion-mode-field) {
grid-column: 1 / 3;
}
.randomizer-multi-trigger.nds-select-trigger,
.randomizer-multi-trigger.nds-select-trigger[data-size="sm"] {
grid-template-columns: minmax(0, 1fr);
width: 100%;
color: var(--color-text-primary);
font: inherit;
font-size: inherit;
font-weight: inherit;
}
.randomizer-multi-trigger.nds-select-trigger > .randomizer-multi-trigger-label {
display: block;
width: 100%;
min-width: 0;
max-width: none;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-checkbox-dropdown {
position: relative;
min-width: 0;
width: 100%;
}
.randomizer-checkbox-dropdown > .randomizer-checkbox-menu.nds-scrollbox {
position: absolute;
z-index: 80;
top: calc(100% + 6px);
right: 0;
left: 0;
display: grid;
gap: 6px;
max-height: 156px;
overflow: auto;
width: 100%;
padding: 8px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-sm);
background: rgb(7, 10, 24);
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.32);
color: var(--color-text-secondary);
opacity: 1;
}
.randomizer-checkbox-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 800;
}
.randomizer-checkbox-option span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-team-advanced .randomizer-exclusion-form {
padding: 0;
}
.randomizer-exclusion-block {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.randomizer-list {
display: grid;
gap: 8px;
align-content: start;
grid-auto-rows: minmax(44px, auto);
max-height: 280px;
overflow: auto;
min-width: 0;
padding: 8px;
}
.randomizer-teams-panel {
gap: var(--space-4);
}
.randomizer-team-stage {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.randomizer-teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr));
gap: var(--space-3);
min-width: 0;
}
.randomizer-teams.is-single-team {
grid-template-columns: minmax(0, min(50%, 420px));
justify-content: center;
}
.randomizer-team {
display: grid;
align-content: start;
gap: 8px;
min-height: 118px;
min-width: 0;
max-height: 280px;
overflow: auto;
padding: var(--space-3);
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.3);
}
.randomizer-team h3 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.randomizer-team span,
.randomizer-history-row time,
.randomizer-empty {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.randomizer-team b {
display: block;
min-width: 0;
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-team b.is-arriving {
animation: randomizer-player-drop 420ms var(--ease-standard);
}
.randomizer-team-reel {
display: grid;
height: 34px;
width: min(100%, 340px);
justify-self: center;
place-items: center;
overflow: hidden;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.3);
}
.randomizer-team-reel-track {
display: grid;
width: 100%;
transform: translateY(calc(var(--randomizer-reel-index, 0) * -34px));
transition: transform var(--randomizer-reel-duration, 96ms) cubic-bezier(0.2, 0.82, 0.25, 1);
will-change: transform;
}
.randomizer-team-reel-track span,
.randomizer-team-reel-placeholder {
display: block;
max-width: min(100%, 260px);
height: 34px;
margin-inline: auto;
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
line-height: 34px;
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-team-reel.is-spinning .randomizer-team-reel-track {
filter: drop-shadow(0 0 10px rgba(246, 196, 83, 0.16));
}
.randomizer-team-settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--space-4);
min-width: 0;
}
.randomizer-team-basic,
.randomizer-team-advanced-section {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 12px;
}
.randomizer-team-advanced {
display: grid;
align-content: start;
gap: var(--space-4);
min-width: 0;
}
.randomizer-team-basic .randomizer-controls {
display: grid;
gap: var(--space-3);
}
.randomizer-team-basic .randomizer-controls label,
.randomizer-team-basic .randomizer-controls .nds-button {
width: 100%;
}

View file

@ -0,0 +1,130 @@
// Rôle : porte la roue pondérée du Randomizer.
.randomizer-wheel-stage {
position: relative;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--space-4);
align-items: center;
justify-items: stretch;
min-height: 228px;
align-content: center;
}
.randomizer-wheel-spacer {
display: none;
}
.randomizer-wheel-center {
position: relative;
display: grid;
justify-self: center;
justify-items: center;
gap: var(--space-3);
min-width: 210px;
width: 100%;
}
.randomizer-wheel-pointer {
position: absolute;
top: 2px;
left: 50%;
z-index: 2;
width: 0;
height: 0;
border-right: 10px solid transparent;
border-left: 10px solid transparent;
border-top: 18px solid var(--color-accent-gold);
transform: translateX(-50%);
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.42));
}
.randomizer-wheel {
width: min(100%, 190px);
aspect-ratio: 1;
margin-top: 16px;
border-radius: 50%;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.32);
transform: rotate(var(--randomizer-wheel-rotation, 0deg));
transition: transform 1120ms cubic-bezier(0.15, 0.78, 0.18, 1);
}
.randomizer-wheel-legend {
align-self: center;
justify-self: stretch;
display: grid;
gap: 8px;
align-content: start;
width: 100%;
min-width: 0;
max-height: 238px;
overflow: auto;
padding: 8px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.3);
}
.randomizer-wheel-legend.nds-scrollbox {
max-height: 238px;
}
.randomizer-wheel-legend-row {
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.randomizer-wheel-swatch {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--randomizer-swatch);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.randomizer-wheel-label {
min-width: 0;
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.randomizer-wheel-legend .randomizer-empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.randomizer-wheel.is-spinning {
filter: drop-shadow(0 0 18px rgba(246, 196, 83, 0.18));
}
.randomizer-wheel path {
stroke: rgba(7, 10, 24, 0.74);
stroke-width: 0.8;
}
.randomizer-wheel-form .randomizer-entry-form {
padding: 0;
}
.randomizer-wheel-form .randomizer-entry-form .nds-button {
grid-column: auto;
}
.randomizer-wheel-form .randomizer-entry-form > .randomizer-field,
.randomizer-wheel-form .randomizer-entry-form > .nds-button {
width: 100%;
}
.randomizer-entry-form {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}