On this page

← All documentation

Menus: behaviour and themes

Menus: behaviour, the behaviour API and themes, the modifier selector.

Menus separate what they do from what they look like:

  • a typed behavior layer (apps/web/src/menu/): named stores (what the menus display, as JSON values) and named actions (what they can do), with no markup;
  • the interface's Svelte components, written on top of this layer (they read the same stores and call the same actions);
  • interface themes (Classic, Slant, Cabinet…): data (a manifest, CSS tokens validated by the host, images) that restyle these components without replacing them, see themes and interfaces. There is no longer an HTML menu model: a third-party theme contains neither markup nor script.
flowchart LR
  host[Rust host<br/>window.ipc] <--> shell[Main page<br/>bridge.ts, host.ts]
  shell --> behavior[Behavior layer<br/>stores + actions]
  behavior --> svelte[Svelte components]
  themes[Themes<br/>validated tokens] --> svelte

This layer covers map selection; Settings, Mods, Skins, and the Workshop are opened through its actions, like the Esc menu.

Map selection

The list on the right shows the beatmapsets (song folders). As in osu!, clicking a set opens it in place: its difficulties appear below it, each with its layout ("4K"), its name, and the rating of the view recorded by the chosen mod. A missing value stays "—" and takes a neutral color; a MinaCalc value uses a ramp adapted to MSD, and a star rating uses the star spectrum. Opening a set selects its already chosen difficulty, otherwise the first one; clicking a difficulty selects it; clicking the open set closes it again. Only one set is open at a time. The left panel follows the selection: title, artist, chosen rating and essential statistics, then the analysis panels declared by the rating mod. The theme does not decide their content from the calculator's name: Etterna chooses a radar accompanied by bars carrying the real skillset names, osu a chart profile and a timeline. Etterna declares a fixed scale of 0 to 40: 10 fills a quarter of a bar, 20 fills half; beyond 40, the fill saturates without changing the displayed number. A missing value has no fill.

The Info / Leaderboard / Mods tabs sit directly under the statistics, before their content; Local / Online remains a compact sub-choice of the leaderboard. Only the score list scrolls: the title, the statistics, the tabs, Local / Online, the Performance selector, and the footer stay fixed. The leaderboard shows no explanatory paragraphs about the current judgement, the selected performance, or the simulator values.

Mods contains the native rate (playbackRate), from 0.50 to 2.00 in steps of 0.05, with text entry, slider, and reset to 1, under the simple label "Rate". No inactive effect or explanatory paragraph about the rate or development data is offered. Changing the rate keeps the previous consistent analysis during computation, without a flickering loading message. Errors remain reported in a fixed location.

Below the rate, Mods lists a compact toggle for each gameplay modifier published by mods (gameplayMods), for example Auto from pvng.auto: label only, with the description in a custom tooltip on hover or focus, nothing else. The choice is the gameplayMods setting, removed with a notification if the mod that provides the modifier disappears. Numbers move to their new value without recreating the graphs or shifting the controls. An interruption restarts from the displayed number; reduced motion disables this interpolation, which is purely visual. Responses from an old chart, rate, or computation are ignored. Score cards and the replay page display the recorded rate, without a multiplication sign, with their own density and difficulty recomputed natively. Changing the menu rate does not rewrite these plays. The replay page has a Difficulty selector next to Judgement and Performance: it is the same component (RatingPicker) and the same setting (ratingSystem) as the selector in the selection footer, so ONE choice for the whole game (changing one changes the other, as with Judgement and Performance). Same calculators, grouped by mod, with search and keyboard; hiding a difficulty remains a separate action in Settings. The value, colored by ratingColor and rounded to 2 decimals, is that of the chart at the recorded rate of the replay (native analysis of that rate, never a 1x value multiplied); the rate is written below it ("at speed 1.50"). Switching calculators simply reads another already computed value: the number moves to its new value in place, without reloading. A calculator with no value for this chart displays "Unavailable" in the same layout (the name always takes two lines), without shifting anything. Leaderboard cards do not display the difficulty: their row already carries rank, player, rate, combo, accuracy, performance, and tiers, and one more value would overload it; the replay page displays it. The panel footer stays visible on a single line: Rating and Judgement, clickable across their whole surface, then Play on the right. Menus are drawn by the theme (not native select elements), grouped by mod, with bounded height, with search beyond eight choices. ↑/↓, Home/End, and Enter browse and choose; Esc closes without opening navigation. The parameters of the chosen judgement remain in its menu. Settings distinguishes Use from Hide/Show for a rating, without a checkbox.

