Sur cette page

← Toute la documentation

Menus : comportement et thèmes

Menus : comportement, API de comportement et thèmes, sélecteur de modificateurs.

Les menus séparent ce qu'ils font de ce à quoi ils ressemblent :

  • une couche de comportement typée (apps/web/src/menu/) : des stores nommés (ce que les menus affichent, en valeurs JSON) et des actions nommées (ce qu'ils peuvent faire), sans balisage ;
  • les composants Svelte de l'interface, écrits sur cette couche (ils lisent les mêmes stores et appellent les mêmes actions) ;
  • les thèmes d'interface (Classique, Slant, Cabinet…) : des données (un manifeste, des jetons CSS validés par l'hôte, des images) qui restylent ces composants sans les remplacer, voir thèmes et interfaces. Il n'y a plus de modèle de menus en HTML : un thème tiers ne contient ni balisage ni script.
flowchart LR
  host[Hôte Rust<br/>window.ipc] <--> shell[Page principale<br/>bridge.ts, host.ts]
  shell --> behavior[Couche de comportement<br/>stores + actions]
  behavior --> svelte[Composants Svelte]
  themes[Thèmes<br/>jetons validés] --> svelte

Cette couche couvre la sélection des maps ; Réglages, Mods, Skins et l'Atelier s'ouvrent par ses actions, comme le menu Échap.

Sélection des maps

La liste, à droite, montre les beatmapsets (dossiers de morceaux). Comme dans osu!, un clic sur un set l'ouvre sur place : ses difficultés s'affichent dessous, chacune avec sa disposition (« 4K »), son nom et la note de la vue enregistrée par le mod choisi. Une valeur absente reste « — » et prend une couleur neutre. La couleur d'une valeur n'est pas écrite dans l'interface : c'est la note qui la déclare (colors, voir Mods TypeScript), pvng.ratings donnant le spectre des étoiles osu! à toutes ses notes en étoiles et le même spectre lu sur MSD / 4,5, avec un fondu vers le blanc entre 32 et 35 MSD, au MSD d'Etterna ; une note sans rampe déclarée reste neutre. Ouvrir un set sélectionne sa difficulté déjà choisie, sinon la première ; un clic sur une difficulté la sélectionne ; un clic sur le set ouvert le referme. Un seul set est ouvert à la fois. Le panneau de gauche suit la sélection : titre, artiste, note choisie et statistiques essentielles, puis les panneaux d'analyse déclarés par le mod de rating. Le thème ne décide pas de leur contenu à partir du nom du calculateur : Etterna choisit un radar accompagné de barres portant les vrais noms des skillsets, osu un profil du chart et une timeline. Etterna déclare une échelle fixe de 0 à 40 : 10 remplit un quart de barre, 20 la moitié ; au-delà de 40, le remplissage sature sans changer le nombre affiché. Une valeur absente n'a pas de remplissage.

Les onglets Infos / Classement / Mods / Entraînement / Historique sont directement sous les statistiques, avant leur contenu ; Local / Online reste un sous-choix compact du classement. Seule la liste des scores défile : le titre, les statistiques, les onglets, Local / Online, le sélecteur Performance et le pied de page restent fixes. Le classement n'affiche pas de paragraphes explicatifs sur le jugement courant, la performance sélectionnée ou les valeurs du simulateur.

Historique (après Mods et Entraînement, avant les onglets ajoutés par les mods) trace vos parties de la map sélectionnée, une par point, sur un axe des dates : la précision, ou la performance du calculateur choisi. Les parties de rates différents ne sont jamais mélangées : le sélecteur de rate démarre sur le rate de la partie la plus récente. Un record est une partie qui dépasse toutes les précédentes de la courbe. Un clic sur un point ouvre l'analyse de cette partie. Au-delà de 200 parties, seules les plus récentes sont tracées, et le nombre total reste affiché. Les replays reçus d'un autre joueur et les parties avec un mod qui change les notes ne sont pas comptés. Les données viennent du classement réévalué en cache : rien n'est rejugé.

Mods contient le rate natif (playbackRate), de 0,50 à 2,00 par pas de 0,05, avec saisie, curseur et retour à 1, sous le simple libellé « Rate ». Aucun effet inactif ni paragraphe explicatif sur le rate ou les données de développement n'est proposé. Changer le rate garde l'analyse cohérente précédente pendant le calcul, sans message de chargement qui clignote. Les erreurs restent signalées dans un emplacement fixe.

Sous le rate, Mods liste un interrupteur compact par modificateur de jeu publié par les mods (gameplayMods), par exemple Auto de pvng.auto : libellé seul, description dans une info-bulle personnalisée au survol ou au focus, rien d'autre. Le choix est le réglage gameplayMods, retiré avec une notification si le mod qui fournit le modificateur disparaît.

