Skip to content

Theming

A theme in RMT Compose is a flat map of 16 colour tokens plus three note-geometry numbers. The theme manager resolves the active theme from three layers, then projects it into two completely different targets: CSS custom properties for every piece of DOM chrome, and the WebGL renderer for the canvas.

This page is the developer view. For what the controls do, see Themes & Appearance and the Settings reference.

The pieces

FileRole
src/theme/presets.jsPure data. Four presets, THEME_PRESETS, DEFAULT_THEME_ID, getPreset(id). No DOM or GL imports, so it is safe to import from anywhere.
src/theme/theme-manager.jsThe themeManager singleton. Resolves, writes CSS vars, calls the renderer.
src/settings/settings-schema.jsdefaultSettings() / validateSettings() — the source of truth for appearance defaults and clamps.
src/settings/settings-store.jsPersistence to localStorage["rmt:settings:v1"], dot-path get/set, subscriptions.
src/settings/settings-panel.jsThe Appearance tab, including COLOR_TOKEN_GROUPS — the 15 pickers.
src/renderer/webgl2/renderer.jssetConfig(), setThemeColors(), and every draw call that reads a themed colour.
src/renderer/webgl2/renderer-config.jsdefaultRendererConfig + normalizeRendererConfig() deep-merge.
public/styles.cssThe :root { --rmt-* } literal defaults, and every DOM rule that consumes them.

player.js wires it up once the GL workspace exists:

javascript
themeManager.init({ renderer, requestResync: () => { /* glWorkspace.sync(...) + createMeasureBars() */ } });

init() applies the theme immediately, then subscribes to settingsStore and re-applies on any change whose path is '', 'appearance', or starts with 'appearance'.

The token schema

A ThemePreset is { id, name, tokens, geometry } (presets.js:20).