Settings open in a large centered modal window, above the dimmed selection. The library stays mounted: search, open set, chosen difficulty, and scroll position are preserved. The categories (one icon each) are on the left, the search at the top; only the content scrolls (structure, search, restoration, export/import: reglages.md). Valid changes apply immediately, with no Apply button. The close button or Esc hands control straight back to the selection, without opening navigation. An open dropdown or an active key capture consumes Esc first. Focus stays in the window and returns to its available entry point; settings notifications are displayed in the window so they remain readable and dismissible.

The Scroll speed category is independent of Game and Judgement. It presents the mods' providers, including Prism in ms, and accepts free decimals outside the suggested slider. The Play button follows scrollSpeed.playDisabled, so as not to play a conversion that is still unknown.

Settings → Judgement simply displays the name of the active judgement ("Etterna · J4", for example), with a small chevron: clicking this name opens the list, with no large full-width field or permanent grid. A choice, a second click on the name, an outside click, or Esc closes the list; it opens above or below depending on the available space. Parameters have a theme-drawn slider and an editable value box with −/+ buttons. Enter confirms, Esc cancels an unconfirmed entry; the mod's available bounds, steps, and combinations remain respected. Under the sliders, the windows of each tier are displayed in milliseconds with their colors and update immediately from the host's tables. "−" means before the note, "+" after; "±" indicates the same tolerance on both sides. The small menu in the selection also displays these values under its sliders; Settings keeps the detailed table.

The theme distinguishes title, main rating, and secondary information; map rows have accents tied to their difficulty rather than an identical border everywhere. Selection, tabs, and menus have short transitions, disabled with prefers-reduced-motion: reduce. Timelines color low values in blue/cyan and high values in warm tones. The cursor gives the exact value and time of the bin, with mouse and keyboard alike; color does not replace the numbers. Timelines, radar points, and their value lines use the same custom tooltip: full label declared by the mod, actual value, unit, and time interval or declared scale. A missing data point remains "Unavailable". The time cursor and the radar lines are keyboard accessible; the tooltip accepts the pointer and stays bounded to the window. Esc or an outside click closes it without opening the navigation menu. The graph uses the available space (160 to 240 px tall) instead of leaving a large gap before the controls. At low heights, the panels keep their readable sizes and scrolling stays within the details, never across the whole page. Leaderboard offers Local / Online. Local shows a single leaderboard of clickable cards: player, rank, accuracy, combo, and performance above the date. The small colored counters give the hits for each tier of the current judgement; Miss is not repeated as a second metric. Clicking a card opens the dedicated replay page. All entries are re-judged from the replay's key presses and releases with the currently selected judgement, not with the one from the original play. Changing the judgement set or settings restarts the evaluation off the interface thread; old results never replace the current request. Missing or unreadable replays are flagged, without using their old accuracy as a fallback result. The full history can be browsed page by page, with no cap of twelve or twenty scores. The host keeps the re-evaluated leaderboard cached; requesting the next page does not re-judge all the replays. Pages from a previous map or a previous evaluation are rejected.

The name is saved in each new replay. Settings → Interface lets you choose the name for upcoming plays; if left blank, the native application uses the Windows account name. An older replay without a name stays flagged as such, without being retroactively attributed to the current player.