Intention de sélection. La sélection de maps est la même partout, mais ce que fait son geste de confirmation dépend d'une intention (menu/selection-intent.ts, store selectionIntent, lu par la couche de comportement sous le nom intent : { kind, label, canConfirm, lobbyName }). play est le défaut : confirmer lance la partie. pick-lobby-map est posée par l'action lobby.pickMap (l'hôte d'un salon multijoueur qui n'a pas démarré clique la carte de map du salon) : confirmer fait de la chart sélectionnée la map du salon (onlineLobbyChart) et revient au salon, et le bouton principal s'appelle « Choisir » au lieu de « Jouer ». Le double clic sur la difficulté déjà sélectionnée (selon le réglage « cliquer pour jouer »), Entrée et le bouton principal appellent la même action nommée chart.confirm, qui résout selon l'intention ; play reste l'action de lancement (elle ne change pas) et un thème n'a rien d'autre à faire que d'appeler chart.confirm et de lire intent.label. lobby.pickCancel quitte le choix sans rien changer ; lobby.showMap ouvre la bibliothèque sur la map du salon pour un membre. L'intention retombe sur play dès que la page n'est plus la bibliothèque, que le salon disparaît ou démarre, ou que le joueur n'en est plus l'hôte : elle ne survit jamais à son but. Sous pick-lobby-map, l'onglet Mods ne garde que le rate ; les modificateurs de jeu se choisissent dans la page Multijoueur (en-ligne.md), c'est le même réglage gameplayMods.

Les nombres rejoignent leur nouvelle valeur sans recréer les graphes ni déplacer les contrôles. Une interruption repart du nombre affiché ; le mouvement réduit désactive cette interpolation, exclusivement visuelle. Les réponses d'un ancien chart, rate ou calcul sont ignorées. Les cartes de score et la page du replay affichent le rate enregistré, sans signe de multiplication, avec leur propre densité et difficulté recalculées nativement. Changer le rate du menu ne réécrit pas ces parties. La page du replay a un sélecteur Difficulté à côté de Jugement et de Performance : c'est le même composant (RatingPicker) et le même réglage (ratingSystem) que le sélecteur du pied de la sélection, donc UN seul choix pour tout le jeu (changer l'un change l'autre, comme pour Jugement et Performance). Mêmes calculateurs, groupés par mod, recherche et clavier ; masquer une difficulté reste une action séparée dans les Réglages. La valeur, colorée par ratingColor et arrondie à 2 décimales, est celle du chart au rate enregistré du replay (analyse native de ce rate, jamais une valeur 1x multipliée) ; le rate est écrit dessous (« à la vitesse 1.50 »). Changer de calculateur lit simplement une autre valeur déjà calculée : le nombre rejoint sa nouvelle valeur sur place, sans rechargement. Un calculateur sans valeur pour ce chart affiche « Indisponible » dans le même gabarit (le nom prend toujours deux lignes), sans rien déplacer. Les cartes de classement n'affichent pas la difficulté : leur ligne porte déjà rang, joueur, rate, combo, précision, performance et paliers, une valeur de plus la surchargerait ; la page du replay l'affiche. Le pied du panneau reste visible sur une seule ligne : Note et Jugement, cliquables sur toute leur surface, puis Jouer à droite. Les menus sont dessinés par le thème (pas des select natifs), regroupés par mod, à hauteur bornée, avec recherche au-delà de huit choix. ↑/↓, Début/Fin et Entrée parcourent et choisissent ; Échap ferme sans ouvrir la navigation. Les paramètres du jugement choisi restent dans son menu. Réglages distingue Utiliser de Masquer/Afficher une note, sans case à cocher.

Les Réglages s'ouvrent dans une grande fenêtre modale centrée, au-dessus de la sélection assombrie. La bibliothèque reste montée : recherche, set ouvert, difficulté choisie et position de défilement sont conservés. Les catégories (une icône chacune) sont à gauche, la recherche en haut ; seul le contenu défile (structure, recherche, restauration, export/import : reglages.md). Les changements valides s'appliquent immédiatement, sans bouton Appliquer. Le bouton de fermeture ou Échap rend directement la main à la sélection, sans ouvrir la navigation. Une liste déroulante ou une capture de touche active consomme d'abord Échap. Le focus reste dans la fenêtre et revient à son point d'entrée disponible ; les notifications de réglages sont affichées dans la fenêtre pour rester lisibles et refermables.

La catégorie Scroll speed est indépendante de Jeu et de Jugement. Elle présente les fournisseurs des mods, dont Prism en ms, et accepte les décimales libres hors du curseur suggéré. Le bouton Jouer suit scrollSpeed.playDisabled, pour ne pas jouer une conversion encore inconnue.

Réglages → Jugement affiche simplement le nom du jugement actif (« Etterna · J4 », par exemple), avec un petit chevron : cliquer sur ce nom ouvre la liste, sans gros champ pleine largeur ni grille permanente. Un choix, un second clic sur le nom, un clic extérieur ou Échap ferme la liste ; elle s'ouvre au-dessus ou au-dessous selon la place disponible. Les paramètres ont un curseur dessiné par le thème et une boîte de valeur éditable avec boutons −/+. Entrée valide, Échap annule une saisie non validée ; les bornes, pas et combinaisons disponibles du mod restent respectés. Sous les curseurs, les fenêtres de chaque palier sont affichées en millisecondes avec leurs couleurs et se mettent à jour immédiatement depuis les tables de l'hôte. « − » signifie avant la note, « + » après ; « ± » indique la même tolérance des deux côtés. Le petit menu de la sélection affiche aussi ces valeurs sous ses curseurs ; les Réglages conservent le tableau détaillé.

Le thème distingue titre, note principale et informations secondaires ; les lignes de maps ont des accents liés à leur difficulté plutôt qu'une bordure identique partout. Sélection, onglets et menus ont de courtes transitions, désactivées avec prefers-reduced-motion: reduce. Les timelines colorent les valeurs basses en bleu/cyan et les valeurs hautes en tons chauds. Le curseur donne la valeur et le temps exacts du bin, à la souris comme au clavier ; la couleur ne remplace pas les nombres. Les timelines, les points radar et leurs lignes de valeur utilisent la même infobulle personnalisée : libellé complet déclaré par le mod, valeur réelle, unité et intervalle temporel ou échelle déclarée. Une donnée manquante reste « Indisponible ». Le curseur temporel et les lignes du radar sont accessibles au clavier ; l'infobulle accepte le pointeur et reste bornée à la fenêtre. Échap ou un clic extérieur la ferme sans ouvrir le menu de navigation. Le graphe utilise l'espace disponible (160 à 240 px de haut) au lieu de laisser un grand vide avant les contrôles. À faible hauteur, les panneaux conservent leurs tailles lisibles et le défilement reste dans les détails, jamais sur toute la page.

Classement propose Local / En ligne. Local affiche un seul classement de cartes cliquables : joueur, rang, précision, combo et performance au-dessus de la date. Les petits compteurs colorés donnent les hits de chaque palier du jugement courant ; Miss n'est pas répété comme une deuxième métrique. Cliquer sur une carte ouvre la page dédiée au replay. Toutes les entrées sont rejugées depuis les appuis et relâchements du replay avec le jugement actuellement choisi, pas avec celui de la partie d'origine. Changer de jeu de jugement ou de paramètres relance l'évaluation hors du thread d'interface ; les anciens résultats ne remplacent jamais la demande courante. Les replays absents ou illisibles sont signalés, sans utiliser leur ancienne précision comme résultat de remplacement. L'historique complet est consultable par pages, sans plafond de douze ou vingt scores. L'hôte garde le classement réévalué en cache ; demander la suite ne rejuge pas tous les replays. Les pages d'une ancienne map ou d'une ancienne évaluation sont refusées.

Le nom est enregistré dans chaque nouveau replay. Réglages → Interface permet de choisir le nom pour les prochaines parties ; sans saisie, l'application native utilise le nom du compte Windows. Un ancien replay sans nom reste signalé comme tel, sans être attribué rétroactivement au joueur actuel.

Sur bureau, la page du replay place le joueur, la précision dominante et les compteurs à gauche, l'analyse à droite, sans grille de tuiles arrondies. Le chart reste en en-tête. La liste verticale nom / nombre ne suppose jamais six paliers : les 32 paliers personnalisés et Miss restent accessibles dans son propre défilement, sans déplacer le score ou les graphes. Le remplissage de chaque ligne représente sa part réelle dans le total des jugements. Les onglets Timing, Distribution et Progression évitent d'empiler ou de miniaturiser tous les graphes ; ←/→, Début et Fin permettent aussi de les choisir au clavier. La typographie reste lisible et la page tient presque sans défilement aux tailles de bureau usuelles. Les axes sont en HTML, sans déformation par le SVG. Le timing montre un point par événement du juge natif, à son instant exact, avec sa touche, son palier et son offset réel, sans moyenne ni barre min–max. Le Canvas dessine tous les événements, y compris les frappes simultanées, sans échantillonnage. Survoler ou focaliser une touche la met en évidence ; cliquer la conserve, et « All keys » rétablit toutes les colonnes. Les flèches, Début et Fin parcourent aussi les touches au clavier. Une colonne sans frappe mesurée garde ses Miss et des statistiques indisponibles, jamais un faux zéro. Chaque Miss est une croix rouge vif sur la ligne centrale, à son instant de jugement, sans offset. Les croix passent au-dessus des points pour ne pas être masquées par une frappe parfaite. Elles n'entrent ni dans les statistiques d'offset ni dans l'histogramme. Les fenêtres avant/après du jugement actif gardent leur fond discret et leurs limites colorées, même asymétriques. L'aide près de « Hit offset » explique les offsets signés et les croix Miss. Le survol inspecte l'événement le plus proche ; le contrôle sous le graphe parcourt les événements par temps, avec leur identité pour distinguer les notes simultanées. L'infobulle donne la touche, le palier, l'instant exact et l'offset réel, ou « Miss · no offset ». ←/→, Début et Fin parcourent les notes ; Échap ferme l'infobulle sans quitter le replay. Le bouton LN ouvre une analyse compacte : têtes manquées, relâchements anticipés, relâchements dans la fenêtre et notes tenues jusqu'au bout. Les moyennes et étendues des têtes et relâchements viennent des événements du moteur natif, avec le modèle de tenue du jugement actif. Une tête réellement pressée reste mesurable même si la tenue échoue ensuite ; une fin automatique ne fabrique pas de relâchement à zéro ni de jugement de queue indépendant. La distribution cadre initialement les bins renseignés (sans perdre de hits) et garde le zéro en référence. Ses barres reprennent les couleurs des fenêtres du jugement courant, avec leurs bornes avance/retard indépendantes. Un bin traversant une borne change de couleur à cet endroit, sans prétendre connaître la répartition exacte de ses frappes entre les paliers.

Les trois vues proposent un zoom à la molette, ancré sous le pointeur, ou avec les boutons −/+, jusqu'à 32 fois : la molette seule zoome l'axe horizontal (le temps, ou l'offset pour la distribution), Maj + molette zoome l'axe vertical (offset ±ms du Timing, effectif de la distribution, échelle de chaque courbe de la Progression, qui partagent le même zoom vertical ; les bornes visibles s'affichent sous chaque courbe). Le groupe de boutons ↕−/↕+ en est l'équivalent. Une fois zoomée, la vue se déplace par glisser (les deux axes à la fois) ou avec les flèches ; Full view retrouve l'étendue complète des deux axes. Au clavier, le graphe sélectionné répond à + / − (axe horizontal), Maj + ↑ / ↓ (axe vertical), aux flèches (déplacement) et à 0 (vue complète). Timing et Progression partagent la même fenêtre temporelle ; la distribution garde sa fenêtre d'offset indépendante. Les axes et l'inspection suivent l'étendue visible, sans agrandir les textes ni inventer de nouveaux points. Le clavier de Timing et le curseur d'intervalle de Progression ramènent la mesure choisie dans la vue. Le zoom (horizontal et vertical) est conservé au changement de jugement ou de calculateur, et borné si l'étendue des données change. L'état des vues est dans lib/graph-view.ts (fonctions pures, testées).

Le décalage moyen (panneau de gauche, onglet Timing, intervalle de la Progression, invite de décalage visuel, détail d'une case de la carte d'erreurs) est coloré selon son éloignement de zéro, en dégradé continu : blanc jusqu'à 2 ms, jaune vers 4 ms (« pas terrible, fais attention »), orange vers 7 ms (« change tes réglages »), rouge à partir de 10 ms (« change tes réglages maintenant »). Les quatre couleurs sont les jetons --pv-offset-ok/-warn/-high/-bad (un thème clair les redéfinit) ; lib/offset-color.ts (offsetColor, ancres nommées) calcule le mélange. Le signe (avance/retard) reste dans le texte, et le conseil est une infobulle et un texte lu par les lecteurs d'écran.

Le combo est bleu, la précision violette en pointillés ; la densité conserve un remplissage discret et une couleur ambre/orange/rouge liée à son amplitude. Les transitions de vue ne déplacent pas les axes. La progression conserve les valeurs de combo et précision de fin d'intervalle, sans interpolation. Combo, précision et densité du chart (notes/s) sont superposés dans un seul graphe. Les trois boutons de légende affichent ou masquent leurs courbes indépendamment, à la souris ou au clavier. Les échelles et unités sont indiquées dans ces contrôles, sans colonnes de valeurs ni espace réservé à gauche du graphe. L'axe temporel et le curseur restent communs. Masquer une courbe ne redimensionne pas les autres échelles et ne déplace pas le graphe ; tout masquer affiche une invitation à en choisir une. Les aplats des intervalles contenant des Miss sont masqués par défaut. Le bouton Miss intervals affiche ou masque ceux du replay et de sa référence, indépendamment des courbes, sans changer les axes ni le curseur. Leur nombre reste affiché à l'inspection, sans position exacte inventée. Les bins de densité gardent leur largeur native, indépendamment des intervalles du replay ; la valeur inspectée correspond à la fin de l'intervalle sélectionné. Une densité absente désactive son bouton, sans afficher de courbe à zéro. Les statistiques proviennent des événements du juge natif ; l'absence d'offset d'un Miss n'est jamais prise pour une frappe parfaite. Le timing complet est chargé par pages de 2048 événements depuis le même snapshot natif, sans relire ni rejuger le replay à chaque page. Une nouvelle analyse et ses historiques de points sont publiés ensemble, une fois toutes les pages reçues ; les pages obsolètes ne peuvent pas remplacer la paire affichée. Seules Progression et Distribution conservent leurs agrégats bornés. Le retour au classement conserve la map, les pages de scores chargées et la position dans la bibliothèque.

Compare score choisit un autre score du même chart, avec accès à toutes les pages du classement et une action pour retirer la référence. Une seule requête évalue les deux replays sous le même jugement et calculateur courants ; une référence supprimée ou illisible ne fait pas disparaître le score principal. La référence affiche son joueur, son rate enregistré et les écarts de précision, combo, difficulté et performance disponibles. Ses points de timing sont creux, aux couleurs des paliers, et ses croix Miss rouges sont entourées d'un cercle. Ses courbes de progression sont en pointillés et sa distribution tracée en contour. Le bouton Reference masque ces tracés sans changer les échelles. Si les rates diffèrent, les temps de la référence sont alignés par position du chart (temps × rate référence / rate principal) ; l'axe garde les secondes réelles du replay principal, et les offsets restent en millisecondes réelles.

Watch replay lance la lecture native des entrées physiques enregistrées, avec le rate du replay et le jugement actuellement affiché. Le playfield, l'audio et le HUD suivent cette lecture ; l'overlay indique REPLAY et son rate. Les appuis du spectateur ne modifient pas les notes ; Échap revient à l'analyse conservée, comme la fin naturelle. La lecture ne crée ni score ni nouveau replay. Dans le serveur de développement uniquement, un simulateur clairement identifié visualise le chart et les entrées fictives, sans prétendre être le rendu ou le juge natif.

Changer le jugement ou le calculateur ne démonte plus l'analyse : le dernier résultat cohérent reste affiché pendant le recalcul. Les nombres montent ou descendent vers les nouvelles valeurs en 260 ms ; une interruption repart du nombre affiché. Cette interpolation ne modifie ni les stores, ni les calculs, ni les résultats enregistrés. Les graphes conservent une transition de 180 ms, sans inventer de points entre deux agrégats. L'onglet, le curseur, les courbes masquées et le défilement sont conservés. La performance et son unité appartiennent au même résultat, sans mélange entre ancienne précision et nouveau calculateur. Les réponses périmées restent ignorées. L'animation est désactivée, ou arrêtée si elle est en cours, avec la préférence de mouvement réduit. Le simulateur génère sa densité depuis les mêmes notes que ses replays ; il n'étire pas un graphe indépendant pour masquer une durée différente.

Références visuelles : résultats Etterna, interface Quaver et résultats osu!mania.

Par défaut, la performance suit le jugement actif : précision osu!mania → pp osu! (calculateur Metron osu-2018, affiché « osu!mania », en pp), Wife3 → SSR MinaCalc 515. Le joueur peut choisir un autre calculateur avec le sélecteur Performance visible au-dessus du classement, sur la page du replay ou dans les Réglages. Il n'est pas caché dans le menu du jugement. La note de difficulté reste séparée. La précision du jugement courant est passée au calculateur choisi : une combinaison de modèles différents est indiquée comme non standard, et non comme des pp/SSR officiels. Seuls osu-2018 (pp) et etterna-515 (SSR) ont une performance ; les entrées qu'un calculateur exigerait en plus ne sont jamais inventées.

L'index SQLite est écrit après la sauvegarde du replay ; les parties interrompues sont exclues. L'identité comprend le chemin du chart et son indice de difficulté. Les anciens replays sans cette identité ne sont pas rapprochés par titre. En ligne indique l'absence de service, sans inventer de scores.

Dans l'hôte web de développement (bun run dev), chaque difficulté de la bibliothèque fictive reçoit un historique déterministe fourni, avec des noms de joueurs de démonstration et des entrées simulées rejugées lorsque le jugement change. Les mêmes données réapparaissent après rechargement ; la démo reste vide. Les parties terminées du simulateur s'ajoutent à cet historique en mémoire, propre à chaque chart. MockOptions.replays permet de fournir des entrées de replay ; scores fournit des lignes déjà évaluées pour les scénarios d'interface uniquement. scores: {} laisse tous les classements vides jusqu'à une partie terminée. Le mock affiche des valeurs numériques déterministes de pp/SSR pour travailler la présentation, comme demandé : ce sont des fixtures de développement, pas une réimplémentation de Metron. Ses graphes restent issus des entrées simulées. Ces fixtures ne sont jamais écrites dans la bibliothèque native et ne rendent pas le service en ligne disponible.

Touche Action Effet
↓ library.down difficulté suivante du set ouvert, sinon première difficulté du set suivant
↑ library.up difficulté précédente, sinon dernière difficulté du set précédent
→ / ← library.nextSet / library.previousSet set suivant / précédent, sur sa première difficulté
Entrée play joue la difficulté sélectionnée
Échap navigation.drawer ferme d'abord les Réglages ouverts ; sinon ouvre ou ferme le menu

Sans sélection, ↓ ouvre le premier set visible. Les touches de la sélection ne jouent que sur la page Bibliothèque, menu et Réglages fermés. Dans un champ de texte (la recherche), seules Échap, Entrée, ↑ et ↓ passent ; sur un bouton hors de la liste, Entrée et Espace restent au bouton ; sur un curseur, une liste déroulante, dans une boîte de dialogue ou une popover, seule Échap passe. La molette et la barre de défilement ne font défiler que la liste.

Un clic droit sur une carte de set ouvre un menu de copie : le titre, l'artiste, le mappeur et la ligne de partage. Un clic droit sur une ligne de difficulté copie la même chose, plus le nom de la difficulté. La ligne de partage suit le format Artiste - Titre [Difficulté] (mapped by Mappeur) ; une partie vide est omise, et le nom suit le réglage romanisé ou original. Aucun chemin de fichier n'est copié.

Une ligne de difficulté ajoute en plus Ajouter à une playlist…, qui ouvre le dialogue de playlist (playlists-et-marathons.md). Les cartes de set n'ont pas cette entrée : elles ne désignent pas une seule map.

Au clavier, la touche Menu (ou Maj+F10, selon le clavier) sur une carte ou une difficulté ouvre le menu au niveau de la ligne. ↑ et ↓ se déplacent et reviennent en boucle, Début et Fin vont aux extrémités, Entrée copie l'élément, Échap ferme le menu et rend le focus à la ligne. Tab ferme le menu sans rendre le focus. Le menu consomme ses touches : elles ne parviennent pas à la liste ni aux raccourcis. Un refus du presse-papiers affiche un message dans le menu, sans copie silencieuse.

Le menu n'ajoute aucune entrée à la palette Ctrl+K : c'est un composant partagé, pas une seconde liste de destinations.

Filtre par nom (artiste, mappeur)

Ctrl+clic (ou Cmd+clic) sur l'artiste ou le mappeur du panneau de la map sélectionnée, ou sur le nom d'une carte de set, ajoute le filtre exact correspondant à la barre de recherche (Artiste ou Mappeur égal au nom, sans tenir compte de la casse, romanisé ou original). Le Ctrl+clic ne déplie ni ne lance la carte, et garde la sélection ; un clic simple sur un nom reste le clic de la carte (il la déplie). Un même filtre cliqué deux fois ne se duplique pas ; cliquer un autre artiste remplace le filtre d'artiste existant, comme dans l'éditeur. Le texte libre et les autres filtres restent en place. Au clavier, le menu contextuel d'une carte propose « Filtrer sur cet artiste » et « Filtrer sur ce mappeur » ; après le filtre, le focus va au champ de recherche, car la liste est recalculée.

Historique des recherches

Le champ de recherche garde les dernières recherches validées sur cette machine : le texte et les filtres composés, jusqu'à 50, la plus récente en premier. Une recherche est validée quand le champ perd le focus, par Entrée, ou quand un filtre est ajouté, retiré, une collection ou un filtre de nom choisi ; taper sans valider ne garde rien. Une recherche déjà présente remonte en tête sans se dupliquer.

Le bouton d'historique ouvre la liste ; cliquer dans le champ l'ouvre aussi quand il y a un historique. Cliquer une entrée remet son texte et ses filtres à la place des actuels ; un filtre absent du catalogue est ignoré, avec une notice. Restaurer ne réenregistre pas la recherche. Chaque entrée a un bouton de retrait, Vider efface toute la liste, et Pause suspend l'enregistrement sans effacer la liste.

Au clavier, Flèche bas entre dans la liste, Échap la ferme et rend le focus au champ. Retirer une entrée ou vider garde le focus dans la liste. L'historique n'est stocké que dans le navigateur de Prism (clé prism.search.history) : il n'entre ni dans les réglages exportés, ni dans un profil, ni dans une synchronisation.

Noms des maps

Le titre et l'artiste affichés suivent le réglage titleDisplay (romanisé par défaut ou original), la recherche trouve les deux noms : voir noms des maps. Les stores sets et selection donnent déjà le nom choisi dans title/artist ; les lignes de sets gardent titleRomanised/artistRomanised dans song de chaque entrée.

Bande de difficultés et changements de map

Chaque carte de set porte une bande de difficultés : autant de pastilles que la largeur de la bande en laisse sur une seule ligne, de la plus facile à la plus dure sur la note choisie, chacune colorée par ratingColor, puis un badge « +N » (N = difficultés non montrées, libellé accessible « plus N difficultés ») dont le survol ou le focus clavier de la carte ouvre le popover partagé (ChartPopover) listant toutes les difficultés. Selon la place, une pastille est un simple trait coloré, la note arrondie, ou la disposition et la note (menu/difficulty-strip.ts, fitStrip) : le calcul ne dépend que de la largeur de la liste (mesurée une fois, jamais par ligne ni par pastille) et du nombre de difficultés, car les largeurs de pastille sont fixes. La hauteur est constante, rien ne déborde et aucune ligne ne bouge : la liste reste virtualisée.

Choisir une autre map met à jour le panneau de gauche en place : le titre et les graphes se fondent, les nombres défilent (AnimatedNumber), et le panneau garde la dernière analyse livrée jusqu'à l'arrivée de la nouvelle, sans vider ni saut de mise en page. L'animation d'entrée des panneaux ne se rejoue qu'une fois la sélection posée : menu/settled.ts (createEntrance) pose data-entering sur .selected-map 250 ms après le dernier changement (le délai repart à chaque changement), seulement si la map posée appartient à un autre set que la précédente (changer de difficulté dans un set ne rejoue rien), jamais en mouvement réduit. Le déclencheur est commun aux trois interfaces ; chaque interface décide dans son CSS de ce que .selected-map[data-entering] anime.

Marqueur « Nouveau »

Une petite pastille « Nouveau » (couleur d'accent du thème, jamais une couleur de difficulté) au début de la ligne d'infos d'une carte de set, dans les six thèmes et en classic, sans changer la hauteur de la ligne : elle tient dans la ligne d'infos (une ligne de haut, ni mesure du DOM ni hauteur variable, la liste reste virtualisée). L'info-bulle dit « Ajoutée il y a 2 jours » (Intl.RelativeTimeFormat : la langue du joueur sans clé par unité) ; le mot « Nouveau » est du texte, donc lu par les lecteurs d'écran.

  • Règle, sans réglage : un set est nouveau tant que la bibliothèque l'a vu pour la première fois il y a moins de 7 jours (chartset.first_seen_ms, la colonne du tri « Date d'ajout » : posée par le scan et par chaque installation, téléchargement, .osz glissé ou lien collé, voir Snapshot::stamp_first_seen) et qu'aucune de ses difficultés n'a une partie terminée du joueur lui-même (celles que la liste compte : ni les parties abandonnées, ni les replays reçus d'un autre joueur). Elle s'arrête donc à la première partie sur une difficulté, ou à l'échéance.
  • Pas toute la bibliothèque : first_seen_ms est la date de modification du dossier du set au moment où la bibliothèque le voit (migration 24 : une bibliothèque déjà présente garde les dates de ses dossiers, donc rien de ses vieilles maps n'est nouveau ; un set jamais daté, NULL, n'est jamais nouveau). Limite connue (non vérifiée avec chaque outil de copie ou d'extraction) : la date est celle de la modification du dossier, pas l'heure de la première analyse ; un lot de maps copié ou extrait à l'instant en dossiers neufs peut donc être marqué nouveau jusqu'à l'échéance, alors que les dossiers qui gardent leur ancienne date ne le sont pas.
  • Côté hôte (crates/db/src/fresh.rs) : BeatmapSet.addedMs (la date de première vue, absente pour tout autre set) est lue pour les sets d'une page seulement, en une requête (jamais une par ligne, jamais la bibliothèque entière). « Jamais joué » regarde toutes les difficultés du set, pas seulement celles que la recherche liste. L'échéance se calcule à chaque page.
  • Après une partie : un result enregistré et terminé de la map sélectionnée retire la pastille sur place (LibraryBrowser.markPlayed : chaque page chargée et le set ouvert, aucune question à l'hôte, ni défilement ni sélection touchés).
  • Tri et groupe : aucun nouveau filtre ni tri, le tri et le groupe « Date d'ajout » existants lisent déjà la même donnée (pas de colonne de plus).
  • Tests : crates/db/tests/fresh.rs (7 jours, jamais joué, parties abandonnées et reçues, difficulté cachée par la recherche, bibliothèque ancienne ou jamais datée), menu/views.test.ts, menu/library-browser.test.ts (retrait sur place), tests/new-mark.spec.ts (Playwright : dans les huit thèmes, dans la carte, aucune hauteur de ligne changée, info-bulle, retrait après une partie).

Notes et étiquettes sur une map

Un petit texte libre et quelques étiquettes privés par difficulté (« trop dur », « à retravailler », « échauffement »). Ils restent sur cette machine : aucun mod, aucun serveur, aucun export de réglages ne les lit ; la base de données les garde (la sauvegarde de la base les contient donc).

  • Édition : menu ⋯ (« Plus ») de la map sélectionnée → « Note et étiquettes », une boîte modale (MapNotePicker.svelte) avec des boutons-icônes seulement (enregistrer, supprimer, fermer, ajouter, retirer une étiquette). Note : 500 caractères au plus (compteur). Étiquettes : 8 au plus, 24 caractères, sans virgule ni caractère de contrôle, une seule fois quelle que soit la casse ASCII ; Entrée ou virgule ajoute, Retour arrière retire la dernière, et les étiquettes déjà employées (les plus utilisées d'abord, 8 au plus) se proposent d'un clic. L'hôte refuse un dépassement (jamais de troncature silencieuse) : mapNoteFailed. Une note vide sans étiquette supprime la ligne.
  • Identité (comme le décalage d'une map, map_offset) : la note hash de la chart quand elle en a une, donc la note suit les notes si le fichier change de dossier (deux copies d'une même chart la partagent) ; une chart pas encore hachée garde sa note sous son emplacement, retirée dès que la chart a une empreinte (le fichier à cet emplacement peut être un autre). Pas de clé étrangère : la note d'une map qui a quitté la bibliothèque est supprimée avec ses étiquettes après un scan (prune_map_notes) ; une map qui revient avant ce nettoyage retrouve sa note. Tables map_note et map_note_tag (migration 33, additive).
  • Pastille sur les cartes : une petite icône (carnet) en fin de ligne d'infos du set et sur la ligne de la difficulté qui porte une note ou des étiquettes, info-bulle « A une note ou des étiquettes » ; LibraryEntry.noted ne porte que l'indicateur, jamais le texte, une requête par page. Enregistrer ou supprimer met la marque à jour sur place (LibraryBrowser.setNoted, aucune question à l'hôte).
  • Recherche : le texte libre cherche aussi dans les notes et les étiquettes (chaque mot dans la note, une étiquette ou les noms d'une même chart). Critères natifs (« + Filtre » : « Étiquette », « Note », contient ou exact) et syntaxe de recherche : tag=difficile, tag="à retravailler", note="trop dur" (comme mapper= : exact, casse ASCII ignorée). Ils se combinent avec les autres critères sur la même difficulté et la pagination reste native (crates/db/src/search.rs, sous-requête sur map_note indexée par note hash et par emplacement : jamais la bibliothèque dans la WebView).
  • Protocole : mapNoteGet / mapNoteSet { note, tags } / mapTagsGet → mapNote { path, variant, note }, mapNoteFailed, mapTags ; l'interface ne garde que la réponse de la chart sélectionnée.
  • Non fait : une note par set (le choix est la difficulté) ; la palette de commandes ne cherche pas les notes ; les suggestions ne comptent que les étiquettes (pas de couleur par étiquette).
  • Tests : crates/db/src/map_note.rs (identité, bornes, nettoyage, suggestions), crates/db/tests/map_note.rs (texte libre, filtres, même chart, indicateur), lib/map-note.test.ts, lib/search-syntax.test.ts, menu/library-browser.test.ts, tests/map-notes.spec.ts (Playwright).

Map aléatoire (F3)

Une touche (et un bouton dé dans la barre de la liste, et l'entrée « Aller à une map au hasard » de la palette de commandes) tire une map au hasard parmi les résultats actuels et y place la sélection. Réglage randomBinding (défaut F3), une ligne ControlRow de Réglages → Contrôles → « Sélection de morceaux » à côté de Visualiser et de la palette : même capture, même inventaire de touches (key-codes.json), mêmes règles (lib/controls.ts, côté hôte Settings::validate), partie du profil de contrôles (donc exportée avec « Inclure les touches », voir manettes).

  • Pourquoi F3 : une touche F n'écrit rien, donc elle ne gêne pas la barre de recherche qui a le focus à l'ouverture, et aucune touche de jeu par défaut ne la prend (pause F2, recommencer F4, saut d'intro Entrée, quitter Échap, capture F12).
  • Règles (comme la palette) : ni une touche de la liste (MENU_KEYS : flèches, Entrée, Échap, Début, Fin, PageHaut, PageBas ; raison menu), ni la touche Visualiser, ni celle de la palette, ni celle de la capture d'écran (la page ferait deux actions sur une pression : dans un vieux fichier ces trois touches gardent la leur et la touche de map aléatoire prend la première touche de fonction libre, F3 d'abord, puis F1…F12). Elle peut être la même touche que pause, quitter, recommencer, saut d'intro, une colonne ou une action de mod : la page ne la lit que sur la sélection de morceaux, jamais pendant une partie, le visualiseur, un entraînement ou un replay.
  • Quand elle agit (lib/random-key.ts, fonction pure des faits de l'événement, lus par random-key-facts.ts ; App.svelte ne fait que l'appeler) : page Bibliothèque seule (ni Réglages, ni tiroir, ni page replay, ni éditeur de skin), jamais au-dessus d'une boîte de dialogue, de la palette, d'un menu, ni pendant le visualiseur, un entraînement, un replay ou une partie (paletteBlocked, la même liste que la palette), jamais avec une touche tenue ni avec un modificateur. Une touche qui écrit (une lettre, un chiffre…) ne tire qu'avec aucun contrôle au focus (sinon elle reste la lettre du champ ou le clic du bouton) ; une touche qui n'écrit rien (F1 à F24, verrouillages…) tire aussi au-dessus du champ de recherche ou d'un bouton.
  • Le tirage est natif (Rust, crates/db/src/random_pick.rs, Snapshot::random_pick) : la page envoie libraryRandom avec la requête courante (texte, filtres, doublons, ordre et regroupement du réglage) et l'identifiant du set sélectionné. L'hôte compte les sets du résultat (un count), tire un rang de l'ordre complet de la liste (groupes d'abord, puis le tri dans le groupe ; les groupes repliés comptent : replier est la mise en page de la liste, jamais une règle de requête), lit le set de ce rang par LIMIT 1 OFFSET rang sur la même agrégation que les pages, puis tire une des difficultés que le résultat liste pour ce set (une recherche qui n'en garde qu'une ne tire que celle-là). La bibliothèque n'entre jamais dans la WebView ; une requête plus ancienne que la plus récente est abandonnée comme une page.
  • Jamais deux fois le même set de suite quand le résultat en liste au moins deux : le tirage porte d'abord sur tout le résultat ; seulement s'il tombe sur le set sélectionné, un autre rang est tiré parmi les n − 1 autres (mêmes probabilités qu'en excluant ce set d'avance, sans la passe d'agrégation de plus qu'il fallait pour trouver son rang à chaque appui). Un seul set listé : c'est lui. Résultat vide : rien ne se passe.
  • La difficulté est tirée au hasard parmi celles du set listées par le résultat (choix documenté : pas « la plus proche de la difficulté sélectionnée », qui dépendrait du calculateur choisi ; le hasard est ce que la touche promet).
  • Atteindre la map (LibraryBrowser.random, receiveRandom) : le saut est celui de la barre de défilement, la même logique que « garder la map visible » (rang → position, page du rang seule demandée, jamais toute la liste, aucune mesure du DOM). Le set s'ouvre sur place (un groupe replié qui le contient se déplie), la difficulté tirée est sélectionnée par la sélection habituelle (select, les réponses obsolètes ne la remplacent jamais), puis centrée. Un seul tirage est en vol à la fois : une touche appuyée de nouveau pendant qu'un tirage tourne n'en envoie pas un autre ; quand il répond, sa map n'est même pas atteinte (ni défilement, ni page) et un seul nouveau tirage part (le dernier appui gagne, jamais une file de tirages qui coûtent chacun plusieurs passes sur la bibliothèque). Un échec du tirage répond « aucune map » pour que la touche reste libre. Côté hôte, un tirage qu'un plus récent a remplacé avant qu'un worker le prenne ne s'exécute jamais (Loader::random). Une réponse d'une autre requête est ignorée ; changer la recherche pendant le tirage l'abandonne.
  • Simulateur de développement : MockLibrarySearch.random, même règle (rang de l'ordre des pages, jamais le set courant, difficultés listées) sur les mêmes fonctions de page.
  • Tests : crates/db/tests/random_pick.rs (graine fixe : Draw::seeded, rang = place dans l'ordre de la liste sous chaque tri et regroupement, jamais le set courant, tous les sets et difficultés sortent, recherche respectée, résultat vide), settings.rs (règles et profils de la touche), lib/random-key.test.ts (garde, sauvegarde, règles de capture), menu/library-browser.test.ts (saut, ouverture, centrage, réponses obsolètes, groupe replié), mock/library-search.test.ts, tests/random-map.spec.ts (Playwright : la touche, la palette, le bouton, le champ de recherche, Réglages, la capture de la touche).

Marcher dans la liste (flèches maintenues, molette, clics rapides) ne doit rien empiler. Chaque nouvelle sélection rend la précédente obsolète, de la page jusqu'à l'aperçu audio :

Étape Où Ce qui coûte Le travail obsolète
Pas de marche → commande select page (lib/latest-gate.ts, selection.ts) une commande + 3 notifications par pas Avant : un select par pas. Maintenant : la sélection visuelle suit chaque pas, l'hôte n'entend que le premier pas après un moment calme (250 ms) puis le dernier, 90 ms après l'arrêt (et, pendant une très longue marche, toutes les 350 ms). Toute autre commande (Jouer, Visualiser…) envoie d'abord la sélection retenue ; libraryPage et libraryRandom n'attendent jamais
Chargement du morceau fil Loader de l'hôte (selection.rs) lecture du chart, décodage + ré-encodage de l'image (jusqu'à 2048 px), 4 lectures SQLite une demande en attente est remplacée par la dernière ; une demande en cours s'arrête avant l'image et avant les lectures SQLite dès qu'une plus récente existe (newest_selection)
Analyse du morceau choisi (graphes, notes) pool rate-analysis (1 fil) avant : TOUS les calculateurs Metron sur le chart à chaque sélection, même à Rate 1,0, alors que la page n'utilise que cette analyse (pas celle de l'événement song) Maintenant à Rate 1,0 elle est lue : statistiques du cache et lignes de notes déjà lues par la sélection (RateAnalysis::from_stored) ; seule une autre vitesse, ou une note pas encore stockée, lance le calcul (annulé entre deux calculateurs si une sélection plus récente arrive, comme avant)
Classement local pool leaderboard (1 fil) rejugement des replays du chart annulé entre deux replays (jeton de révision), comme avant
Mesure de sonie (préchargement) fil normalize-prefetch décodage du fichier audio ENTIER Avant : un fil par sélection, tous en parallèle. Maintenant : un seul fil, attend 400 ms de stabilité, le dernier gagne
Aperçu audio fil menu-music + fils d'aide lecture du chart, ouverture/déplacement du fichier (1 à 3 ms), mesure de sonie le dernier choix gagne (résolveur, attente, jeton de réchauffage) ; l'aperçu ne mesure plus la sonie (il prend le cache) ; attente 30 ms pour un clic, 120 ms pour un pas de marche
Tirage au hasard (F3) page + pool library-pages (2 fils) avant : 3 à 4 passes sur la bibliothèque (compte, rang du set courant, OFFSET, difficultés) Maintenant un tirage à la fois, 2 passes en général (le rang du set courant n'est cherché que si le tirage tombe dessus), un tirage remplacé n'est jamais lancé
Travail de fond (empreintes de notes, noms, notes Metron, téléchargements, optimiseur) pools bas niveau des milliers de fichiers lus et décodés avant : suspendu seulement par une partie. Maintenant aussi tenu 1 s après la dernière interaction de menu (sélection, tirage) par le même drapeau pause_rating_workers (app/menu_activity.rs), jamais sous une partie ni un export

Le profil fast-local du build de test est un LTO fin, pas la release : les écarts de vitesse entre ce build et la version livrée existent, et rien ici n'a été mesuré sur le vrai jeu (pas de benchmark).

Liste virtuelle

LibraryBrowser (apps/web/src/menu/library-browser.ts) garde la liste indépendante de son rendu :

  • Géométrie. Chaque set occupe setHeight px (pas de ligne, espacement compris) ; le set ouvert ajoute nombre de difficultés × difficultyHeight. Avec un seul set ouvert, la position d'un set et le set sous un décalage se calculent directement (ListLayout), sans mesurer le DOM. Le rendu déclare ses pas (library.metrics) ; changer de rendu garde le premier set visible en haut.
  • Hauteur stable. Dès la première page, la hauteur totale vaut total × setHeight ; l'arrivée des pages ne la change jamais. Ouvrir un set réserve la hauteur de ses difficultés, connue puisque sa page est chargée ; si un set ouvert plus haut se referme, le défilement est corrigé pour que le set cliqué reste sous le pointeur.
  • Pages par rang de set. Pages de 80 sets (libraryPage, décalage multiple de 80 = un rang dans l'ordre complet), 12 gardées au plus (les plus éloignées de la vue partent) ; sont demandées les seules pages qui recouvrent des sets visibles dans la vue plus 6 sets de chaque côté (le petit tampon), jamais « la suivante » d'office. Un saut de la barre de défilement demande directement la page visée ; en attendant, des lignes vides de même hauteur la remplacent.
  • Lignes bornées. Seuls les sets autour de la vue et, pour le set ouvert, les difficultés proches de la vue sont rendus.
  • Liste groupée. Le groupement est de la mise en page, pas de la requête : la première requête d'un jeu de critères demande en plus son plan (libraryPage.outline, une passe GROUP BY sur les seules colonnes du groupe : clés, effectifs, dans l'ordre d'affichage), jamais les pages suivantes. Les en-têtes sont des lignes de la liste virtuelle (pas --pv-group-row/-gap) : hauteur = en-têtes + sets des groupes dépliés + difficultés du set ouvert, connue dès ce plan. Replier ou déplier un groupe change seulement quelles positions ont une ligne : aucune requête, aucune page rechargée, la liste n'est jamais vide, le défilement reste sur ce qu'il montrait (l'ancre est le set en haut de la vue, ou l'en-tête de son groupe), la sélection et le set ouvert sont gardés (un set ouvert dans un groupe replié revient avec lui). total compte donc tous les sets du résultat, groupes repliés compris ; la liste est « sans résultat » seulement quand total vaut 0. Au clavier, les groupes repliés sont sautés ; ouvrir un set exprès (aller à une map) déplie son groupe. L'état replié (libraryCollapsed, 512 clés au plus, les plus récentes gardées, dans l'ordre où elles ont été repliées) est un réglage validé côté hôte mais jamais envoyé dans une requête. Sur un groupement par lettre (40 groupes au plus), une barre de lettres à droite de la liste saute à un groupe, et l'en-tête du groupe en vue reste épinglé en haut de la liste (le suivant le pousse). Tri et groupement ensemble : les groupes d'abord, dans leur ordre propre (« # » puis A-Z, tranches croissantes avec « non noté » en dernier, dates du plus récent au plus ancien, noms par ordre alphabétique, nombre de touches croissant), puis les sets d'un groupe suivent le tri choisi. Chaque tri est défini sous chaque groupement : un tri sans rapport avec le groupement (ordre du scan sous des lettres, aléatoire sous des tranches) ordonne simplement les sets à l'intérieur de chaque groupe.
  • Recherche et filtres. Changer le texte, ajouter ou retirer un filtre relance la requête, mais la sélection reste en vue : la première page demande le rang natif du set sélectionné (anchors, comme le rattrapage des notes). S'il est dans le nouveau résultat avec la difficulté choisie, la liste réserve toute sa hauteur, demande la page de ce rang, ouvre le set en place et centre la difficulté (centeredTop), sans sélectionner de nouveau ni déplacer le champ de recherche (focus conservé) ; si le joueur a fait défiler entre-temps, la vue n'est pas ramenée. Sinon (set absent du résultat), la liste repart du haut comme avant et la sélection reste celle du chart.
  • Clavier. Un déplacement vers un set dont la page n'est pas chargée la demande et l'ouvre à son arrivée ; toute autre action annule cette attente. Le déplacement fait défiler juste assez pour montrer la difficulté choisie.
  • Réponses périmées. Chaque libraryPage et chaque library porte le queryId de sa requête (recherche, filtres, tri, sens, groupement, graine) : une réponse d'une autre requête est ignorée, même si son requestId est attendu. Le rafraîchissement des appartenances (tri par difficulté) garde le queryId de la liste, donc ne jette pas ses pages ; la sélection (lib/selection.ts) ignore les réponses d'une sélection remplacée et reste interactive pendant le chargement. Les notes arrivent avec la page ou la sélection ; le rattrapage du mod rafraîchit la note et les colonnes de détail de chaque vue, sans relancer la sélection ni perdre le résultat d'une partie.
  • Recherche structurée. libraryPage.query est un LibraryQuery généré par @pvng/db : { text, filters: [{ key, value }] }. Le texte libre attend 150 ms après la dernière frappe ; ajouter, modifier ou retirer un filtre applique immédiatement le texte courant. Tous les mots et tous les critères doivent correspondre à la même difficulté. Les sets ne contiennent que les difficultés correspondantes, avec leurs indices natifs inchangés ; chercher ne resélectionne jamais un chart. La recherche reçoit le focus à l'ouverture ; + Filtre ouvre les champs regroupés par fournisseur, puis un éditeur compact.
  • Catalogue extensible. libraryFilters publie le catalogue complet et sa révision monotone. Le sélecteur groupe les champs neutres dans « Chart » et les contributions par le vrai nom de leur mod. Le thème par défaut propose une liste recherchable au clavier, puis un éditeur de bornes numériques inclusives ou de texte « contient / égal à ». Les chips permettent de modifier et retirer chaque critère. Échap ferme le sélecteur ou annule l’édition avant d’ouvrir le menu de navigation ; un clic extérieur referme le sélecteur.
  • Collections. Le menu + Filtre liste les collections, favoris en tête, puis « Gérer les collections… » (boîte de gestion). Choisir une collection pose aussitôt le critère host/collection (SQL natif) ; un seul à la fois, et un nouveau choix remplace l'ancien. Une collection supprimée ou absente retire son critère. Le panneau de la map sélectionnée porte une case par collection ; l'action Ajouter aux favoris / Retirer des favoris est la première entrée du menu ⋯ (même commande qu'avant, icône étoile pleine ou vide), et un set en favori montre une petite étoile pleine après son titre (pas un bouton).
  • Validation et disponibilité. Au plus 512 caractères de texte libre et 16 critères ; au moins une borne numérique finie (négatifs et décimaux autorisés, minimum ≤ maximum), ou 1 à 256 caractères de texte non blanc. La recherche textuelle est littérale, sans distinction de casse ASCII : %, _ et \ ne sont pas des jokers. Un fournisseur supprimé, désactivé, en échec, ou dont le champ change de type laisse un chip indisponible : les résultats sont bloqués jusqu’au retour du fournisseur ou au retrait explicite du critère. Les anciennes révisions du catalogue sont ignorées.
  • Calculs en arrière-plan. Sans filtre de mod, les notes sont corrigées en place : ni reset, ni changement de défilement, de set ouvert ou de sélection. Avec un filtre de mod, le rattrapage peut changer les résultats : le navigateur demande au natif le rang filtré de deux identités au plus (libraryPage.anchors : dossier de la vue, dossier ouvert). La réponse library.anchors associe chaque dossier à son rang exact, ou null s’il ne correspond plus. Le rang suit le premier indice original du dossier, pas celui de la première difficulté restante. Le navigateur charge directement les pages de ces rangs et de la vue, puis les remplace atomiquement : même dossier ouvert et même décalage dans la vue, même après un déplacement de milliers de sets, sans charger toute la bibliothèque. Les anciennes lignes restent utilisables pendant cette préparation. La sélection native n’est jamais remplacée ; si les rangs ou le total changent pendant les requêtes bornées, les ancres sont résolues à nouveau.

Les composants n'implémentent aucun calcul de difficulté et ne reçoivent pas la bibliothèque entière. Le mock Web applique les mêmes conjonctions et la pagination côté hôte, avec des notes et tags déterministes ; désactiver/recharger leurs mods exerce le cycle de vie réel du catalogue.

Livré, à vérifier à la main. La manette agit comme le clavier sur les menus (menu/pad-nav.ts, menu/pad-nav-page.ts, menu/focus-move.ts) : une touche synthétique part sur l'élément focalisé, donc les gestionnaires existants (listes, menus contextuels, dialogues, palette) la traitent comme une vraie frappe. Si aucun ne la prend, le repli déplace le focus au plus proche dans la direction (ou change un curseur), et Entrée clique le bouton, le lien ou la case focalisés.

Croix et stick gauche : flèches, fixes (tenue répétée après 400 ms, puis toutes les 90 ms). A, B, Y, LB et RB : Entrée, Échap, champ de recherche visible, onglet précédent et suivant dans une liste d'onglets, par défaut. Ces cinq entrées se règlent par profil de contrôles (Réglages → Contrôles → Navigation des menus, voir manettes) ; Start n'a pas d'action dans les menus.

Tests : pad-nav.test.ts, focus-move.test.ts, tests/menu-pad.spec.ts.

API de comportement

apps/web/src/menu/behavior.ts exporte stores, actions, invoke(nom, arguments) et act(nom, ...arguments) (version typée pour les composants). Les types des valeurs viennent des types générés @pvng/* : les vues (views.ts) sont calculées à partir de BeatmapSet, LibraryEntry, SongInfo, Analysis, JudgementSetInfo… ; aucun type d'échange avec Rust n'est réécrit. StoreValue<nom> et ActionArgs<nom> donnent le type d'un store et des arguments d'une action.

Stores

Store Contenu
library query (texte saisi), filtered (texte ou critères actifs), total (sets du résultat, groupes repliés compris), loaded, empty, folders, hasFolders, scanning, progress, percent, issues (nombre), locked (hôte absent ou import en cours), grouped, groupCount, foldedCount, canFoldAll (liste groupée : au plus 512 groupes se replient d'un coup)
libraryFilters ready, revision, options (clé, label traduit, groupe, type, unité), groups (name, options), active (métadonnées, summary, unavailable, removeLabel), editor (key, label, group, unit, number, available, min, max, text, match), error traduit, blocked, status traduit, limit (16 critères atteints)
scrollSpeed ready, scrollMs (temps exact résolu ou null), playDisabled (verrou de bibliothèque ou conversion indisponible)
sets la liste virtuelle : extent (px), rows (en-têtes de groupe group avec label, letter, countText, collapsed, sets, difficultés, placeholders), total, loaded, expanded (rang, -1 sans set ouvert ou quand son groupe est replié), scroll ({ top, seq } : demande de défilement), rail (les lettres d'un groupement par lettre, 40 au plus ; vide sinon)
selection hasSong, loading, demo, title, artist, creator, difficulty, keys, keysText, color, rating, ratingUnit, duration, bpm, notes, holds, averageNps, peakNps, panels, density, binSeconds, background ; value, durationSeconds, bpmMin, bpmMax sont les nombres résolus pour une présentation animée
chartPanel info, leaderboard, history ou mods : vue du chart sélectionné
visualizer open (le panneau de la liste est remplacé par l'aperçu du chart), status (loading, playing, paused, ended, closed), positionUs (dernier rapport de l'hôte), durationUs, playbackRate, seeking (docs/visualiseur.md)
playbackRate value, min, max, step, loading, error, available, displayedRate, mock ; displayedRate identifie le dernier résultat cohérent pendant le recalcul
gameplayMods label, mods : modificateurs de jeu publiés (key, modId, kind, name, short, description, enabled, group, icon, conflicts, params) pour le sélecteur de l'onglet Mods
leaderboardSource local ou online
leaderboard loading, loadingMore, entries, total, evaluationId, unavailableReplays, performance, performanceLabel, mock : classement paginé rejugé avec le jugement courant, noms enregistrés, compteurs par palier et performance choisie (pp / SSR, ou null)
performance chosen (chaîne vide pour Auto), label, choices : calculateurs natifs de performance disponibles, indépendants de la note de difficulté
replay open, loading, replayId, evaluationId, detail, judgement, error, performance, performanceLabel, mock, analysis, rating, ratingUnit, ratingLabel, color : analyse au rate enregistré, avec jugement et calculateur courants
judgement ready, label (« osu!mania · OD 8 »), sets (key, name, mod, active), tiers (index, name, color, gradient?), refusal (message localisé quand aucun mod de jugement n'est installé et que le choix est un jeu de mod, sinon null : play ne fait alors rien)
rating chosen (<mod>/<id>), calculator, label, choices (notes affichables), options (toutes, avec hidden et performance) ; le calculateur détermine les nombres de sets et selection, jamais les règles de jugement
navigation page (page sous-jacente), drawer, settings (fenêtre modale ouverte), pages (id, label, active)
result visible, aborted, auto (partie Auto : ni score ni replay), accuracy, maxCombo, tiers (name, color, gradient?, count) ; résumé masqué après dix secondes, sans toucher aux scores enregistrés ni à l'analyse de replay
i18n locale, messages (catalogue de la langue, anglais en repli)
modOptions groups (modId, name, elements : key, name, resettable, options : name, label, kind, min, max, step, values, maxLength, value, default, resettable) : les options que les mods déclarent player: true (leurs réglages propres, globaux), générées depuis leurs déclarations
skinCustomization editing (session de l'éditeur de skin en cours) et skins : un élément par skin installé, avec id, selected, customized (la configuration du joueur n'est pas vide), playfield (valeurs du playfield réglées), lanes (colonnes qui ont leurs propres valeurs), elements (widgets du HUD réglés), imported (images importées, null tant que l'hôte ne les a pas signalées) et canCustomize (le skin en cours, hors session)

selection.panels fournit les panneaux résolus dans l'ordre du mod. Chaque panneau a kind et title ; les panneaux de valeurs ont fields (label, unit, decimals, value ou null, display prêt à afficher), les timelines ont series (label, source, unit, values, binSeconds).

Une ligne de sets.rows a kind, key, position, top, height et :

  • set : expanded, selected, set (id, title, artist, creator, count, countText, keysText, ratingText, color, chips, thumbnail) ; chips liste ses difficultés de la plus facile à la plus dure sur la note choisie (index, name, keysText, value, rating, short = note arrondie, color = ratingColor), les non notées en dernier ;
  • difficulty : difficulty (rang dans le set), selected, last, entry (index, name, keys, keysText, rating, ratingUnit, color, notes, duration) ;
  • placeholder : une page pas encore arrivée.

Les textes affichés (« 3 difficulties », « 4K », « 12.40–21.03 MSD ») sont déjà traduits : tous les thèmes montrent les mêmes valeurs.

Actions

Action Arguments Effet
play — joue la sélection (sans effet pendant un import)
chart.panel info, leaderboard, history ou mods change le panneau du chart, sans le resélectionner
editor.open aucun ouvre la map sélectionnée dans l'éditeur de maps (bouton « Éditer » à droite des onglets) et affiche la page de l'éditeur ; une map qui n'est pas un .rox est éditée par une copie .rox à côté, l'original n'est jamais réécrit
gameplayMod.set clé <modId>/<id>, booléen active ou désactive un modificateur publié (sans effet pour une clé inconnue) ; synchronise Settings.gameplayMods
playbackRate.set nombre règle le rate natif, borné entre 0,5 et 2 par pas de 0,05 ; synchronise Settings.playbackRate
leaderboard.source local ou online change la source affichée du classement
leaderboard.more — charge la page suivante du classement courant
history.load — charge ou recharge l'historique de la map (aussi après une erreur)
history.rate nombre choisit le rate tracé ; une réponse pour un autre rate est ignorée
history.open identifiant d'un replay de l'historique ouvre son analyse
replay.open identifiant d'un replay du classement ouvre sa page d'analyse
replay.back — revient à la sélection préservée
replay.compare identifiant d'un autre replay du classement, ou chaîne vide choisit ou retire la référence, réévaluée avec le score principal
replay.watch — regarde le replay courant au rate enregistré, sans créer de score
replay.export_video résolution (480p, 720p, 1080p, 1440p ou 2160p), images par seconde (24, 30, 60 ou 120), qualité (fast, balanced ou best) exporte le replay courant en WebM (vidéo AV1, audio Opus), côte à côte avec la référence si le comparatif est choisi ; le débit, l'intervalle d'images clés et l'encodeur sont ceux que le joueur a gardés ; voir export vidéo
replay.share identifiant d'un replay du classement écrit ce score dans un fichier .pvreplay choisi avec la boîte d'enregistrement native (voir docs/game.md)
replay.import — ouvre un fichier .pvreplay avec le sélecteur natif ; le déposer sur la fenêtre fait la même chose
performance.choose identifiant d'un calculateur disponible, ou chaîne vide pour Auto recalcule la performance avec la précision du jugement courant ; les mélanges de modèles sont signalés
library.search texte recherche (appliquée 150 ms après la dernière frappe)
library.filter.edit clé du catalogue ouvre l’éditeur du champ, prérempli si le critère existe déjà
library.filter.field champ (min, max, text, match), texte modifie le brouillon ; match vaut contains ou exact ; une borne vide signifie aucune limite
library.filter.apply — valide et applique le critère ; une erreur conserve le brouillon et la requête précédente
library.filter.cancel — abandonne le brouillon sans changer les résultats
library.filter.remove clé retire explicitement ce critère, même si son fournisseur est indisponible
library.toggle position ouvre le set, ou le referme s'il est ouvert
library.select position, rang sélectionne une difficulté (ouvre son set)
library.collapse — referme le set ouvert
library.up, library.down, library.previousSet, library.nextSet — voir les touches
library.viewport haut, hauteur (px) décalage et hauteur de la liste rendue
library.metrics pas d'un set, d'une difficulté, d'un en-tête de groupe (8 à 1000 px) géométrie de la liste rendue (--pv-set-row/-gap, --pv-diff-row/-gap, --pv-group-row/-gap)
library.group.toggle clé du groupe replie ou déplie le groupe sur place (aucune requête, défilement et sélection gardés) et retient l'état dans les réglages
library.group.jump clé du groupe fait défiler la liste jusqu'à l'en-tête du groupe (la barre de lettres)
library.groups.collapseAll / library.groups.expandAll replie ou déplie tous les groupes (jusqu'à 512)
library.import, library.refresh, library.demo — ajouter des dossiers, actualiser, démo sans audio (le bouton « Démo » n'est affiché que bibliothèque vide)
library.visualize — ouvre le visualiseur de map sur le chart sélectionné à la place de la liste, ou le referme ; nécessite le panneau par défaut (un modèle n'a pas de viewport à découper)
library.random — tire une map au hasard parmi les résultats actuels de la liste (voir « Map aléatoire »), s'y rend et la sélectionne ; ne lance jamais de partie ; sans effet pendant le visualiseur, les playlists ou un chargement
navigation.open destination (library, download, multiplayer, profile, skins, settings, mods : la liste typée pageList de lib/navigation.ts, une page de plus s'y déclare une fois) change de page, sauf settings qui ouvre la fenêtre modale sur la dernière catégorie
navigation.settings catégorie (gameplay, controls, audio, video, judgement, scroll, library, interface, mods ; les anciens general, display et hud ouvrent interface, video et interface) ouvre la fenêtre des Réglages au-dessus de la bibliothèque, sur cette catégorie
navigation.drawer — ferme les Réglages s'ils sont ouverts ; sinon ouvre ou ferme le menu Échap
judgement.choose clé d'un jeu jouable choisit ce jeu de jugement
rating.choose clé d'une note enregistrée et visible change le calculateur affiché pour toute la bibliothèque
rating.toggle clé d'une note enregistrée masque ou réaffiche son choix dans le sélecteur, indépendamment du mod qui la fournit
skin.customize — démarre l'éditeur de skin sur le skin en cours (editLayout) ; les Réglages se masquent tant que dure la session
skin.resetCustomization id du skin retire toute la configuration du joueur de ce skin (réglage skinConfig, aucune commande dédiée de l'hôte) ; refusée pendant une session
modOption.set clé de l'élément (<mod>/<élément>), nom de l'option, valeur (booléen, nombre ou texte) change une option joueur d'un élément (editElement avec commit) ; une valeur hors déclaration est refusée sans commande, les nombres sont bornés
modOption.reset clé de l'élément, nom de l'option rétablit sa valeur par défaut déclarée
modElement.reset clé de l'élément rétablit les valeurs par défaut de toutes ses options joueur

invoke refuse un nom inconnu (y compris les noms de Object.prototype), un nombre d'arguments différent ou un argument d'une autre sorte (entier, nombre fini, texte de 1000 caractères au plus, booléen ou nombre fini ou texte pour une valeur d'option, page ou catégorie connue).

Les thèmes et la couche de comportement

pages/Library.svelte (barre d'outils, recherche, état de l'import), components/SongList.svelte (la liste virtuelle, data-pvng-list="sets", pas lus dans les jetons --pv-set-row/-gap et --pv-diff-row/-gap), components/SelectedMap.svelte (panneau de gauche, JudgementPicker, Jouer) lisent stores et appellent act(...) ; App.svelte applique la keymap (DEFAULT_KEYMAP, menu/keys.ts) et le menu Échap suit navigation.drawer.

Le thème choisi (Skins → Interface, réglage uiTheme) est l'attribut data-direction de <html> ; ses jetons --pv-* sont posés par theme/apply.ts à partir de l'événement interfaces que l'hôte publie après validation. Le thème classic garde les styles d'origine des composants ; les autres reçoivent la structure du système de design (theme/ui.css).

Sélecteur de modificateurs (onglet Mods de la map)

L'onglet Mods de la map choisie (à ne pas confondre avec la page Mods du tiroir, qui liste les mods installés avec leurs interrupteurs : elle n'est pas modifiée ici ; une liste groupée avec une icône par mod y serait la suite logique) montre d'abord Rate (compact, sans paragraphe), puis les modificateurs de jeu en tuiles : components/ModPicker.svelte. C'est la seule interface de cet onglet : les modificateurs et leurs paramètres sont ceux que l'hôte déclare, l'interface ne les invente pas.

  • Disposition : les familles sont des blocs côte à côte qui coulent sur la largeur de l'onglet (flex-wrap) : sur une même ligne tant qu'elles tiennent (les trois familles actuelles tiennent sur une ligne à 1400×850, en français aussi), sinon une famille passe à la ligne suivante, sans jamais couper un nom. Les tuiles d'une famille restent sur une ligne (largeur selon le contenu, 84 px au moins, nom en 12 px, aucune troncature) et ne bougent pas quand on sélectionne (le nom ne passe pas en gras ; la place du bouton Réglages est réservée). Flèches : gauche/droite suivent l'ordre de lecture d'une famille à la suivante ; haut/bas vont à la ligne voisine, à la tuile la plus proche en colonne (rien en dessous : sans effet).
  • Familles (petit titre, familles vides omises) : le champ group de l'hôte (longNotes, layout, assist, timing, difficulty, other), dans cet ordre : Longues notes d'abord (No LN et Full LN côte à côte), puis Disposition, Assistances, etc. Un groupe inconnu vient en dernier ; ses textes sont chartgroup.<groupe>.
  • Tuile : une icône lucide, un nom court, un bouton à bascule (aria-pressed) ; sélectionnée, l'icône est sur l'accent du thème et la tuile porte le liseré d'accent de ses plaques. La description et les conflits sont dans l'infobulle commune (use:tooltip) et, pour les lecteurs d'écran, dans aria-describedby. Uniquement des jetons du thème.
  • Icônes : le champ icon de l'hôte est cherché dans la table fermée lib/mod-icons.ts (les 28 icônes lucide de MODIFIER_ICONS de crates/modding/src/modifiers.rs, une liste que mod-icons.test.ts relit dans le source Rust) ; tout autre texte donne la pièce de puzzle, jamais rien d'injecté dans la page.
  • Conflits : conflictsWith de l'hôte, symétrique (l'un des deux côtés suffit). Activer un modificateur désactive ceux avec qui il est en conflit (setGameplayMod) et une annonce polie (role="status") le dit ; une sélection ancienne qui contiendrait les deux affiche les deux tuiles en avertissement (triangle, texte warn), et l'hôte joue sans l'un ni l'autre.
  • Clavier : un seul arrêt de tabulation sur la grille (roving tabindex) ; flèches gauche/droite d'une tuile à l'autre, haut/bas vers la rangée voisine, Début/Fin ; Espace ou Entrée bascule. Le mouvement réduit supprime les transitions.
  • Réglages d'un modificateur : un modificateur dont l'hôte déclare des params reçoit, tant qu'il est sélectionné seulement, un petit bouton-icône « Réglages » posé sur sa tuile (un arrêt de tabulation à part). Il ouvre un petit menu (ChartPopover en role="dialog", ModParamsMenu.svelte) : Échap ou un clic dehors le ferme et rend le focus au bouton ; il est borné par la fenêtre (il défile à l'intérieur s'il est long) et ne déplace rien dans la grille. Le menu est généré du schéma de l'hôte : titres = section, un curseur + une case numérique modifiable (unité à droite) pour un paramètre slider, des boutons segmentés (radiogroup, flèches gauche/droite) pour un choice, seulement les paramètres dont la condition showWhen est vraie (gapMs si l'unité d'écart est fixe, gapPercent si elle est en %), un bouton-icône « Défauts ». Chaque changement s'écrit tout de suite dans le réglage gameplayModParams (même synchronisation que les autres réglages ; l'hôte valide, arrondit et enregistre dans le replay), et une ligne d'état donne le compteur calculé par l'hôte (chartModsPreview, demandé 120 ms après le dernier changement). Rate n'a pas d'options : pas de bouton de réglages.
  • Contrat : GameplayModifierInfo (group, icon, conflictsWith, params: ModParamInfo[], voir docs/modding.md et docs/game.md) ; le simulateur de développement publie le schéma Rust (src/mock/full-ln-params.json, tenu à jour par un test). Les textes viennent des clés chartgroup.* et chartparam.*, le repli est le texte anglais de l'hôte.
  • Tests : lib/mod-picker.test.ts, lib/mod-icons.test.ts, lib/gameplay-mods.test.ts ; tests/mod-picker.spec.ts (tuiles, exclusion, infobulle, menu généré, choix et conditions, compteur, clavier, aucun chevauchement à 1400×850 et 1000×700 en classic, cabinet, macos-light).

Test du clavier (Réglages → Contrôles)

Sous le test des manettes, components/settings/KeyboardTest.svelte montre ce que le clavier transmet à l'hôte pendant que la page est ouverte : chaque touche tenue (avec son côté, d'après le code de touche), le nombre de touches tenues ensemble et le maximum atteint (le rollover), le nombre de rebonds (deux appuis d'une même touche à moins de 10 ms : un interrupteur usé), le plus court intervalle entre deux appuis d'une même touche, le nombre d'événements dans la dernière seconde et les derniers événements. Le bouton ↺ remet tout à zéro. La page demande keyTestWatch (active: true) à l'ouverture et active: false à la fermeture : pendant ce temps l'hôte envoie les bords des touches au test (input/raw.rs, Controller::key_test_edge) et le jeu n'en reçoit aucun (ni pause, ni sortie, ni changement de touche). Rien n'est enregistré ni envoyé. Un appui ne compte que quand Prism a le focus ; un relâchement compte toujours. Le « taux » est le nombre d'événements par seconde, pas la fréquence d'interrogation du clavier, qui ne se mesure pas depuis les seuls bords.

Champ de date

Le champ de date du jeu (components/ui/DateField.svelte, fonctions pures lib/date-field.ts) remplace tout <input type="date"> natif : un test (date-field.test.ts) refuse qu'il en reste un. Le champ natif écrit ses dates dans la langue du WebView (« jj/mm/aaaa » sous un Windows français) quelle que soit celle du jeu, et lang n'y change rien dans Chromium.

  • Affichage : Intl.DateTimeFormat(langue du jeu, dateStyle: medium) (« Oct 16, 2026 », « 16 oct. 2026 », « 2026年10月16日 », « 2026. 10. 16. »). La valeur interne est toujours ISO YYYY-MM-DD, lue comme une date civile (aucun fuseau). Vide = pas de borne (« Any date »).
  • Saisie tolérante (parseDate) : ISO, forme courte de la langue (10/16/2026 en anglais, 16/10/2026 en français, année d'abord en chinois et en coréen), n'importe quel séparateur, mois en lettres de la langue ou en anglais, année à deux chiffres = 20xx. Le texte est lu à Entrée ou à la sortie du champ ; une date qui n'existe pas (30 février) ou un texte inconnu marque le champ invalide sans rien deviner. Une date hors des bornes est ramenée à la borne.
  • Calendrier (popover dans la couche supérieure : aucun parent qui défile ne le coupe ; au-dessus du champ quand il n'y a pas de place dessous) : mois, flèches, jours (lundi en premier), aujourd'hui, effacer, jours hors bornes désactivés. Clavier : flèches = jour et semaine, Page précédente/suivante = mois, Début/Fin = bords de la semaine, Entrée choisit, Échap ferme et rend le focus au champ (sans ouvrir le tiroir).
  • Thèmes : jetons seulement, comme les autres contrôles. Le champ est dans OWN_LOOK de shared-inputs.test.ts (il dessine sa propre plaque).

Palette de commandes (Ctrl+K)

Une fenêtre de saisie unique au-dessus de tout (Réglages et tiroir compris : un <dialog> modal est dans la couche supérieure) qui mène directement à un endroit : « settings audio » ouvre Réglages → Audio, « audio offset » ouvre Réglages → Jeu sur la ligne du décalage audio et la met au point, le titre d'une map la sélectionne. Flèches (et PageHaut/PageBas) + Entrée, Échap ferme et rend le focus à ce qui l'avait, la dernière commande choisie est en tête de la liste vide. Pas de raccourci global au niveau du système : elle ne s'ouvre que fenêtre de jeu active.

La touche

Réglage paletteBinding (défaut KeyK), une ligne ControlRow de Réglages → Contrôles → « Sélection de morceaux » à côté de Visualiser, même capture, même inventaire de touches (key-codes.json), mêmes règles (lib/controls.ts, côté hôte Settings::validate), refus avec le nom de qui détient la touche, Échap annule. Elle fait partie du profil de contrôles comme previewBinding (donc exportée avec « Inclure les touches », voir manettes).

  • Une touche qui écrit ou édite (lettres, chiffres, Espace, Tab, Suppr, pavé numérique…) ouvre la palette avec Ctrl (Cmd d'un clavier Mac) : c'est Ctrl+K par défaut, et ça marche au-dessus d'un champ texte comme Ctrl+K ailleurs. Une touche qui n'écrit rien (F1 à F24, verrouillages, Pause, modificateurs, touches média) l'ouvre seule, sans modificateur. Le libellé de la ligne le dit (« Ctrl + K », « F1 »). Un modificateur choisi comme touche ne l'ouvre que sans autre touche et hors d'un champ ou d'un bouton, pour que taper une majuscule ne l'ouvre pas.
  • Règles : elle diffère de la touche Visualiser (en cas de conflit dans un vieux fichier, la palette garde la touche et Visualiser retrouve Espace) et n'est aucune touche de la liste de morceaux (MENU_KEYS : flèches, Entrée, Échap, Début, Fin, PageHaut, PageBas ; raison menu). Contrairement à Visualiser, elle peut être la même touche que pause, quitter, recommencer, saut d'intro, capture d'écran, une colonne ou une action de mod : le visualiseur fait tourner une vraie partie où l'hôte lit ces touches, la palette y est fermée, donc rien ne les lit ensemble.
  • Fermée pendant le visualiseur, un entraînement, un replay en chargement ou en lecture (lib/palette/facts.ts, paletteBlocked), la session de l'éditeur de skin, la boîte de compte (mot de passe) et l'invite d'offset (une question à trancher). Une partie normale masque la page de menu : aucune touche ne lui parvient. Elle ne s'ouvre pas pendant une capture de touche de Réglages (la capture arrête l'événement avant la palette). La touche de la palette la ferme toujours.

Architecture : un module pur, un composant

Tout est dans apps/web/src/lib/palette/ ; le composant components/CommandPalette.svelte (monté une fois dans App.svelte) ne fait que dessiner et router les touches.

Fichier Rôle
types.ts PaletteEntry (id stable, label, detail, group, icon, texts/haystack de recherche, weight, floor, run) et PaletteProvider (entries() synchrone et/ou search(query, signal) asynchrone).
score.ts le petit scoreur flou : sans casse ni accents (fold de la recherche des Réglages, largeurs pleines repliées), tous les mots de la requête doivent être trouvés ; un mot vaut 100 (nom exact), 85 (début du nom), 65 (début d'un mot), 45 (dans le nom), 25 (dans les autres textes : autres langues, alias, descriptions), au plus 20 en lettres dans l'ordre (3 lettres minimum : « vlm » trouve « Volume ») ; un nom plus court gagne une égalité.
registry.ts PaletteRegistry (les fournisseurs), rank (classement), suggest (liste vide : récents puis entrées pondérées).
recent.ts RecentCommands : les 12 derniers ids choisis (localStorage prism.palette.recent, jamais d'objet) ; la dernière commande gagne une égalité (+12, puis +9, +6, +4, +2). Un id que plus aucun fournisseur ne liste est ignoré.
session.ts PaletteSession : requête, liste, ligne active, recherches asynchrones (pause debounceMs, abandon par AbortController à la frappe suivante, une réponse périmée n'écrase jamais) ; sans DOM, testée avec un ordonnanceur factice.
key.ts, facts.ts la décision de la touche (fonction pure des faits de l'événement) et la lecture des faits dans les stores.
providers.ts, index.ts les fournisseurs intégrés et le câblage (registre, récents, session, openPalette()/closePalette()).

Source unique : les entrées viennent des catalogues que le reste du jeu tient déjà, jamais d'une liste à part : pageList (le tiroir), settingsCategories et DEFINITIONS de lib/settings-index.ts (la recherche des Réglages, plus les options que déclarent les mods), les actions existantes (play, scan, act('library.reshuffle'), openAccount) et la requête paginée de la bibliothèque. Les libellés viennent des catalogues de langues ; chaque entrée est cherchée dans les quatre langues (donc « settings audio », « réglages audio », « 设置 音频 » et « 설정 오디오» mènent tous à Réglages → Audio) et avec les alias anglais. Une ligne de Réglages passe par le même lien openSettings(catégorie, ancre) / settingsTarget que le reste (SettingsDialog défile jusqu'à la ligne, la déplie par l'événement reveal et la met au point ; il suit désormais le store, donc ça marche aussi quand Réglages est déjà ouvert).

Ajouter un nouveau type d'entrée (collections, favoris le jour où ils existent, commandes d'un mod…) : paletteRegistry.register({ id, entries: () => [...] }) (ou search), c'est tout ; ni le composant ni la session ne changent. Collections et favoris n'existent pas encore dans le jeu : il n'y a donc pas de fournisseur pour eux.

Les maps ne chargent jamais la bibliothèque dans le WebView : mapsProvider.search envoie libraryPage (la même requête paginée que la liste, text = la saisie, une seule page, 8 maps au plus affichées) après 150 ms de pause et pour 2 caractères au moins. Ses requestId (et son queryId, égal au requestId) partent de 2^52 : la liste utilise l'horloge pour son queryId, l'hôte n'abandonne une page que si son queryId est plus ancien que la dernière requête « outline », et la palette n'envoie jamais d'outline, donc ses pages ne sont jamais abandonnées. La liste de morceaux ignore ces réponses (id inconnu). Choisir une map fait navigate('library') puis browser.goTo(index, titre) comme « aller à la map » du Profil ; le texte de recherche de la liste devient ce titre.

Accessibilité : <dialog> modal nommé, champ role="combobox" (aria-controls, aria-activedescendant suit la ligne active, aria-expanded), liste role="listbox" de role="option", compteur de résultats en role="status" (poli), animation coupée sous prefers-reduced-motion, couleurs et rayons uniquement par jetons --pv-*, icônes lucide, textes par les quatre catalogues (palette.*).

Tests : lib/palette/*.test.ts (scoreur, registre, session, récents, règles de la touche et de sa sauvegarde, fournisseurs dans les quatre langues), lib/controls.test.ts (règles de capture), tests/command-palette.spec.ts (Playwright, simulateur : ouverture, focus rendu, « settings audio » dans les quatre langues, ligne de réglage mise au point, map sélectionnée, fermée pendant le visualiseur, retouche de la touche).

Source dans le dépôt du jeu : docs/menus.md