Colour tokens (all #rrggbb strings):

GroupTokens
Interfaceaccent, accentText, bg, surface, surfaceBorder, textPrimary, textSecondary, danger
WorkspacenoteBorder, playhead, measureBar, selectionRing, hoverRing
Dependency highlightsdepFrequency, depStartTime, depDuration

Non-colour tokens, present in every preset: noteDefaultSaturation (a number) and newNoteColorMode ('random' in all four presets).

noteDefaultSaturation is consumed: the theme manager publishes it as the CSS var --rmt-note-default-saturation, and note-creation.js reads it back as the saturation a new note's random hsla(<random 0–360>, <sat>%, 60%, 0.7) colour is born with. newNoteColorMode is still read by nothing — there is no "new note colour mode" setting anywhere in the UI. Note body colours remain per-note user data, not theme data.

Geometry:

javascript
geometry: { noteHeightWU: 22, borderPxAtZoom1: 1, roundedCornerPxAtZoom1: 6 }

Resolution: preset ∪ overrides ∪ geometry

themeManager.resolve() (theme-manager.js:101) returns { id, tokens, geometry }:

javascript
const appearance = settingsStore.get('appearance') || {};
const preset = getPreset(appearance.themeId);            // unknown id → classic-orange
const tokens = { ...preset.tokens, ...(appearance.overrides || {}) };

const noteCfg = appearance.note || {};
const geometry = {
  noteHeightWU:           noteCfg.heightWU               ?? preset.geometry.noteHeightWU,
  borderPxAtZoom1:        noteCfg.borderPxAtZoom1        ?? preset.geometry.borderPxAtZoom1,
  roundedCornerPxAtZoom1: noteCfg.roundedCornerPxAtZoom1 ?? preset.geometry.roundedCornerPxAtZoom1,
};

appearance.overrides is a sparse diff — only the tokens the user actually touched. That is exactly why the preset dropdown clears overrides before writing the new themeId (settings-panel.js:258-263): layering an old override map over a new preset would show a mixture.

The stored shape:

json
{
  "version": 1,
  "appearance": {
    "themeId": "classic-orange",
    "overrides": { "accent": "#ff00aa" },
    "note": { "heightWU": 22, "borderPxAtZoom1": 1, "roundedCornerPxAtZoom1": 6 }
  }
}

The geometry ?? fallback in resolve() never fires — the panel applies preset geometry instead.

validateSettings() always fills appearance.note.* with a clamped number (defaults 22 / 1 / 6), so those three values are never null/undefined and the ?? branches above are technically dead. Preset geometry reaches the canvas by a different route: selecting a preset in the Appearance tab writes preset.geometry into appearance.note (settings-panel.js:264-270), re-seeding the sliders — switching to high-contrast lands its declared borderPxAtZoom1: 2, roundedCornerPxAtZoom1: 4. The sliders remain the fine-tuning control afterwards.

Overrides are not validated: validateSettings copies appearance.overrides verbatim (settings-schema.js:155). No key whitelist, no hex check. Garbage keys are stored harmlessly; a value that is not #rrggbb shows as #000000 in the picker.

Projection 1: CSS custom properties

_applyCssVars(tokens) (theme-manager.js:122) walks CSS_VAR_MAP (theme-manager.js:50-67) and writes each token onto document.documentElement.style as --rmt-<kebab>:

TokenCSS var
accent--rmt-accent
accentText--rmt-accent-text
bg--rmt-bg
surface--rmt-surface
surfaceBorder--rmt-surface-border
textPrimary--rmt-text-primary
textSecondary--rmt-text-secondary
danger--rmt-danger
noteBorder--rmt-note-border
playhead--rmt-playhead
measureBar--rmt-measure-bar
selectionRing--rmt-selection-ring
hoverRing--rmt-hover-ring
depFrequency / depStartTime / depDuration--rmt-dep-frequency / --rmt-dep-start-time / --rmt-dep-duration

It then derives four RGB component triplets so that translucent forms work without a second token, and publishes the numeric saturation token:

javascript
setRgb('--rmt-accent-rgb',  tokens.accent);   //  →  "255, 168, 0"
setRgb('--rmt-bg-rgb',      tokens.bg);
setRgb('--rmt-surface-rgb', tokens.surface);
setRgb('--rmt-danger-rgb',  tokens.danger);
rootStyle.setProperty('--rmt-note-default-saturation', String(tokens.noteDefaultSaturation));
css
/* which is what makes this possible */
box-shadow: 0 0 12px rgba(var(--rmt-accent-rgb), 0.6);
background: rgba(var(--rmt-surface-rgb), 0.88);   /* every translucent panel */

public/styles.css:10-32 declares the same 16 vars literally in :root with the classic-orange values, so the app looks right before any JS runs.

Which CSS vars actually have consumers

Writing a var is not the same as anyone reading it. Today:

CSS varConsumed?
--rmt-accent, --rmt-accent-rgbYes — heavily (styles.css, menu-bar, variable-controls, settings-panel)
--rmt-bg, --rmt-bg-rgbYes — body background, translucent bars and panels
--rmt-danger, --rmt-danger-rgbYes — heavily
--rmt-text-primary, --rmt-text-secondaryYes
--rmt-surface-borderYes — settings inputs, chips, buttons, menu bar
--rmt-surface (via --rmt-surface-rgb)Yes — the projected triplet drives every 0.88-alpha panel background (rgba(var(--rmt-surface-rgb), 0.88))
--rmt-note-default-saturationYes — read by note-creation.js for new-note random colour saturation
--rmt-accent-textNo consumers, and no picker
--rmt-note-border, --rmt-playhead, --rmt-measure-bar, --rmt-selection-ring, --rmt-hover-ring, --rmt-dep-*No CSS consumers by design — these are GL concerns, delivered through the renderer path below

Projection 2: the WebGL renderer

_applyRenderer(geometry, tokens) (theme-manager.js:143) calls two different renderer methods. They are not interchangeable.

renderer.setConfig(partial) — geometry and the playhead

renderer.js:330. Deep-merges a partial into this._config (via normalizeRendererConfig) and sets needsRedraw. The theme manager sends note geometry and the playhead colour, because the playhead is already config-driven:

javascript
renderer.setConfig({
  note: {
    heightWU:               geometry.noteHeightWU,
    borderPxAtZoom1:        geometry.borderPxAtZoom1,
    roundedCornerPxAtZoom1: geometry.roundedCornerPxAtZoom1,
  },
  playhead: { color: hexToRgba(tokens.playhead || tokens.accent) },  // [r,g,b,a] floats 0..1
});

renderer.setThemeColors(colors) — structural GL colours

renderer.js:345. Takes hex strings, converts to RGBA float arrays, and stores them on this._themeColors. It also clears _octaveLabelCache and bumps _colorEpoch and _viewEpoch, because canvas-textured labels are cached by string key with no colour in the key — without the invalidation they would keep rendering in the old accent.

javascript
renderer.setThemeColors({
  accent, noteBorder, measureBar, selectionRing, hoverRing,
  depFrequency, depStartTime, depDuration,
  textPrimary,   // stored as noteText / noteTextHex — on-note glyph text
});

Reads go through accessors with baked fallbacks matching the pre-theme literals — _accentRgba(), _noteBorderRgba(), _measureBarRgb(), _selectionRingRgba(), _hoverRingRgba(), _depFrequencyRgba() / _depStartTimeRgba() / _depDurationRgba(), _noteTextRgba() / _noteTextHex() (renderer.js:378-392) — so the renderer draws correctly even if setThemeColors was never called.

MethodInputEffectTriggers a re-sync?
setConfignested partial of defaultRendererConfigdeep-merge into _config; needsRedraw = trueNo (the theme manager decides — see below)
setThemeColorsflat map of hex stringsstore RGBA on _themeColors; clear label cache; bump _colorEpoch + _viewEpochNo

The resync gate

Note rects are computed inside sync(), so a geometry change needs a full rebuild. A colour change does not. The theme manager therefore fires requestResync() only when the geometry key actually changed (theme-manager.js:176-180):

javascript
const geoKey = `${geometry.noteHeightWU}:${geometry.borderPxAtZoom1}:${geometry.roundedCornerPxAtZoom1}`;
if (geoKey !== this._lastGeometryKey) {
  this._lastGeometryKey = geoKey;
  if (this._requestResync) this._requestResync();
}

Colour pickers fire on the input event — continuously while the user drags the colour wheel — so this gate is what keeps that cheap. See Performance for what sync() actually costs.

What is themed on the canvas

GL elementTokenSource
Note body border (every note, not just the base)noteBorderrenderer.js:2710
Silence dashed ringnoteBorderrenderer.js:7284, :7518
BaseNote circle fillaccentrenderer.js:5342
BaseNote circle bordernoteBorderrenderer.js:5343
Octave / base guide linesaccent (alpha 0.9 primary, 0.35 secondary)renderer.js:8894, :9337
Note ID labelsaccentrenderer.js:6944-6949
BaseNote fraction labelaccentrenderer.js:6054
Octave-guide + measure-triangle ID labelsaccentrenderer.js:7664, :7717
Measure bars — dashed interiormeasureBar @ alpha 0.35renderer.js:4981
Measure bars — solid start/endmeasureBar @ alpha 0.8renderer.js:5167
Playhead lineplayhead, via config.playhead.colorrenderer.js:3034
Marquee rectangle (multi-select drag)accent — deliberately not selectionRing: classic-orange's ring token is white, and the marquee has always drawn orangerenderer.js:10866-10871
Selected-note ringselectionRingrenderer.js:2931
Selected-note fill washselectionRing @ 0.12renderer.js:2888
Multi-select group ringselectionRing, 4 px (selection.multiRingThicknessPxAtZoom1 ?? 4.0)renderer.js:1532-1539
Selected BaseNote ring / selected measure-triangle outlineselectionRingrenderer.js:5381, :6017
Hover ring — notes, BaseNote, measure triangleshoverRingrenderer.js:3006, :5464, :5989
Dependency-highlight rings and dependency link linesdepFrequency / depStartTime / depDurationrenderer.js:2786-2788, :5413-5415
Measure-triangle outlines (dep-coloured states)depStartTime / depDurationrenderer.js:5962-5978
On-note fraction digits, "silence", ▲/▼ glyphs, BaseNote fractiontextPrimary (stored as noteText)renderer.js:5638-5712, :6148-6215, :7008, :7098
Note height / border px / corner pxappearance.note.*theme-manager.js:151-158

What is baked and cannot be themed

GL elementColourSource
Note body fillper-note user data (note.color) — the preset only sets the random saturation a new note is born withplayer.js:2391, note-creation.js:502-509

The Appearance tab of the Settings panel: theme dropdown, three geometry sliders, and fifteen colour pickers grouped into Interface, Workspace and Dependency highlights

Adding a new theme token, end to end

The hover ring is a worked example of the full path (it is wired this way today).

  1. Add the token to every preset in src/theme/presets.js. All four, or resolve() will produce undefined for the presets that lack it. (hoverRing already exists — for a genuinely new token, add it here.)

  2. Decide who consumes it.

    • DOM → add it to CSS_VAR_MAP (theme-manager.js:50-67), then var(--rmt-your-token) in public/styles.css. Also add a literal default under :root (styles.css:10-32) so the app looks right before JS boots. If you need rgba(...) forms, add a setRgb(...) line in _applyCssVars.
    • GL → pass it through _applyRenderer's setThemeColors({ ... }) call (theme-manager.js:163-172), accept it in setThemeColors (renderer.js:345) with a baked fallback matching today's literal, and add an accessor next to _accentRgba() / _noteBorderRgba() (renderer.js:378-381).
  3. Make the draw path read the accessor instead of the literal. For the hover ring that is const hr = this._hoverRingRgba(); gl.uniform4f(uCol, hr[0], hr[1], hr[2], a) (renderer.js:3006), keeping the existing alpha logic.

  4. Add a picker — one entry in COLOR_TOKEN_GROUPS (settings-panel.js:40-53):

    javascript
    { title: 'Workspace', tokens: [
      ['noteBorder', 'Note border'], /* … */ ['hoverRing', 'Hover ring'],
    ]},

    colorRow() handles the rest: it seeds from effectiveColor(token) (preset overlaid with override), writes appearance.overrides.<token> on every input event, and re-seeds itself through addSync when the preset changes or "Reset colors to theme" is pressed. No schema change is needed — overrides is a free-form map.

  5. Verify against a pixel diff, not your eyes. Canvas-textured labels are cached by string key with no colour in it; if your token feeds one, setThemeColors must invalidate that cache (it already clears _octaveLabelCache and bumps _colorEpoch / _viewEpoch). Run node scripts/perf/visual-regress.mjs --compare --url http://localhost:3000 and switch presets in a real browser. node scripts/perf/shot-settings.mjs drives the panel headlessly.

Skipping step 3 is exactly how a picker goes dead.

A token can be defined, persisted, mapped to a CSS var, handed to setThemeColors and given a picker — and still do nothing, because no draw call reads it. The hover-ring and dependency-colour pickers shipped in exactly that state for a while. Wiring the consumer is the step that counts.

Adding a new preset

Add the object to src/theme/presets.js and register it in THEME_PRESETS:

javascript
const MY_THEME = {
  id: 'my-theme',
  name: 'My Theme',
  tokens: { /* all 16 colour tokens + noteDefaultSaturation + newNoteColorMode */ },
  geometry: { noteHeightWU: 22, borderPxAtZoom1: 1, roundedCornerPxAtZoom1: 6 },
};

export const THEME_PRESETS = { /* … */, 'my-theme': MY_THEME };

The Appearance dropdown is built from Object.values(THEME_PRESETS), so it appears with no UI change. Ship every colour token: a missing one resolves to undefined and the CSS var is simply not written (_applyCssVars only sets string values), leaving the previous theme's value on <html>. Tune geometry too — selecting your preset writes it into appearance.note, so it is the note shape users land on.

getPreset(id) falls back to classic-orange for an unknown id (presets.js:142), so a stale themeId in someone's localStorage degrades quietly rather than crashing.

The four shipped presets

classic-orange is the default and is deliberately pixel-identical to the pre-theme app — its values were read straight out of the old shader and CSS literals.

idNameaccentbgnoteBorder
classic-orangeClassic Orange#ffa800#151525#636363
slate-cyanSlate Cyan#38bdf8#0b1120#5a6a85
mono-lightMono Light#d17400#f5f5f0#9a9a92
high-contrastHigh Contrast#ffd400#000000#ffffff

Full token tables are in the Settings reference.

Gotchas

  • There is no OS light/dark detection. No prefers-color-scheme handling for app theming anywhere. mono-light is chosen by hand or not at all.
  • There is a boot flash. The :root literals in styles.css are classic-orange, and themeManager.init() only runs once the GL workspace exists (player.js:1195). A user on mono-light sees a brief dark/orange flash on every load. Do not promise flicker-free theming.
  • Selecting a preset silently discards every colour override and overwrites the geometry sliders with the preset's declared geometry, with no confirmation. Only the explicit "Reset colors to theme" button asks first (and it leaves geometry alone).
  • There is no theme import/export and no custom named themes. Four presets plus a flat override map is the whole surface.
  • geometry values are clamped to [8, 60], [0, 6] and [0, 20] by validateSettings, whether they come from a preset selection or the sliders.
  • Note height is the master dimension for on-note overlays. The ID label, fraction text, arrow column and pull tab are all sized as factors of it (renderer-config.js:61-74), so a geometry change is never just a note-body change.

See also

Released under the MIT License