On desktop, the replay page places the player, the dominant accuracy, and the counters on the left and the analysis on the right, with no grid of rounded tiles. The chart stays in the header. The vertical name / count list never assumes six tiers: the 32 custom tiers and Miss remain accessible in their own scroll area, without moving the score or the graphs. The fill of each row represents its actual share of the total judgements. The Timing, Distribution, and Progression tabs avoid stacking or shrinking all the graphs; ←/→, Home, and End also let you select them from the keyboard. Typography stays readable and the page fits almost without scrolling at common desktop sizes. Axes are rendered in HTML, with no distortion from the SVG. Timing shows one point per native judge event, at its exact time, with its key, tier, and actual offset, with no averaging or min–max bar. The Canvas draws every event, including simultaneous hits, without sampling. Hovering or focusing a key highlights it; clicking it keeps it selected, and "All keys" restores all columns. Arrow keys, Home, and End also move through the keys from the keyboard. A column with no measured hit keeps its Miss counts and shows statistics as unavailable, never a false zero. Each Miss is a bright red cross on the center line, at its judgement time, with no offset. The crosses are drawn above the points so they are not hidden by a perfect hit. They are included in neither the offset statistics nor the histogram. The before/after windows of the active judgement keep their subtle background and colored limits, even when asymmetric. The help next to "Hit offset" explains signed offsets and the Miss crosses. Hovering inspects the nearest event; the control below the graph steps through events by time, with their identity to tell simultaneous notes apart. The tooltip gives the key, tier, exact time, and actual offset, or "Miss · no offset". ←/→, Home, and End step through the notes; Esc closes the tooltip without leaving the replay. The LN button opens a compact analysis: missed heads, early releases, releases within the window, and notes held to the end. The means and ranges for heads and releases come from the native engine's events, using the active judgement's hold model. A head that was actually pressed remains measurable even if the hold fails afterward; an automatic end does not fabricate a zero-offset release or an independent tail judgement. The distribution initially frames the populated bins (without losing any hits) and keeps zero as a reference. Its bars reuse the colors of the current judgement's windows, with their independent early/late bounds. A bin crossing a bound changes color at that point, without claiming to know the exact split of its hits between tiers.

All three views offer zoom via the mouse wheel, anchored under the pointer, or with the −/+ buttons, up to 32×: the wheel alone zooms the horizontal axis (time, or offset for the distribution), Shift + wheel zooms the vertical axis (Timing's ±ms offset, the distribution's count, the scale of each curve in Progression, which share the same vertical zoom; the visible bounds are shown below each curve). The ↕−/↕+ button group is the equivalent. Once zoomed, the view pans by dragging (both axes at once) or with the arrow keys; Full view restores the full extent of both axes. From the keyboard, the selected graph responds to + / − (horizontal axis), Shift + ↑ / ↓ (vertical axis), arrow keys (panning), and 0 (full view). Timing and Progression share the same time window; the distribution keeps its own independent offset window. Axes and inspection follow the visible extent, without enlarging text or inventing new points. Timing's keyboard and Progression's interval cursor bring the chosen measurement back into view. Zoom (horizontal and vertical) is preserved when changing judgement or calculator, and clamped if the data extent changes. The view state lives in lib/graph-view.ts (pure, tested functions).

The mean offset (left panel, Timing tab, Progression interval, audio offset prompt, detail of an error map cell) is colored by its distance from zero, in a continuous gradient: white up to 2 ms, yellow around 4 ms ("not great, be careful"), orange around 7 ms ("change your settings"), red from 10 ms ("change your settings now"). The four colors are the tokens --pv-offset-ok/-warn/-high/-bad (a light theme redefines them); lib/offset-color.ts (offsetColor, named anchors) computes the blend. The sign (early/late) stays in the text, and the advice is a tooltip and text read by screen readers.

Combo is blue, accuracy is dotted purple; density keeps a subtle fill and an amber/orange/red color tied to its magnitude. View transitions do not move the axes. Progression keeps the end-of-interval combo and accuracy values, without interpolation. Combo, accuracy, and chart density (notes/s) are overlaid in a single graph. The three legend buttons show or hide their curves independently, by mouse or keyboard. Scales and units are shown in these controls, with no value columns or space reserved to the left of the graph. The time axis and cursor remain shared. Hiding a curve does not resize the other scales and does not move the graph; hiding everything shows a prompt to pick one. The fills of intervals containing Misses are hidden by default. The Miss intervals button shows or hides those of the replay and its reference, independently of the curves, without changing the axes or the cursor. Their count remains shown on inspection, with no invented exact position. Density bins keep their native width, independent of the replay's intervals; the inspected value corresponds to the end of the selected interval. A missing density disables its button, without showing a curve at zero. The statistics come from native judge events; the absence of an offset on a Miss is never mistaken for a perfect hit. The full timing is loaded in pages of 2048 events from the same native snapshot, without re-reading or re-judging the replay on each page. A new analysis and its point histories are published together, once all pages have been received; stale pages cannot replace the displayed pair. Only Progression and Distribution keep their bounded aggregates. Returning to the leaderboard keeps the map, the loaded score pages, and the position in the library. Compare score picks another score on the same chart, with access to every page of the leaderboard and an action to remove the reference. A single request evaluates both replays under the same current judgement and calculator; a deleted or unreadable reference does not make the main score disappear. The reference shows its player, its recorded rate, and the available differences in accuracy, combo, difficulty, and performance. Its timing points are hollow, in the colors of the tiers, and its red Miss crosses are circled. Its progression curves are dashed and its distribution is drawn as an outline. The Reference button hides these overlays without changing the scales. If the rates differ, the reference's times are aligned by chart position (time × reference rate / main rate); the axis keeps the real seconds of the main replay, and offsets remain in real milliseconds.

