An interface (Classic, Slant, Cabinet…) is a theme: a simple folder, read from disk like mods and skins, which you delete to remove it. The player chooses their theme in Skins → Interface (setting uiTheme, the theme's identifier).
Why data and not code
Scripts never have access to the DOM, the CSS, or the JavaScript of the WebView, and every value that enters the page is validated in Rust (AGENTS.md). A theme is therefore data: a manifest, a token file (a strict subset of CSS), and images. The themes crate (crates/themes) validates everything against a contract of typed tokens, then the host publishes the interfaces event to the page with only the values that passed. The page sets them as CSS variables under [data-direction="<id>"]. The structural CSS (plates, buttons, tabs, list, graphs) stays in the application and reads only these tokens: a third-party theme restyles the whole interface without markup or script, and cannot execute or load anything.
Where themes are
Search order: themes/ next to prism.exe (in development: the repository's themes/ folder, found by walking up to Cargo.lock), then <data>/themes (PRISM_THEMES_DIR changes this folder). If the same identifier appears twice, the first wins and the second is reported. Each folder is named after its identifier (lowercase letters, digits, hyphens, 32 characters at most).
themes/<id>/
theme.json the manifest
tokens.css the tokens
preview.png (optional) preview; without it, the picker card is drawn using the tokens
images/… (optional) images referenced by image-type tokens
theme.json
| Field | Role |
|---|---|
id |
must be the folder name |
name |
1 to 48 characters |
description |
up to 240 characters |
version |
letters, digits, . _ + -, 32 at most |
author |
up to 64 characters |
order |
position in the selector (0 to 1000, 100 by default), then the identifier |
preview |
image from the folder (optional) |
accent |
"preference" (default): the accent follows the player's setting (Settings → Interface → Accent color: default, this is the theme's --pv-accent; artwork, the hue of the map's artwork, optional; custom, a chosen color); "fixed": the accent is always the theme's --pv-accent (signature color: phosphor green…). The old word "artwork" is accepted and is equivalent to "preference" |
tokens |
token file (tokens.css by default) |
localized |
{ "fr": { "name": "…", "description": "…" }, "zh": { … } }: name and description per language (catalog language code: en, fr, zh) |
tokens.css
One :root { --pv-x: value; … } block and up to two @media (max-width: <n>px) { :root { … } } blocks for narrow windows (320 to 4000 px). Nothing else: no other selector, property, @import, @font-face, nesting, url(), expression, or var() outside colors. /* … */ comments are allowed. Limits: 64 KiB per file, 200 declarations, 600 characters per value, images of 4 MiB at most. A missing token keeps the application's neutral value: a theme can be partial. A rejected file removes its theme from the list, with a precise message (line number, token, reason) in the log.
The token contract
Value types:
- color: colour:
#rgb,#rrggbb,#rrggbbaa,rgb()/rgba()/hsl()/hsla()(numbers, comma or space syntax with/ alpha),color-mix(in srgb, <color> [n%], <color> [n%]),transparent,white,black, andvar(--pv-<color token>)(the only reference allowed, pointing to another color-type token, for example the accent). - length: length: number +
px,em,rem,%,vw,vh,vmin,vmax(or0),clamp(a, b, c),min(a, b),max(a, b). - font: font stack: 1 to 8 families (names in quotes or plain words: letters, digits, spaces,
.,_,-), the last one a generic family (sans-serif,serif,monospace,system-ui,ui-monospace,cursive);PrismLegacyis the game font. - weight: weight: integer from 1 to 1000.
- opacity: number from 0 to 1.
- easing:
ease,ease-in,ease-out,ease-in-out,linearorcubic-bezier(x1, y1, x2, y2)(x from 0 to 1, y from -4 to 4). - clip: shape:
none,polygon(x y, …)(3 to 16 points, coordinates as lengths orcalc(<n>% ± <n>px)) orinset(1 to 4 offsets [round 1 to 4 radii]). - layers: gradients:
noneor 1 to 4linear-gradient(…)/repeating-linear-gradient(…)separated by commas (angle90degorto right, 2 to 12 stops: color + 0 to 2 positions). - filter: filter:
noneor up to 6 functions amongsaturate,brightness,contrast,grayscale,sepia,opacity,hue-rotate(<n>deg),blur(≤ 12px). - image: image:
noneorimage("relative/path.png")(an image file from the theme's folder: png, jpg, webp, gif, svg). - case:
none,uppercase,lowercaseorcapitalize. - scheme:
darkorlight. - choice: a single word taken from the token's list (see "Layout choices"). Any other word, several words, an empty value, a number or a
var()is rejected.
| Tokens | Type | Role |
|---|---|---|
--pv-bg |
color | Page background and the colour gaps and cut-outs show. |
--pv-plate, --pv-plate-raised, --pv-plate-hover, --pv-plate-active, --pv-plate-active-hover |
color | Surfaces of panels (plate), raised controls, hovered and selected ones. |
--pv-line, --pv-track |
color | Hairlines and the empty part of gauges. |
--pv-text, --pv-text-2, --pv-text-3 |
color | Primary, secondary and tertiary text. |
--pv-ink |
color | Text drawn on an accent-coloured surface. |
--pv-accent |
color | The theme's own accent: what the interface uses unless the player opted in to the map's artwork or a colour of their own (accentSource), and always for a theme with "accent": "fixed". |
--pv-accent-2 |
color | A second accent for secondary marks (the goal pin, a goal that follows the current judgement). |
--pv-ok, --pv-warn, --pv-danger |
color | Success, warning and error tones. |
--pv-offset-ok, --pv-offset-warn, --pv-offset-high, --pv-offset-bad |
color | Colour of the mean offset on the results screen, depending on how far it is from zero: fine (≤ 2 ms), worth watching (≈ 4 ms), needs fixing (≈ 7 ms), needs fixing right away (≥ 10 ms). The interface interpolates continuously between these four colours; a light theme redefines them to stay readable. |
--pv-tier-platinum, --pv-tier-diamond, --pv-tier-prism, --pv-tier-prism-b, --pv-tier-prism-c |
color | Colours of the achievement tiers above gold (platinum, diamond, then prism, the highest; bronze, silver and gold are the podium colours). Prism is a blue, a violet and a cyan: only the medal's ring blends them into a conic gradient, the text and the bar use only --pv-tier-prism. A light theme redefines them to stay readable. |
--pv-gold, --pv-silver, --pv-bronze |
color | Podium ranks. |
--pv-graph-surface, --pv-graph-grid, --pv-graph-zero, --pv-graph-cursor |
color | Chart plot background, grid lines, zero line and inspection cursor. |
--pv-graph-low, --pv-graph-mid, --pv-graph-high, --pv-graph-peak |
color | Chart palette, from calm values to the busiest ones (the density timeline climbs from low to peak). Judgement tier and difficulty colours are never tokens. |
--pv-graph-bar-stroke |
color | Outline of chart bars. |
--pv-clip-lg, --pv-clip-md, --pv-clip-sm |
clip | Shape of large (rows, panels), medium (buttons, tabs) and small (badges) plates. |
--pv-clip-edge-lg, --pv-clip-edge-md, --pv-clip-edge-sm |
clip | Shape of the accent edge drawn on the leading side of each plate size. |
--pv-clip-meter, --pv-graph-bar-clip |
clip | Shape of gauge fills and of chart bars. |
--pv-art-clip |
clip | Shape of cover pictures. |
--pv-inset-lg, --pv-inset-md, --pv-inset-sm |
length | Horizontal padding that clears the slanted or chamfered ends of each plate size. |
--pv-edge-w, --pv-meter-h, --pv-radius-input, --pv-radius-panel, --pv-graph-line-w, --pv-graph-bar-gap |
length (at most 64px) | Accent edge width, gauge height, input corner radius, chart line width and gap between chart bars. |
--pv-h-sm, --pv-h-md, --pv-h-lg |
length | Control heights. |
--pv-art-inset, --pv-art-left, --pv-art-w, --pv-art-pad |
length | Cover picture of a song row: inset, left offset, width and the text padding it needs. |
--pv-set-row, --pv-diff-row |
length (at least 8px, at most 400px) | Height of a set card and of a difficulty row in the list (relative to the list template). |
--pv-set-gap, --pv-diff-gap |
length (0 to 100px) | Space below a set card and below a difficulty row. |
--pv-shift-base |
length | Distance entrance animations travel (zero under reduced motion). |
--pv-rail-w, --pv-drawer-w |
length (at least 40px, at most 600px) | Width of the collapsed (icon rail) and expanded Escape drawer. |
--pv-display-ls, --pv-label-ls, --pv-title-ls, --pv-hero-ls |
length (may be negative) | Letter spacing of display text, labels, titles and big numerals. |
--pv-fs-xs, --pv-fs-sm, --pv-fs-md, --pv-fs-lg, --pv-fs-xl, --pv-fs-2xl, --pv-fs-3xl, --pv-fs-hero, --pv-fs-meta, --pv-fs-accuracy, --pv-fs-title, --pv-fs-rating |
length (at least 12px) | Type scale; every size is at least 12px. |
--pv-font-display, --pv-font-body, --pv-font-num, --pv-font-label, --pv-font-mono |
font | Font stacks: display text, body, numerals, labels, technical details (PrismLegacy is the game font). |
--pv-fw-display, --pv-fw-num, --pv-fw-label |
weight | Font weights. |
--pv-art-opacity |
opacity | Opacity of cover pictures. |
--pv-ease, --pv-ease-spring |
easing | Easing of motion and of its springy variant. |
--pv-display-case, --pv-label-case |
case | Text transform of display text and labels. |
--pv-color-scheme |
scheme | Whether native controls (scrollbars, inputs) are dark or light. |
--pv-meter-ticks, --pv-art-mask, --pv-graph-scan, --pv-shade |
layers | Gradient layers: gauge segment ticks, cover fade mask, the chart scanline overlay and the shade over the map picture. |
--pv-art-filter |
filter | Filter applied to the map picture behind the menus. |
--pv-plate-filter |
filter | Depth beneath each plate: drop-shadow(<x> <y> [<blur>] <color>) (offsets ≤ 48 px), or none. A filter and not a box shadow, because plates are cut-out shapes. |
--pv-plate-sheen |
layers | Up to four linear gradients painted over each plate's colour (bevel, key relief, highlight); none for a flat plate. |
--pv-overlay-image |
image | An image from the theme's folder drawn over the map artwork and beneath the menus (vignette, grid, decoration), stretched to the window (the SVG's preserveAspectRatio chooses between cropping and distortion): image("images/overlay.svg"). |
--pv-thumb |
color | Thumb of the chosen segment of a segmented control (with --pv-layout-controls: pill); the hovered plate if absent. |
--pv-switch |
color | Colour of a switched-on toggle (with pill); the accent if absent. |
--pv-bg-image |
image | Picture of the theme's folder drawn on the page background (image("images/bg.png")). |
Layout choices
A theme can change how menus are built, still purely as data: a choice token takes one word from a closed list (checked by Rust, then by the page). The page sets each valid choice as a data-layout-<name> attribute on <html>; the application's stylesheets (theme/layout.css and the components) react to it with grid-template-areas, order, and variables. A theme provides no selector and no CSS: only these words. The first word of each list is the current layout: an omitted (or rejected) choice defaults to that word, so a theme that says nothing about it changes nothing.
| Token | Words | Effect |
|---|---|---|
--pv-layout-list |
right · left · center |
Side of the song list; the map panel is on the opposite side. center: list in the middle, panel on the right, artwork visible on the left. Row height remains that of the --pv-set-row and --pv-diff-row tokens. |
--pv-layout-tabs |
top · rail · dock · sidebar |
Map tabs: a bar at the top, a vertical rail to the left of the panel, a dock at the bottom, or a full-height sidebar to the left of the whole panel (a source list; it collapses to icons in narrow windows). |
--pv-layout-play |
bottom · top |
Scoring, judgement, and the Play button at the bottom of the panel, or at the top. |
--pv-layout-density |
comfortable · compact · dense |
Spacing and row heights get one notch tighter at each step (the virtualized list re-reads its heights). |
--pv-layout-chrome |
none · titlebar · nav |
Decorative banner: titlebar, a window bar (three dots and the application name); nav, a thin site navigation bar, an 80% translucent tint (the only blur) with the application name and the page title. Non-functional: the real buttons are the system's. |
--pv-layout-drawer |
left · right · bottom |
Edge the Escape drawer slides in from; bottom: an icon dock. |
--pv-layout-rows |
cover · strip · text · list |
List row: thumbnail on the left, artwork as a faded banner behind the row, text only (table row), or a flat list row (thumbnail and text with no plate, a tint under the hovered row and the selected row, like a music app's list). |
--pv-layout-profile |
top · rail |
Player identity on the Profile page: a banner at the top, or a rail on the left. |
--pv-layout-cards |
rich · compact |
Leaderboard score card: large accuracy with counters below, or a single table row. |
--pv-layout-buttons |
plate · bracket · link |
link: all buttons except the primary one become accent-colored text with no plate. Buttons, tabs, and icon buttons: a solid plate in the theme's shape, or a bracket outline drawn with the gradient (no fill, like a terminal [ ]); elements with an accent background keep the accent for the brackets and their text. |
--pv-layout-focus |
ring · blink |
Keyboard focus: the usual outline, or that outline blinking like a text cursor (static, as a ring, under prefers-reduced-motion). |
--pv-layout-icons |
none · icon |
Tabs (Info, Leaderboard, Mods, Practice, Editor) show their icon before the label. |
--pv-layout-groups |
plain · inset |
Settings rows: rules between sections, or groups as rounded cards with thin rules between rows (system settings window). |
--pv-layout-controls |
plain · pill |
Switches, segmented controls, dropdown menus, and scrollbars: theme shapes, or pill switches with a white knob, a segmented control with the selected segment raised, rounded menus with the selection in the accent color, thin overlaid scrollbars. |
--pv-layout-hero |
plate · open |
Header of the selected map and its four figures: on plates, or open, set directly on the page (title and large figure with no card, figures in columns between rules, like a product page). |
--pv-rating-dark |
opacity | Same idea for the text of difficulty and skill values (the ratingColor color itself is never modified). |
--pv-tier-dark |
opacity | Amount of black mixed into the TEXT of the judgement tiers (0: game colors). Light themes raise it so tier names stay legible; the dots and the data do not change. ratingColor is never touched. |
Replay progression graph
The progression graph (combo, accuracy, and map density on a shared time axis) is tuned with the same tokens. The component reads these keywords (data-graph-* attributes on <html>) with no per-theme markup; zoom, panning, popovers, the legend, and the hiding of miss intervals do not change.
| Token | Values | Effect |
|---|---|---|
--pv-graph-progression |
steps · lines · area · bars |
steps (default): each sample is held until the next one, density as a pale area; lines: straight segments between the actual samples, nothing smoothed; area: filled step curves; bars: density as bars, combo and accuracy as steps. The player can override it in Settings → Interface ("Progression graph style"). |
--pv-graph-grid-style |
lines · none · dots · scanlines |
Grid behind the replay graphs. |
--pv-graph-cursor-shape |
dashed · solid · band |
Cursor: dashed line, solid line, or translucent band. |
--pv-graph-marker |
none · dot · diamond · square |
Marker where the cursor crosses each visible curve. |
--pv-graph-miss-style |
bands · ticks |
Miss intervals: full-height bands or small marks at the bottom. |
--pv-graph-curve-w |
length 1–6 px | Thickness of the combo curve (accuracy: 75%, density outline: 60%). |
--pv-graph-fill-opacity |
opacity | Opacity of the fills under the curves. |
--pv-graph-combo, --pv-graph-accuracy, --pv-graph-density-low, --pv-graph-density-mid, --pv-graph-density-high |
#rrggbb |
Colors of the three curves (density is a low → mid → high gradient according to magnitude). |
The three curves remain distinguishable, whatever the theme does. Combo is always solid, accuracy is always dashed, and density is always an area or bars (this is not configurable). As for color, combo, accuracy, and the middle of density must differ pairwise by at least 30° of hue (for grays: 25% lightness); otherwise the entire theme is rejected at load time, with the names of the two tokens. Judgement tier colors and ratingColor are never tokens; --pv-rating-dark and --pv-tier-dark only darken their text.
The game's sizes (spacing --pv-s*, durations --pv-dur*, --pv-stagger) belong to the application; under prefers-reduced-motion, durations and --pv-shift-base drop to zero regardless of the theme.
The map artwork remains visible, whatever the theme. The map image behind the menus is part of the game's identity: a theme can tint it (--pv-shade), darken it, blur it, desaturate it, or recolor it (--pv-art-filter), but never remove it or bury it. The validator (crates/themes/src/artwork.rs) computes the share of the image's light that reaches the screen, averaged over the window: each --pv-shade gradient counts for the average of its opacity along its axis, layers compose, and the filter counts for its brightness() and opacity(). The rules: at least 20% of the image visible; no --pv-shade layer covers more than 90% on average (an opaque flat fill is rejected); in the filter, brightness ≥ 0.25, opacity ≥ 0.4, and contrast ≥ 0.5. The check also applies to every @media block. The player keeps their switch: "Black background" (Video → In-game background) hides the image in game; song select is not affected, and background brightness (backgroundBrightness) does not change either. For readable plates without hiding the image, prefer translucent plates (opacity of 0.7 to 0.85).
A theme does not change the geometry of an icon button. An icon button (IconButton, the "?" help buttons, the export and import icons) is a square whose side comes from the --pv-h-md tokens (--pv-h-sm for the small size) and whose shape comes from --pv-clip-sm; the icon is centered inside it (display: inline-grid; place-items: center, svg as a block), and neither the letter case, nor the letter spacing, nor the line height, nor the horizontal padding that themes give to text buttons affects it. A theme in capitals, in monospace, or with wide spacing therefore cannot off-center or distort an icon. Text buttons (Utiliser, Personnaliser…) remain free to take the theme's style. Safeguard: tests/icon-button-geometry.spec.ts runs through all the shipped themes, at 1400×850 and 1000×700, and checks that every icon button on the Skins page is a square, that its icon is centered to ±0.5 px, that it does not overflow, that buttons in the same row have the same height, and that hover, keyboard focus, and press do not change the size.
Writing a fourth theme
- Copy
themes/slant/tothemes/<my-id>/(or into<data>/themes). - Edit
theme.json(id= folder name) andtokens.css. - In Skins → Reload (or restart), the theme's card appears; the preview is drawn with your tokens.
To change how screens are built, add --pv-layout-* keywords to tokens.css (see "Layout choices"); an omitted keyword keeps the current layout. The shipped themes serve as examples: macos (tab rail, window banner), hack (brackets, blinking focus, dense), ledger (rail, column profile, right-hand drawer). The --pv-tier-dark and --pv-rating-dark tokens are for a light theme (tier text darkened).
Nothing else: no code, no registration. The default theme (uiTheme setting missing or invalid) is cabinet; the theme with the identifier classic remains selectable and is reserved for the original menu structure (the components that predate the design system are attached to it through this identifier); any other identifier receives the design system's structure.
Fallback
The chosen theme is missing: the first available theme is used, with a notice. No theme exists: the application uses its built-in neutral tokens (theme/fallback.css), so that nothing is ever displayed unstyled.
The skin editor
The skin editor (Skins → Customize) lives in the overlay, a separate WebView page that has neither the theme list nor the settings: the host sends it overlayTheme (the chosen theme uiTheme, accentSource, accentCustom, and the themes already validated, the same data as interfaces, nothing more and nothing a script can reach). From this, the page derives, with the same code as the menus (theme/apply.ts, direction.ts, accent.ts), the CSS variables under [data-direction], the data-layout-* words, data-scheme, and the player's accent (a theme with accent: fixed keeps its own). On the page, PrismLegacy is declared as in the menus for the font stacks that name it.
The editor's panel, tabs, fields, switches, toolbar, and frames read only these tokens, through a layer of --ed-* variables (apps/web/src/overlay/editor/editor-theme.css): surfaces (--pv-plate, --pv-bg, --pv-plate-raised), lines (--pv-line), text (--pv-text, -2, -3), accent and ink (--pv-accent, --pv-ink), selection (--pv-accent-2, falling back to the accent), radii (--pv-radius-input, --pv-radius-panel), fonts (--pv-font-body, -display, -label, -num), --pv-switch and --pv-thumb with the word --pv-layout-controls: pill (pill-shaped switch, capsule tabs with a raised thumb). No color is hard-coded in the editor (a test verifies this): a theme added later restyles it with no changes. Only the scrim over the game, the shadows, and the color space of the color picker are neutral.