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
setHeightpx (row pitch, spacing included); the open set addsnumber 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.queryis aLibraryQuerygenerated 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.
libraryFilterspublishes 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). Thelibrary.anchorsresponse maps each folder to its exact rank, ornullif 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);chipslists 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
groupfield (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 arechartgroup.<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, inaria-describedby. Theme tokens only. - Icons: the host's
iconfield is looked up in the closed tablelib/mod-icons.ts(the 28 lucide icons ofMODIFIER_ICONSincrates/modding/src/modifiers.rs, a list thatmod-icons.test.tsre-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,warntext), 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
paramsreceives, only while it is selected, a small "Settings" icon button placed on its tile (a separate tab stop). It opens a small menu (ChartPopoverwithrole="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 asliderparameter, segmented buttons (radiogroup, left/right arrows) for achoice, only the parameters whoseshowWhencondition is true (gapMsif the gap unit is fixed,gapPercentif it is in %), a "Defaults" icon button. Each change is written immediately to thegameplayModParamssetting (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 thechartgroup.*andchartparam.*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).