Watch replay starts native playback of the recorded physical inputs, with the replay's rate and the judgement currently displayed. The playfield, audio, and HUD follow this playback; the overlay shows REPLAY and its rate. The spectator's key presses do not affect the notes; Esc returns to the preserved analysis, as does the natural end of the replay. Playback creates neither a score nor a new replay. In the development server only, a clearly identified simulator visualizes the chart and the fictitious inputs, without pretending to be the native rendering or judge.

Changing the judgement or the calculator no longer unmounts the analysis: the last consistent result remains displayed during recalculation. Numbers count up or down to the new values over 260 ms; an interruption restarts from the displayed number. This interpolation changes neither the stores, nor the calculations, nor the recorded results. Graphs keep a 180 ms transition, without inventing points between two aggregates. The tab, the cursor, the hidden curves, and the scroll position are preserved. Performance and its unit belong to the same result, with no mixing of old accuracy and the new calculator. Stale responses are still ignored. The animation is disabled, or stopped if it is in progress, with the reduced-motion preference. The simulator generates its density from the same notes as its replays; it does not stretch an independent graph to hide a different duration.

Visual references: Etterna results, Quaver interface and osu!mania results.

By default, performance follows the active judgement: osu!mania accuracy → osu! pp (Metron calculator osu-2018, displayed "osu!mania", in pp), Wife3 → MinaCalc 515 SSR. The player can choose another calculator with the Performance selector visible above the leaderboard, on the replay page, or in Settings. It is not hidden in the judgement menu. The difficulty rating remains separate. The accuracy of the current judgement is passed to the chosen calculator: a combination of different models is flagged as non-standard, and not as official pp/SSR. Only osu-2018 (pp) and etterna-515 (SSR) have a performance value; inputs that a calculator would additionally require are never invented.

The SQLite index is written after the replay is saved; interrupted plays are excluded. The identity includes the chart path and its difficulty index. Older replays without this identity are not matched by title. Online indicates the absence of the service, without inventing scores.

In the development web host (bun run dev), each difficulty in the mock library receives a rich deterministic history, with demo player names and simulated inputs re-judged when the judgement changes. The same data reappears after reload; the demo remains empty. Completed plays in the simulator are added to this history in memory, specific to each chart. MockOptions.replays lets you provide replay entries; scores provides already-evaluated rows for interface scenarios only. scores: {} leaves all leaderboards empty until a play is completed. The mock displays deterministic numeric pp/SSR values to work on the presentation, as requested: these are development fixtures, not a reimplementation of Metron. Its graphs still come from the simulated inputs. These fixtures are never written to the native library and do not make the online service available.

Key Action Effect
↓ library.down next difficulty of the open set, otherwise first difficulty of the next set
↑ library.up previous difficulty, otherwise last difficulty of the previous set
→ / ← library.nextSet / library.previousSet next / previous set, on its first difficulty
Enter play plays the selected difficulty
Esc navigation.drawer first closes open Settings; otherwise opens or closes the menu

With no selection, ↓ opens the first visible set. Selection keys only act on the Library page, with the menu and Settings closed. In a text field (the search), only Esc, Enter, ↑ and ↓ pass through; on a button outside the list, Enter and Space stay with the button; on a slider, a dropdown list, in a dialog box or a popover, only Esc passes through. The mouse wheel and the scrollbar only scroll the list.

Difficulty strip and map changes

