From 7b4cfb7dc2485431f7ca2bfd53b6aba1e6de97ea Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 31 Jul 2026 14:46:28 +0200 Subject: [PATCH] add combos tool --- AGENTS.md | 2 + DESIGN_SYSTEM.md | 16 + docs/STORAGE_SCHEMA.md | 65 ++ tests/helpers/data-validation.mjs | 30 + tests/static-toolboxes.test.mjs | 12 + tests/toolbox-modules.test.mjs | 78 +- website/public/data/site.json | 32 + .../src/features/toolboxes/ToolboxPages.jsx | 2 + .../toolboxes/modules/CombosModule.jsx | 807 ++++++++++++++++++ .../src/features/toolboxes/modules/index.jsx | 2 + .../toolboxes/storage/toolboxStorage.js | 104 ++- website/src/styles/_responsive.scss | 19 + website/src/styles/_toolboxes.scss | 662 ++++++++++++++ 13 files changed, 1829 insertions(+), 2 deletions(-) create mode 100644 website/src/features/toolboxes/modules/CombosModule.jsx diff --git a/AGENTS.md b/AGENTS.md index a9356d9..dcaec48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ Quand un outil est ajouté ou modifié : - mettre à jour la structure IndexedDB dans `docs/STORAGE_SCHEMA.md` si nécessaire ; - vérifier l'affichage page toolbox et panneau latéral. +Quand la structure de données d'un outil change, ne pas prévoir de compatibilité legacy, de migration ou de tolérance d'anciens formats, sauf demande explicite. Le format documenté dans `docs/STORAGE_SCHEMA.md` doit rester le format cible strict. + ## Style Le style est en Sass dans `website/src/styles/`. diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 7001041..f6ee504 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -179,6 +179,22 @@ Alertes timer : - le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ; - un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures. +### Combos + +L'outil Combos affiche des inputs de manettes et clavier sous forme de tokens visuels compacts. + +Regles UI : + +- les touches simples sont rendues en HTML/CSS, pas en fichiers SVG distincts ; +- les formes specifiques peuvent utiliser de petits SVG inline ou des formes CSS si le HTML seul devient ambigu ; +- les couleurs de boutons restent des accents localises et ne doivent pas dominer le panneau ; +- les sequences longues doivent revenir a la ligne sans agrandir la largeur de l'outil ; +- l'edition utilise une palette guidee pour les manettes et une palette avec saisie courte pour clavier/souris ; +- l'ajout par clic cree des etapes successives par defaut, sans bouton dedie de nouvelle etape ; +- le clavier utilise AZERTY par defaut et propose un switch QWERTY ; +- le mode simultane doit etre volontaire, via switch ou depot d'une touche dans une etape existante ; +- les categories sont regroupees automatiquement depuis le champ categorie du combo. + ### Bloc Notes Riche Le bloc notes utilise une edition directe en `contentEditable`, sans mode preview separe. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 331bb6a..cde4117 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -425,6 +425,71 @@ Notes : - Les valeurs négatives sont acceptées. - Les compteurs sans libellé ne sont pas conservés. +## Outil Combos + +Type : `combos` + +```json +{ + "device": "playstation", + "combos": [ + { + "id": "combo0", + "name": "Anti air", + "device": "playstation", + "inputs": [ + [ + { "kind": "direction", "value": "down" }, + { "kind": "button", "value": "triangle" } + ] + ] + } + ], + "categories": [ + { + "id": "category1", + "title": "Neutral", + "collapsed": false, + "combos": [ + { + "id": "combo1", + "name": "Hadoken", + "device": "playstation", + "inputs": [ + [{ "kind": "direction", "value": "down" }], + [{ "kind": "direction", "value": "down-forward" }], + [ + { "kind": "direction", "value": "forward" }, + { "kind": "button", "value": "cross" } + ] + ] + } + ] + } + ] +} +``` + +Notes : + +- `device` vaut `playstation`, `xbox`, `switch`, `n64` ou `keyboardMouse`. +- `combos[].device` peut figer le périphérique de rendu d'un combo, afin qu'il conserve ses couleurs si l'outil change de périphérique. +- `inputs` est une liste d'étapes successives. +- chaque étape contient une ou plusieurs entrées simultanées. +- `kind` vaut `button`, `direction`, `key` ou `mouse`. +- `combos` à la racine contient les combos sans catégorie. +- les catégories restent locales à l'outil ; l'utilisateur peut ajouter plusieurs outils Combos pour séparer des usages. + +Stockage compact : + +- `device` est omis si la valeur vaut `playstation`. +- `combos` est omis si aucun combo sans catégorie n'existe. +- `categories` conserve uniquement les catégories avec au moins un combo valide. +- les combos sans nom et sans touche sont supprimés. +- `collapsed` est omis si `false`. +- les titres de catégorie et noms de combo sont limités à 80 caractères. +- chaque combo conserve au maximum 64 étapes. + ## Outil Calculateur Type : `calculator` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 61b076b..fb8d8b9 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -160,6 +160,36 @@ export function validateSiteContent(site) { "toolboxes.modules.counters.incrementLabel", "toolboxes.modules.counters.resetTitle", "toolboxes.modules.counters.deleteTitle", + "toolboxes.modules.combos.deviceLabel", + "toolboxes.modules.combos.keyboardLayoutLabel", + "toolboxes.modules.combos.categoryPlaceholder", + "toolboxes.modules.combos.targetCategoryLabel", + "toolboxes.modules.combos.noCategoryLabel", + "toolboxes.modules.combos.comboNameLabel", + "toolboxes.modules.combos.comboNamePlaceholder", + "toolboxes.modules.combos.defaultComboName", + "toolboxes.modules.combos.emptyDraft", + "toolboxes.modules.combos.simultaneousModeTitle", + "toolboxes.modules.combos.successiveModeLabel", + "toolboxes.modules.combos.simultaneousModeLabel", + "toolboxes.modules.combos.removeStepTitle", + "toolboxes.modules.combos.removeLastInputTitle", + "toolboxes.modules.combos.addComboButton", + "toolboxes.modules.combos.saveComboButton", + "toolboxes.modules.combos.cancelEditTitle", + "toolboxes.modules.combos.cancelEditButton", + "toolboxes.modules.combos.paletteLabel", + "toolboxes.modules.combos.customKeyPlaceholder", + "toolboxes.modules.combos.addCustomKeyButton", + "toolboxes.modules.combos.emptyCombos", + "toolboxes.modules.combos.emptyCategory", + "toolboxes.modules.combos.emptySequence", + "toolboxes.modules.combos.showCategoryTitle", + "toolboxes.modules.combos.hideCategoryTitle", + "toolboxes.modules.combos.reorderCategoryTitle", + "toolboxes.modules.combos.reorderComboTitle", + "toolboxes.modules.combos.editComboTitle", + "toolboxes.modules.combos.deleteComboTitle", "toolboxes.modules.calculator.expressionLabel", "toolboxes.modules.calculator.expressionPlaceholder", "toolboxes.modules.calculator.resultLabel", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index ad292c2..4a57960 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -35,6 +35,7 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /module\.scrollable/); assert.match(toolboxStorage, /export function normalizeLinksData/); assert.match(toolboxStorage, /export function normalizeCountersData/); + assert.match(toolboxStorage, /export function normalizeCombosData/); assert.match(toolboxStorage, /export function normalizeCalculatorData/); assert.match(toolboxStorage, /export function normalizeTableData/); assert.match(toolboxStorage, /export function normalizeTimerData/); @@ -62,6 +63,7 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxPages, /#\/toolbox\/\$\{toolbox\.id\}/); assert.match(toolboxPages, /setDrawerGameId\(""\)/); assert.match(toolboxPages, /moduleColumns/); + assert.match(toolboxPages, /normalizeCombosData/); assert.match(toolboxPages, /function ToolboxView/); assert.match(toolboxPages, /usePointerReorder/); assert.match(storageQuota, /export function StorageQuota/); @@ -77,6 +79,7 @@ test("toolbox module registry and modules expose expected behavior", async () => const source = await readFile("website/src/main.jsx", "utf8"); const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); + const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); @@ -100,6 +103,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /images:/); assert.match(moduleRegistry, /links:/); assert.match(moduleRegistry, /counters:/); + assert.match(moduleRegistry, /combos:/); + assert.match(moduleRegistry, /CombosModule/); assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /table:/); assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); @@ -122,6 +127,13 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /CompactDropdown/); assert.doesNotMatch(source, / setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setDraft(value); + event.currentTarget.blur(); + } + }} + aria-label={ariaLabel} + /> + ); +} + +export function CombosModule({ toolboxId, moduleId, context, editing }) { + const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [], categories: [] })); + const textContent = context.moduleText?.combos || {}; + const [comboCategory, setComboCategory] = useState(""); + const [comboName, setComboName] = useState(""); + const [draftInputs, setDraftInputs] = useState([[]]); + const [activeStepIndex, setActiveStepIndex] = useState(0); + const [simultaneousMode, setSimultaneousMode] = useState(false); + const [keyboardLayout, setKeyboardLayout] = useState("azerty"); + const [customKey, setCustomKey] = useState(""); + const [editingComboId, setEditingComboId] = useState(""); + const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]); + const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]); + const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length); + const { + draggingId: draggingCategoryId, + dropTarget: categoryDropTarget, + startDrag: startCategoryDrag + } = usePointerReorder({ + targetSelector: ".combos-category", + getTargetId: (target) => target.dataset.categoryId, + onMove: moveCategory + }); + const { + draggingId: draggingComboId, + dropTarget: comboDropTarget, + startDrag: startComboDrag + } = usePointerReorder({ + targetSelector: ".combo-card, .combos-root-drop-zone, .combos-category", + getTargetId: (target) => { + if (target.classList.contains("combo-card")) return `combo:${target.dataset.comboId}`; + if (target.classList.contains("combos-category")) return `category:${target.dataset.categoryId}`; + return "root"; + }, + canDropOn: (target, draggingId) => { + if (target.classList.contains("combo-card")) return target.dataset.comboId !== draggingId; + if (target.classList.contains("combos-category")) return !target.querySelector(`[data-combo-id="${draggingId}"]`); + return true; + }, + onMove: moveCombo + }); + + function save(nextData) { + context.setModuleData(toolboxId, moduleId, nextData, "combos"); + } + + function updateDevice(device) { + const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device }; + save({ + ...data, + device, + combos: data.combos.map(stampComboDevice), + categories: data.categories.map((category) => ({ + ...category, + combos: category.combos.map(stampComboDevice) + })) + }); + } + + function moveCategory(fromCategoryId, toCategoryId, placement) { + save({ ...data, categories: moveArrayItem(data.categories, fromCategoryId, toCategoryId, placement) }); + } + + function moveCombo(fromComboId, targetId, placement) { + const { movedCombo, data: dataWithoutCombo } = removeComboFromData(data, fromComboId); + if (!movedCombo) return; + if (targetId === "root") { + save({ ...dataWithoutCombo, combos: [...dataWithoutCombo.combos, movedCombo] }); + return; + } + if (targetId.startsWith("category:")) { + const categoryId = targetId.replace("category:", ""); + save({ + ...dataWithoutCombo, + categories: dataWithoutCombo.categories.map((category) => category.id === categoryId + ? { ...category, combos: [...category.combos, movedCombo], collapsed: false } + : category) + }); + return; + } + const targetComboId = targetId.replace("combo:", ""); + const targetElement = document.querySelector(`[data-combo-id="${targetComboId}"]`); + const targetList = targetElement?.dataset.comboList || ""; + if (!targetList) { + save({ ...dataWithoutCombo, combos: insertComboInList(dataWithoutCombo.combos, movedCombo, targetComboId, placement) }); + return; + } + save({ + ...dataWithoutCombo, + categories: dataWithoutCombo.categories.map((category) => category.id === targetList + ? { ...category, combos: insertComboInList(category.combos, movedCombo, targetComboId, placement), collapsed: false } + : category) + }); + } + + function updateCategory(categoryId, updater) { + save({ + ...data, + categories: data.categories.map((category) => category.id === categoryId ? updater(category) : category) + }); + } + + function getCategoryTarget(categories, title) { + const cleanTitle = title.trim(); + if (!cleanTitle) return { categoryId: "", categories }; + const existing = categories.find((category) => category.title.toLowerCase() === cleanTitle.toLowerCase()); + if (existing) return { categoryId: existing.id, categories }; + const category = { id: context.uid("section"), title: cleanTitle, collapsed: false, combos: [] }; + return { categoryId: category.id, categories: [...categories, category] }; + } + + function addInputToStep(kind, value, stepIndex) { + const input = createInput(kind, value); + setDraftInputs((steps) => { + const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]]; + const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1); + nextSteps[targetIndex] = [...nextSteps[targetIndex], input]; + return nextSteps; + }); + } + + function addDraftInput(kind, value) { + const input = createInput(kind, value); + if (simultaneousMode) { + addInputToStep(kind, value, activeStepIndex); + return; + } + + setDraftInputs((steps) => { + const filledSteps = steps.filter((step) => step.length); + const nextSteps = [...filledSteps, [input]]; + setActiveStepIndex(nextSteps.length - 1); + return nextSteps; + }); + } + + function dragPaletteInput(event, kind, value) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value))); + } + + function dragDraftInput(event, stepIndex, inputIndex, input) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({ + ...input, + sourceStepIndex: stepIndex, + sourceInputIndex: inputIndex + })); + } + + function dropInputOnStep(event, stepIndex) { + const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input"); + if (!rawInput) return; + try { + const input = JSON.parse(rawInput); + if (!input?.kind || !input?.value) return; + if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) { + moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex); + } else { + addInputToStep(input.kind, input.value, stepIndex); + } + setActiveStepIndex(stepIndex); + } catch { + // Ignore invalid drag payloads from outside the app. + } + } + + function dropInputAsNewStep(event) { + const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input"); + if (!rawInput) return; + try { + const input = JSON.parse(rawInput); + if (!input?.kind || !input?.value) return; + if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) { + moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex); + } else { + addDraftInput(input.kind, input.value); + } + } catch { + // Ignore invalid drag payloads from outside the app. + } + } + + function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) { + setDraftInputs((steps) => { + if (sourceStepIndex === targetStepIndex) return steps; + const nextSteps = steps.map((step) => [...step]); + const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || []; + if (!input || !nextSteps[targetStepIndex]) return steps; + nextSteps[targetStepIndex].push(input); + return nextSteps.filter((step, index) => step.length || index === targetStepIndex); + }); + } + + function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) { + setDraftInputs((steps) => { + const nextSteps = steps.map((step) => [...step]); + const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || []; + if (!input) return steps; + const safeSteps = nextSteps.filter((step) => step.length); + setActiveStepIndex(safeSteps.length); + return [...safeSteps, [input]]; + }); + } + + function removeActiveStep() { + setDraftInputs((steps) => { + const nextSteps = steps.filter((_, index) => index !== activeStepIndex); + const safeSteps = nextSteps.length ? nextSteps : [[]]; + setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1)); + return safeSteps; + }); + } + + function removeLastInput() { + setDraftInputs((steps) => { + const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]]; + let targetIndex = -1; + for (let index = nextSteps.length - 1; index >= 0; index -= 1) { + if (nextSteps[index].length) { + targetIndex = index; + break; + } + } + if (targetIndex < 0) return [[]]; + nextSteps[targetIndex].pop(); + const safeSteps = nextSteps.filter((step) => step.length); + setActiveStepIndex(Math.max(0, safeSteps.length - 1)); + return safeSteps.length ? safeSteps : [[]]; + }); + } + + function addCustomKey(event) { + event.preventDefault(); + const value = normalizeCustomKey(customKey); + if (!value || data.device !== "keyboardMouse") return; + addDraftInput("key", value.toLowerCase()); + setCustomKey(""); + } + + function saveCombo(event) { + event.preventDefault(); + if (!comboName.trim() && !cleanDraftInputs.length) return; + const combo = createCombo(context, editingComboId, comboName, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device); + const nextRootCombos = data.combos.filter((item) => item.id !== editingComboId); + const nextCategories = data.categories.map((category) => ({ + ...category, + combos: category.combos.filter((item) => item.id !== editingComboId) + })); + const target = getCategoryTarget(nextCategories, comboCategory); + const nextData = target.categoryId + ? { + ...data, + combos: nextRootCombos, + categories: target.categories.map((category) => category.id === target.categoryId ? { ...category, combos: [...category.combos, combo], collapsed: false } : category) + } + : { ...data, combos: [...nextRootCombos, combo], categories: nextCategories }; + save(nextData); + cancelComboEdit(); + } + + function startComboEdit(categoryId, combo) { + setComboCategory(data.categories.find((category) => category.id === categoryId)?.title || ""); + setEditingComboId(combo.id); + setComboName(combo.name); + setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]); + setActiveStepIndex(0); + } + + function cancelComboEdit() { + setEditingComboId(""); + setComboName(""); + setComboCategory(""); + setDraftInputs([[]]); + setActiveStepIndex(0); + setSimultaneousMode(false); + } + + function updateCombo(categoryId, comboId, updater) { + if (!categoryId) { + save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) }); + return; + } + updateCategory(categoryId, (category) => ({ + ...category, + combos: category.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) + })); + } + + function deleteCombo(categoryId, comboId) { + updateCombo(categoryId, comboId, () => ({ name: "", inputs: [] })); + if (editingComboId === comboId) cancelComboEdit(); + } + + function renderCombo(combo, categoryId = "") { + const comboClassName = [ + "combo-card", + "is-editing", + draggingComboId === combo.id ? "is-dragging" : "", + comboDropTarget.id === `combo:${combo.id}` ? "is-drop-target" : "", + comboDropTarget.id === `combo:${combo.id}` && comboDropTarget.placement === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + return ( +
+ +
+ updateCombo(categoryId, combo.id, (current) => ({ ...current, name: name || current.name }))} + ariaLabel={textContent.comboNameLabel || "Nom"} + /> + +
+
+ + +
+
+ ); + } + + function renderCategory(category) { + const categoryClassName = [ + "combos-category checklist-section is-grouped", + category.collapsed ? "is-collapsed" : "", + draggingCategoryId === category.id ? "is-dragging" : "", + categoryDropTarget.id === category.id || comboDropTarget.id === `category:${category.id}` ? "is-drop-target" : "", + (categoryDropTarget.id === category.id && categoryDropTarget.placement === "after") ? "drop-after" : "" + ].filter(Boolean).join(" "); + return ( +
+
+
+ +

{category.title}

+
+
+ {category.combos.length} + +
+
+ {!category.collapsed && ( +
+ {category.combos.map((combo) => renderCombo(combo, category.id))} + {!category.combos.length &&

{textContent.emptyCategory || "Aucun combo dans cette catégorie."}

} +
+ )} +
+ ); + } + + return ( +
+ {(editing || editingComboId) && ( +
+
+ + {data.device === "keyboardMouse" && ( +
+ {[ + { value: "azerty", label: "AZERTY" }, + { value: "qwerty", label: "QWERTY" } + ].map((layout) => ( + + ))} +
+ )} +
+ +
+
+ + +
+
+ {paletteColumns.map((columnGroups, columnIndex) => ( +
+ {columnGroups.map((group) => ( +
+ {group.title} +
+ {group.inputs.map((input, inputIndex) => { + if (!input) return
+
+ ))} +
+ ))} +
+ + {data.device === "keyboardMouse" && ( +
+ setCustomKey(event.target.value)} placeholder={textContent.customKeyPlaceholder || "Touche personnalisée"} /> + +
+ )} + +
+ +
+ + + +
+ {editingComboId && ( + + )} + +
+
+
+
+
+ )} + + {!data.combos.length && !data.categories.length && ( +

{textContent.emptyCombos || "Aucun combo enregistré."}

+ )} + +
+
+ {textContent.noCategoryLabel || "Sans catégorie"} +
+ {data.combos.length > 0 && ( +
+ {data.combos.map((combo) => renderCombo(combo))} +
+ )} + {data.categories.map(renderCategory)} +
+
+ ); +} diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index 316a497..a63a991 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -7,6 +7,7 @@ import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; import { lockBodyScroll } from "../../../utils/bodyScrollLock.js"; import { CalculatorModule } from "./CalculatorModule.jsx"; import { ChecklistModule } from "./ChecklistModule.jsx"; +import { CombosModule } from "./CombosModule.jsx"; import { CountersModule } from "./CountersModule.jsx"; import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { LinksModule } from "./LinksModule.jsx"; @@ -22,6 +23,7 @@ const MODULE_COMPONENTS = { images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true }, links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true }, counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true }, + combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }, table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true }, timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index f0b07e3..30c19dc 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -1,5 +1,5 @@ // Rôle : normalise, compacte et sérialise les données toolbox persistées. -const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" }; +const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", combo: "o", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" }; const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]); const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]); @@ -17,6 +17,8 @@ const TIMER_TABS = new Set(["stopwatch", "countdown"]); const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]); const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]); const TASK_TYPES = new Set(["unique", "daily", "weekly"]); +const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); +const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1; const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00"; const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; @@ -47,6 +49,7 @@ const DEFAULT_MODULE_TITLES = { images: "Images", links: "Liens", counters: "Compteurs", + combos: "Combos", calculator: "Calculateur", table: "Tableau", timer: "Timer", @@ -356,6 +359,56 @@ export function normalizeCountersData(data) { }; } +function normalizeComboInput(input) { + const kind = COMBO_INPUT_KINDS.has(input?.kind) ? input.kind : ""; + const value = String(input?.value || "").trim().slice(0, 24); + return kind && value ? { kind, value } : null; +} + +function normalizeComboSteps(inputs) { + return (Array.isArray(inputs) ? inputs : []) + .slice(0, 64) + .map((step) => (Array.isArray(step) ? step : []) + .map(normalizeComboInput) + .filter(Boolean) + .slice(0, 8)) + .filter((step) => step.length); +} + +function normalizeCombo(combo) { + const name = String(combo?.name || "").trim().slice(0, 80); + const device = COMBO_DEVICES.has(combo?.device) ? combo.device : ""; + const inputs = normalizeComboSteps(combo?.inputs); + if (!name && !inputs.length) return null; + const normalized = { + id: combo?.id || uid("combo"), + name: name || "Combo", + inputs + }; + if (device) normalized.device = device; + return normalized; +} + +function normalizeComboCategory(category) { + const title = String(category?.title || "").trim().slice(0, 80); + const combos = (Array.isArray(category?.combos) ? category.combos : []).map(normalizeCombo).filter(Boolean); + if (!combos.length) return null; + return { + id: category?.id || uid("section"), + title: title || "Catégorie", + collapsed: category?.collapsed === true, + combos + }; +} + +export function normalizeCombosData(data) { + return { + device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation", + combos: (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean), + categories: (Array.isArray(data?.categories) ? data.categories : []).map(normalizeComboCategory).filter(Boolean) + }; +} + function normalizeCalculatorValue(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; @@ -697,6 +750,41 @@ export function compactModuleDataForStorage(type, value) { })); return counters.length ? { counters } : null; } + if (type === "combos") { + const normalized = normalizeCombosData(value); + const compact = { + combos: normalized.combos.map((combo) => { + const compactCombo = { + id: combo.id, + name: combo.name, + inputs: combo.inputs + }; + if (combo.device) compactCombo.device = combo.device; + return compactCombo; + }), + categories: normalized.categories.map((category) => { + const compactCategory = { + id: category.id, + title: category.title, + combos: category.combos.map((combo) => { + const compactCombo = { + id: combo.id, + name: combo.name, + inputs: combo.inputs + }; + if (combo.device) compactCombo.device = combo.device; + return compactCombo; + }) + }; + if (category.collapsed) compactCategory.collapsed = true; + return compactCategory; + }) + }; + if (normalized.device !== "playstation") compact.device = normalized.device; + if (!compact.combos.length) delete compact.combos; + if (!compact.categories.length) delete compact.categories; + return compact.combos || compact.categories ? compact : null; + } if (type === "calculator") { const normalized = normalizeCalculatorData(value); const entries = normalized.entries.map((entry) => { @@ -837,6 +925,20 @@ function remapModuleDataForExport(type, data, nextId) { return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) }; } + if (type === "combos") { + const remapped = {}; + if (compact.device) remapped.device = compact.device; + if (compact.combos) remapped.combos = compact.combos.map((combo) => ({ ...combo, id: nextId("combo") })); + if (compact.categories) { + remapped.categories = compact.categories.map((category) => ({ + ...category, + id: nextId("section"), + combos: category.combos.map((combo) => ({ ...combo, id: nextId("combo") })) + })); + } + return remapped; + } + if (type === "calculator") { const entryIdMap = new Map(); compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc"))); diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 8403fc2..550e6ce 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -233,6 +233,25 @@ grid-row: auto; } + .combos-device-row, + .combos-form-grid, + .combos-palette { + grid-template-columns: 1fr; + } + + .combo-card { + grid-template-columns: 30px minmax(0, 1fr); + } + + .combo-card-main { + grid-template-columns: 1fr; + } + + .combo-card-actions { + grid-column: 2; + justify-content: flex-end; + } + .toolbox-actions-row { align-items: stretch; flex-direction: column; diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index 24be7ec..ba7bf2c 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -742,6 +742,11 @@ -webkit-mask-image: url("/static/icons/table.svg"); } +.module-icon-controller { + mask-image: url("/static/icons/controller.svg"); + -webkit-mask-image: url("/static/icons/controller.svg"); +} + .tabs { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); @@ -2271,6 +2276,663 @@ textarea:focus { padding-inline: 10px; } +.combos-module { + display: grid; + gap: 12px; + padding: var(--space-4); +} + +.combos-editor { + display: grid; + gap: 12px; +} + +.combos-device-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.combos-device-row label, +.combos-form-grid label { + display: grid; + gap: 6px; + min-width: 0; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 800; +} + +.combos-device-row select, +.combos-form-grid select, +.combos-form-grid input { + min-width: 0; +} + +.combos-keyboard-layout-switch { + display: inline-flex; + width: fit-content; + align-items: center; + padding: 4px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.26); +} + +.combos-keyboard-layout-switch button { + min-height: 34px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.combos-keyboard-layout-switch button.active { + border-color: rgba(246, 196, 83, 0.26); + background: rgba(246, 196, 83, 0.1); + color: var(--color-text-primary); +} + +.combos-add-form { + display: grid; + gap: 12px; +} + +.combos-form-grid { + display: grid; + grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.2fr); + gap: 10px; +} + +.combo-draft-panel { + display: grid; + gap: 10px; + padding: 10px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.32); +} + +.combo-draft-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.combos-simultaneous-toggle { + display: inline-flex; + width: auto; + min-width: 116px; + min-height: 34px; + padding: 3px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.26); + color: var(--color-text-secondary); + justify-content: stretch; + gap: 3px; +} + +.combos-simultaneous-toggle span { + display: inline-grid; + min-width: 50px; + min-height: 26px; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.combos-simultaneous-toggle span.active { + background: rgba(246, 196, 83, 0.12); + color: var(--color-text-primary); +} + +.combo-draft-actions .primary { + min-width: 104px; +} + +.combos-palette { + display: grid; + grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.2fr); + gap: 10px; +} + +.combos-palette-column { + display: grid; + align-content: start; + gap: 10px; + min-width: 0; +} + +.combos-palette-group { + display: grid; + gap: 8px; + min-width: 0; + padding: 9px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.24); +} + +.combos-palette-group > span { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.combos-palette-group > div { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.combos-palette-group.is-direction-grid > div { + display: grid; + width: fit-content; + grid-template-columns: repeat(3, max-content); + gap: 7px; +} + +.combo-palette-empty-cell { + display: block; + width: 34px; + height: 38px; +} + +.combos-custom-key-form { + grid-template-columns: minmax(0, 1fr) auto; +} + +.combos-category-list { + display: grid; + gap: 10px; +} + +.combos-category-title { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.combos-category-title h3 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.combos-category-header > div { + gap: 6px; +} + +.combos-root-list { + padding: 0; +} + +.combos-root-drop-zone { + display: none; + padding: 9px 12px; + border: 1px dashed rgba(165, 180, 252, 0.22); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.2); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.combos-root-drop-zone.is-visible, +.combos-root-drop-zone.is-drop-target { + display: block; +} + +.combos-root-drop-zone.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); + background: rgba(246, 196, 83, 0.08); + color: var(--color-text-primary); +} + +.combos-list { + display: grid; + gap: 8px; +} + +.combo-card { + position: relative; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 8px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.36); +} + +.combo-card.is-editing { + grid-template-columns: 30px minmax(0, 1fr) auto; +} + +.combo-card.is-dragging, +.combos-category.is-dragging { + cursor: grabbing; + opacity: 0.58; + transform: scale(0.99); +} + +.combo-card.is-drop-target, +.combos-category.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); + box-shadow: inset 0 3px 0 rgba(246, 196, 83, 0.8); +} + +.combo-card.is-drop-target.drop-after, +.combos-category.is-drop-target.drop-after { + box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8); +} + +.combo-drag-handle { + align-self: start; +} + +.combo-card-main { + display: grid; + gap: 7px; + min-width: 0; +} + +.combo-title-input { + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: 900; +} + +.combo-card-actions { + display: flex; + gap: 6px; + align-self: start; +} + +.combo-save-actions { + display: inline-flex; + gap: 8px; + margin-left: auto; +} + +.combo-cancel-button { + min-width: 104px; + min-height: 34px; + padding: 0 12px; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: rgba(21, 26, 48, 0.64); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.combo-sequence { + display: flex; + flex-wrap: wrap; + row-gap: 6px; + column-gap: 5px; + align-items: center; + min-width: 0; +} + +.combo-step, +.combo-step-inputs, +.combo-step-input { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.combo-step { + min-width: 0; +} + +.combo-step-inputs { + min-height: 42px; + padding: 3px; + border: 1px solid transparent; + border-radius: var(--radius-pill); + background: transparent; +} + +button.combo-step-inputs { + min-width: 0; + min-height: 42px; + color: inherit; + cursor: pointer; +} + +.combo-step-inputs.is-single { + min-height: 38px; + padding: 0; +} + +.combo-step-inputs.is-simultaneous { + padding: 5px; + border-color: rgba(246, 196, 83, 0.34); + border-radius: var(--radius-md); + background: + linear-gradient(135deg, rgba(246, 196, 83, 0.07), transparent 64%), + rgba(5, 7, 17, 0.28); +} + +.combo-step-inputs.is-active { + border-color: rgba(139, 92, 246, 0.72); + background: rgba(139, 92, 246, 0.08); + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.12); +} + +.combo-step-inputs.is-single.is-active { + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.combo-step-inputs.is-single.is-active .combo-input-token { + box-shadow: + inset 0 -2px 0 rgba(0, 0, 0, 0.2), + 0 0 0 2px rgba(139, 92, 246, 0.18); +} + +.combo-step-separator, +.combo-plus { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-weight: 900; +} + +.combo-step-separator { + margin-inline: 1px; +} + +.combo-empty-sequence { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 700; +} + +.combo-empty-step { + padding: 0 8px; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 800; +} + +.combo-input-token { + --combo-token-bg: rgba(21, 26, 48, 0.82); + --combo-token-border: rgba(165, 180, 252, 0.22); + --combo-token-color: var(--color-text-primary); + display: inline-flex; + box-sizing: border-box; + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--combo-token-border); + border-radius: 50%; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent), + var(--combo-token-bg); + color: var(--combo-token-color); + font-size: 12px; + font-weight: 950; + line-height: 1; + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.2); +} + +button.combo-input-token { + min-width: 36px; + min-height: 36px; + width: 36px; + height: 36px; + padding: 0; + cursor: pointer; +} + +button.combo-input-token:hover { + border-color: var(--color-primary-border); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent), + rgba(32, 39, 69, 0.92); +} + +.combo-input-token span { + display: block; + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.combo-input-key, +.combo-input-mouse, +.combo-value-options, +.combo-value-share, +.combo-value-start, +.combo-value-menu, +.combo-value-view, +.combo-value-c-up, +.combo-value-c-down, +.combo-value-c-left, +.combo-value-c-right { + width: auto; + min-width: 48px; + padding: 0 10px; + border-radius: var(--radius-sm); +} + +.combo-input-short { + width: 36px; + min-width: 36px; + padding: 0; +} + +button.combo-input-token.combo-input-short { + width: 36px; + min-width: 36px; + padding: 0; +} + +button.combo-input-token.combo-input-key, +button.combo-input-token.combo-input-mouse, +button.combo-input-token.combo-value-options, +button.combo-input-token.combo-value-share, +button.combo-input-token.combo-value-start, +button.combo-input-token.combo-value-menu, +button.combo-input-token.combo-value-view, +button.combo-input-token.combo-value-c-up, +button.combo-input-token.combo-value-c-down, +button.combo-input-token.combo-value-c-left, +button.combo-input-token.combo-value-c-right { + width: auto; + min-width: 48px; + padding: 0 10px; +} + +button.combo-input-token.combo-input-short { + width: 36px; + min-width: 36px; + padding: 0; +} + +.combo-input-key span, +.combo-input-mouse span, +.combo-value-options span, +.combo-value-share span, +.combo-value-start span, +.combo-value-menu span, +.combo-value-view span, +.combo-value-c-up span, +.combo-value-c-down span, +.combo-value-c-left span, +.combo-value-c-right span { + text-overflow: clip; +} + +.combo-input-mouse, +button.combo-input-token.combo-input-mouse { + min-width: 62px; +} + +.combo-value-l1, +.combo-value-r1, +.combo-value-l2, +.combo-value-r2, +.combo-value-l3, +.combo-value-r3, +.combo-value-lb, +.combo-value-rb, +.combo-value-lt, +.combo-value-rt, +.combo-value-ls, +.combo-value-rs, +.combo-value-zl, +.combo-value-zr { + width: 38px; + min-width: 38px; + height: 38px; + padding: 0; + font-size: 11px; +} + +.combo-input-key { + height: 32px; + border-radius: 7px; + background: + linear-gradient(180deg, rgba(245, 247, 255, 0.11), rgba(5, 7, 17, 0.18)), + rgba(16, 20, 38, 0.94); + box-shadow: + inset 0 -3px 0 rgba(0, 0, 0, 0.28), + 0 1px 0 rgba(245, 247, 255, 0.08); +} + +.combo-value-ctrl, +.combo-value-alt, +.combo-value-shift, +.combo-value-enter, +.combo-value-tab { + min-width: 58px; +} + +.combo-value-space { + min-width: 92px; +} + +.combo-input-direction { + --combo-token-bg: rgba(34, 211, 238, 0.12); + --combo-token-border: rgba(34, 211, 238, 0.3); + --combo-token-color: var(--color-accent-cyan); +} + +.combo-device-playstation.combo-value-triangle { + --combo-token-bg: rgba(55, 214, 122, 0.16); + --combo-token-border: rgba(55, 214, 122, 0.4); + --combo-token-color: #37d67a; +} + +.combo-device-playstation.combo-value-circle { + --combo-token-bg: rgba(248, 113, 113, 0.16); + --combo-token-border: rgba(248, 113, 113, 0.4); + --combo-token-color: #f87171; +} + +.combo-device-playstation.combo-value-cross { + --combo-token-bg: rgba(96, 165, 250, 0.16); + --combo-token-border: rgba(96, 165, 250, 0.4); + --combo-token-color: #60a5fa; +} + +.combo-device-playstation.combo-value-square { + --combo-token-bg: rgba(244, 114, 182, 0.16); + --combo-token-border: rgba(244, 114, 182, 0.4); + --combo-token-color: #f472b6; +} + +.combo-device-xbox.combo-value-a { + --combo-token-bg: rgba(101, 212, 110, 0.16); + --combo-token-border: rgba(101, 212, 110, 0.4); + --combo-token-color: #65d46e; +} + +.combo-device-xbox.combo-value-b { + --combo-token-bg: rgba(239, 68, 68, 0.16); + --combo-token-border: rgba(239, 68, 68, 0.4); + --combo-token-color: #ef4444; +} + +.combo-device-xbox.combo-value-x { + --combo-token-bg: rgba(59, 130, 246, 0.16); + --combo-token-border: rgba(59, 130, 246, 0.4); + --combo-token-color: #3b82f6; +} + +.combo-device-xbox.combo-value-y { + --combo-token-bg: rgba(250, 204, 21, 0.16); + --combo-token-border: rgba(250, 204, 21, 0.42); + --combo-token-color: #facc15; +} + +.combo-device-switch.combo-value-a, +.combo-device-switch.combo-value-x { + --combo-token-bg: rgba(239, 68, 68, 0.14); + --combo-token-border: rgba(239, 68, 68, 0.36); + --combo-token-color: #fca5a5; +} + +.combo-device-switch.combo-value-b, +.combo-device-switch.combo-value-y { + --combo-token-bg: rgba(34, 211, 238, 0.14); + --combo-token-border: rgba(34, 211, 238, 0.34); + --combo-token-color: var(--color-accent-cyan); +} + +.combo-device-switch.combo-value-l, +.combo-device-switch.combo-value-r, +.combo-device-switch.combo-value-zl, +.combo-device-switch.combo-value-zr { + --combo-token-bg: rgba(245, 247, 255, 0.1); + --combo-token-border: rgba(245, 247, 255, 0.28); +} + +.combo-device-n64.combo-value-a { + --combo-token-bg: rgba(34, 197, 94, 0.18); + --combo-token-color: #86efac; +} + +.combo-device-n64.combo-value-b { + --combo-token-bg: rgba(59, 130, 246, 0.18); + --combo-token-color: #93c5fd; +} + +.combo-device-n64.combo-value-c-up, +.combo-device-n64.combo-value-c-down, +.combo-device-n64.combo-value-c-left, +.combo-device-n64.combo-value-c-right { + --combo-token-bg: rgba(246, 196, 83, 0.2); + --combo-token-border: rgba(246, 196, 83, 0.42); + --combo-token-color: var(--color-accent-gold); +} + +.combo-device-n64.combo-value-start { + --combo-token-bg: rgba(239, 68, 68, 0.16); + --combo-token-color: #fca5a5; +} + .counters-add-form { grid-template-columns: minmax(0, 1fr) auto; }