Each set card carries a difficulty strip: as many pills as the strip's width allows on a single line, from the easiest to the hardest on the chosen rating, each colored by ratingColor, followed by a "+N" badge (N = difficulties not shown, accessible label "N more difficulties") whose hover or keyboard focus on the card opens the shared popover (ChartPopover) listing all difficulties. Depending on the available space, a pill is a plain colored bar, the rounded rating, or the layout and the rating (menu/difficulty-strip.ts, fitStrip): the computation depends only on the width of the list (measured once, never per row or per pill) and on the number of difficulties, because pill widths are fixed. The height is constant, nothing overflows, and no row moves: the list remains virtualized.

Choosing another map updates the left panel in place: the title and the graphs cross-fade, the numbers scroll (AnimatedNumber), and the panel keeps the last delivered analysis until the new one arrives, with no clearing and no layout jump. The panels' entrance animation replays only once the selection has settled: menu/settled.ts (createEntrance) sets data-entering on .selected-map 250 ms after the last change (the delay restarts on each change), only if the settled map belongs to a different set than the previous one (changing difficulty within a set replays nothing), and never under reduced motion. The trigger is shared by all three interfaces; each interface decides in its own CSS what .selected-map[data-entering] animates.

Virtual list

LibraryBrowser (apps/web/src/menu/library-browser.ts) keeps the list independent of its rendering:

  • Geometry. Each set occupies setHeight px (row pitch, spacing included); the open set adds number of difficulties × difficultyHeight. With a single open set, the position of a set and the set under a given offset are computed directly (ListLayout), without measuring the DOM. The renderer declares its steps (library.metrics); switching renderers keeps the first visible set at the top.
  • Stable height. From the first page on, the total height is total × setHeight; the arrival of pages never changes it. Opening a set reserves the height of its difficulties, which is known since its page is loaded; if a taller open set closes, the scroll is corrected so that the clicked set stays under the pointer.
  • Pages by set index. Pages of 80 sets (libraryPage, offset a multiple of 80), at most 12 kept (those farthest from the view are dropped); the pages requested are those that cover the view plus 5 sets on each side, and the next one. A scrollbar jump directly requests the target page; in the meantime, empty rows of the same height stand in for it.
  • Bounded rows. Only the sets around the view and, for the open set, the difficulties close to the view are rendered.
  • Keyboard. Moving to a set whose page is not loaded requests it and opens it when it arrives; any other action cancels that wait. The movement scrolls just enough to show the chosen difficulty.
  • Stale responses. A page from an old search or from a changed library (different requestId) is ignored; the selection (lib/selection.ts) ignores responses from a replaced selection and stays interactive while loading. Ratings arrive with the page or the selection; the mod catch-up refreshes the rating and the detail columns of each view, without restarting the selection or losing the result of a play.
  • Structured search. libraryPage.query is a LibraryQuery generated by @pvng/db: { text, filters: [{ key, value }] }. Free text waits 150 ms after the last keystroke; adding, editing, or removing a filter immediately applies the current text. All words and all criteria must match the same difficulty. Sets contain only the matching difficulties, with their native indices unchanged; searching never reselects a chart. The search field receives focus on opening; + Filter opens the fields grouped by provider, then a compact editor.
  • Extensible catalog. libraryFilters publishes the full catalog and its monotonic revision. The picker groups neutral fields under "Chart" and contributions under the real name of their mod. The default theme offers a keyboard-searchable list, then an editor for inclusive numeric bounds or for "contains / equals" text. Chips let you edit and remove each criterion. Esc closes the picker or cancels the edit before opening the navigation menu; a click outside closes the picker.
  • Validation and availability. At most 512 characters of free text and 16 criteria; at least one finite numeric bound (negatives and decimals allowed, minimum ≤ maximum), or 1 to 256 characters of non-blank text. Text search is literal, ASCII case-insensitive: %, _, and \ are not wildcards. A provider that is removed, disabled, failing, or whose field changes type leaves a chip unavailable: results are blocked until the provider returns or the criterion is explicitly removed. Old catalog revisions are ignored.
  • Background computations. Without a mod filter, ratings are corrected in place: no reset, and no change to scroll, open set, or selection. With a mod filter, the catch-up may change the results: the browser asks the native side for the filtered rank of at most two identities (libraryPage.anchors: the view's folder, the open folder). The library.anchors response maps each folder to its exact rank, or null if it no longer matches. The rank follows the folder's first original index, not that of the first remaining difficulty. The browser directly loads the pages for these ranks and for the view, then replaces them atomically: same open folder and same offset in the view, even after a jump of thousands of sets, without loading the entire library. The old rows remain usable during this preparation. The native selection is never replaced; if the ranks or the total change during the bounded requests, the anchors are resolved again.

Components implement no difficulty computation and do not receive the entire library. The Web mock applies the same conjunctions and the host-side pagination, with deterministic ratings and tags; disabling/reloading their mods exercises the real lifecycle of the catalog.

Behavior API

apps/web/src/menu/behavior.ts exports stores, actions, invoke(name, arguments) and act(name, ...arguments) (a typed version for components). Value types come from the generated @pvng/* types: the views (views.ts) are computed from BeatmapSet, LibraryEntry, SongInfo, Analysis, JudgementSetInfo…; no exchange type with Rust is rewritten. StoreValue<name> and ActionArgs<name> give the type of a store and of an action's arguments.

Stores

Store Contents
library query (entered text), filtered (text or active criteria), total, loaded, empty, folders, hasFolders, scanning, progress, percent, issues (count), locked (host absent or import in progress)
libraryFilters ready, revision, options (key, translated label, group, type, unit), groups (name, options), active (metadata, summary, unavailable, removeLabel), editor (key, label, group, unit, number, available, min, max, text, match), translated error, blocked, translated status, limit (16 criteria reached)
scrollSpeed ready, scrollMs (exact resolved time or null), playDisabled (library lock or conversion unavailable)
sets the virtual list: extent (px), rows, total, loaded, expanded (position, -1 with no open set), scroll ({ top, seq }: scroll request)
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 are the resolved numbers for an animated presentation
chartPanel info, leaderboard or mods: view of the selected chart
visualizer open (the list panel is replaced by the chart preview), status (loading, playing, paused, ended, closed), positionUs (last report from the host), durationUs, playbackRate, seeking (docs/visualiseur.md)
playbackRate value, min, max, step, loading, error, available, displayedRate, mock; displayedRate identifies the last consistent result during recalculation
gameplayMods label, mods: published gameplay modifiers (key, modId, kind, name, short, description, enabled, group, icon, conflicts, params) for the selector in the Mods tab
leaderboardSource local or online
leaderboard loading, loadingMore, entries, total, evaluationId, unavailableReplays, performance, performanceLabel, mock: paginated leaderboard re-judged with the current judgement, saved names, per-tier counters and the chosen performance (pp / SSR, or null)
performance chosen (empty string for Auto), label, choices: available native performance calculators, independent of the difficulty rating
replay open, loading, replayId, evaluationId, detail, judgement, error, performance, performanceLabel, mock, analysis, rating, ratingUnit, ratingLabel, color: analysis at the recorded rate, with the current judgement and calculator
judgement ready, label ("osu!mania · OD 8"), sets (key, name, mod, active), tiers (index, name, color, gradient?), refusal (localized message when no judgement mod is installed and the choice is a mod set, otherwise null: play then does nothing)
rating chosen (<mod>/<id>), calculator, label, choices (displayable ratings), options (all of them, with hidden and performance); the calculator determines the numbers in sets and selection, never the judgement rules
navigation page (underlying page), drawer, settings (modal window open), pages (id, label, active)
result visible, aborted, auto (Auto play: no score or replay), accuracy, maxCombo, tiers (name, color, gradient?, count); summary hidden after ten seconds, without touching saved scores or replay analysis
i18n locale, messages (the language's catalog, English as fallback)
modOptions groups (modId, name, elements: key, name, resettable, options: name, label, kind, min, max, step, values, maxLength, value, default, resettable): the options that mods declare player: true (their own global settings), generated from their declarations
skinCustomization editing (current skin editor session) and skins: one element per installed skin, with id, selected, customized (the player's configuration is not empty), playfield (playfield values that have been set), lanes (lanes that have their own values), elements (HUD widgets that have been set), imported (imported images, null until the host has reported them) and canCustomize (the current skin, outside a session)

selection.panels provides the resolved panels in the mod's order. Each panel has kind and title; value panels have fields (label, unit, decimals, value or null, display ready to show), timelines have series (label, source, unit, values, binSeconds).

A row of sets.rows has kind, key, position, top, height and:

  • set: expanded, selected, set (id, title, artist, creator, count, countText, keysText, ratingText, color, chips, thumbnail); chips lists its difficulties from easiest to hardest on the chosen rating (index, name, keysText, value, rating, short = rounded rating, color = ratingColor), with unrated ones last;
  • difficulty: difficulty (rank within the set), selected, last, entry (index, name, keys, keysText, rating, ratingUnit, color, notes, duration);
  • placeholder: a page that has not arrived yet.

The displayed texts ("3 difficulties", "4K", "12.40–21.03 MSD") are already translated: all themes show the same values.

Actions

Action Arguments Effect
play — plays the selection (no effect during an import)
chart.panel info, leaderboard or mods changes the chart panel, without reselecting it
editor.open none opens the selected map in the map editor ("Edit" button to the right of the tabs) and displays the editor page; a map that is not a .rox is edited through a .rox copy alongside it, the original is never overwritten
gameplayMod.set key <modId>/<id>, boolean enables or disables a published modifier (no effect for an unknown key); synchronizes Settings.gameplayMods
playbackRate.set number sets the native rate, clamped between 0.5 and 2 in steps of 0.05; synchronizes Settings.playbackRate
leaderboard.source local or online changes the displayed leaderboard source
leaderboard.more — loads the next page of the current leaderboard
replay.open identifier of a leaderboard replay opens its analysis page
replay.back — returns to the preserved selection
replay.compare identifier of another leaderboard replay, or empty string chooses or removes the reference, re-evaluated with the main score
replay.watch — watches the current replay at the recorded rate, without creating a score
replay.share identifier of a leaderboard replay writes this score to a .pvreplay file chosen with the native save dialog (see docs/game.md)
replay.import — opens a .pvreplay file with the native picker; dropping it onto the window does the same thing
performance.choose identifier of an available calculator, or empty string for Auto recomputes the performance with the accuracy of the current judgement; model mixes are flagged
library.search text search (applied 150 ms after the last keystroke)
library.filter.edit catalog key opens the field editor, prefilled if the criterion already exists
library.filter.field field (min, max, text, match), text edits the draft; match is contains or exact; an empty bound means no limit
library.filter.apply — validates and applies the criterion; an error keeps the draft and the previous query
library.filter.cancel — discards the draft without changing the results
library.filter.remove key explicitly removes this criterion, even if its provider is unavailable
library.toggle position opens the set, or closes it if it is open
library.select position, rank selects a difficulty (opens its set)
library.collapse — closes the open set
library.up, library.down, library.previousSet, library.nextSet — see the keys
library.viewport top, height (px) offset and height of the rendered list
library.metrics set step, difficulty step (8 to 1000 px) geometry of the rendered list
library.import, library.refresh, library.demo — add folders, refresh, demo without audio (the "Demo" button is only shown when the library is empty)
library.visualize — opens the map visualizer on the selected chart in place of the list, or closes it; requires the default panel (a template has no viewport to carve out)
navigation.open destination (library, editor, skins, settings, mods) switches page, except settings, which opens the modal window on the last category
navigation.settings category (gameplay, controls, audio, video, judgement, scroll, library, interface, mods; the old general, display and hud open interface, video and interface) opens the Settings window above the library, on this category
navigation.drawer — closes Settings if they are open; otherwise opens or closes the Esc menu
judgement.choose key of a playable set chooses this judgement set
rating.choose key of a registered, visible rating changes the calculator displayed for the whole library
rating.toggle key of a registered rating hides or shows its choice in the selector again, independently of the mod that provides it
skin.customize — starts the skin editor on the current skin (editLayout); Settings are hidden for the duration of the session
skin.resetCustomization skin id removes all of the player's configuration for this skin (skinConfig setting, no dedicated host command); refused during a session
modOption.set element key (<mod>/<element>), option name, value (boolean, number or text) changes a player option of an element (editElement with commit); a value outside the declaration is refused without a command, numbers are clamped
modOption.reset element key, option name restores its declared default value
modElement.reset element key restores the default values of all its player options

invoke rejects an unknown name (including the names from Object.prototype), a different number of arguments, or an argument of the wrong kind (integer, finite number, text of at most 1000 characters, boolean or finite number or text for an option value, known page or category).

Themes and the behavior layer

pages/Library.svelte (toolbar, search, import state), components/SongList.svelte (the virtual list, data-pvng-list="sets", steps read from the --pv-set-row/-gap and --pv-diff-row/-gap tokens), components/SelectedMap.svelte (left panel, JudgementPicker, Play) read stores and call act(...); App.svelte applies the keymap (DEFAULT_KEYMAP, menu/keys.ts) and the Esc menu follows navigation.drawer.

The chosen theme (Skins → Interface, uiTheme setting) is the data-direction attribute of <html>; its --pv-* tokens are set by theme/apply.ts from the interfaces event that the host publishes after validation. The classic theme keeps the components' original styles; the others receive the design system's structure (theme/ui.css).

Modifier picker (the map's Mods tab)

The Mods tab of the selected map (not to be confused with the drawer's Mods page, which lists installed mods with their toggles: it is not changed here; a grouped list with one icon per mod would be the logical next step) first shows Rate (compact, no paragraph), then the gameplay modifiers as tiles: components/ModPicker.svelte. This is the only interface in this tab: the modifiers and their parameters are the ones the host declares; the interface does not invent any.

  • Layout: the families are side-by-side blocks that flow across the width of the tab (flex-wrap): on a single row as long as they fit (the three current families fit on one row at 1400×850, in French too), otherwise a family wraps to the next row, without ever breaking a name. The tiles of a family stay on one row (width based on content, at least 84 px, name at 12 px, no truncation) and do not move when you select one (the name does not turn bold; the space for the Settings button is reserved). Arrows: left/right follow reading order from one family to the next; up/down go to the neighboring row, to the nearest tile by column (nothing below: no effect).
  • Families (small heading, empty families omitted): the host's group field (longNotes, layout, assist, timing, difficulty, other), in this order: Long notes first (No LN and Full LN side by side), then Layout, Assists, etc. An unknown group comes last; its texts are chartgroup.<group>.
  • Tile: a lucide icon, a short name, a toggle button (aria-pressed); when selected, the icon takes the theme accent and the tile carries the accent edge of its plates. The description and the conflicts are in the shared tooltip (use:tooltip) and, for screen readers, in aria-describedby. Theme tokens only.
  • Icons: the host's icon field is looked up in the closed table lib/mod-icons.ts (the 28 lucide icons of MODIFIER_ICONS in crates/modding/src/modifiers.rs, a list that mod-icons.test.ts re-reads from the Rust source); any other text yields the puzzle piece, and nothing is ever injected into the page.
  • Conflicts: the host's conflictsWith, symmetric (either side is enough). Enabling a modifier disables those it conflicts with (setGameplayMod) and a polite announcement (role="status") says so; an old selection that contained both shows both tiles with a warning (triangle, warn text), and the host plays without either.
  • Keyboard: a single tab stop on the grid (roving tabindex); left/right arrows from one tile to the next, up/down to the neighboring row, Home/End; Space or Enter toggles. Reduced motion removes the transitions.
  • A modifier's settings: a modifier for which the host declares params receives, only while it is selected, a small "Settings" icon button placed on its tile (a separate tab stop). It opens a small menu (ChartPopover with role="dialog", ModParamsMenu.svelte): Escape or a click outside closes it and returns focus to the button; it is bounded by the window (it scrolls internally if it is long) and does not move anything in the grid. The menu is generated from the host's schema: headings = section, a slider + an editable numeric field (unit on the right) for a slider parameter, segmented buttons (radiogroup, left/right arrows) for a choice, only the parameters whose showWhen condition is true (gapMs if the gap unit is fixed, gapPercent if it is in %), a "Defaults" icon button. Each change is written immediately to the gameplayModParams setting (same synchronization as the other settings; the host validates, rounds, and records it in the replay), and a status line gives the counter computed by the host (chartModsPreview, requested 120 ms after the last change). Rate has no options: no settings button.
  • Contract: GameplayModifierInfo (group, icon, conflictsWith, params: ModParamInfo[], see docs/modding.md and docs/game.md); the development simulator publishes the Rust schema (src/mock/full-ln-params.json, kept up to date by a test). The texts come from the chartgroup.* and chartparam.* keys, the fallback is the host's English text.
  • Tests: lib/mod-picker.test.ts, lib/mod-icons.test.ts, lib/gameplay-mods.test.ts; tests/mod-picker.spec.ts (tiles, exclusion, tooltip, generated menu, choices and conditions, counter, keyboard, no overlap at 1400×850 and 1000×700 in classic, cabinet, macos-light).

Source in the game repository: docs/menus.md