Moonlight runs on my prompt, my editor, mpv, Windows Terminal, PowerShell, bash, the YASB status bar, three Windhawk stylers, a TachiyomiJ2K fork, Sunshine, the gym app, and this site's diagrams. Eleven ports, one palette.
Source of truth
This page. just tokens reads the cards below, recomputes OKLCH and WCAG contrast from each hex, and writes moonlight-tokens.json, which every port reads. It fails the build when a stated OKLCH disagrees with its hex, or when a text-on-surface pair drops below its minimum, so the page cannot claim a number it does not have.
The system
- One hue family. Every surface, every text tier and the whole accent ramp sit within 5° of hue 274.
- Even lightness steps. Surfaces climb +0.04 L each, the accent +0.065 L each, measured in OKLCH where equal steps look equal.
- One ranked accent. A single hue with four steps, so every port emphasises the same way.
- Borders in alpha. One border token over any layer, instead of a new opaque value per surface.
Surfaces
Four layers. The canvas, the chrome, the active thing, the selected thing.
Which layer gets what
Chrome sits on bg, with the canvas. Sidebar, tab bar, activity bar, status bar, title bar, panel header: anything touching an edge of the window is the same color as the editor behind it. Only what floats lifts. Widgets, popups, notifications, hover states, dropdowns and inputs take surface-1. The active tab takes surface-2, selection takes surface-3.
The instinct is to lift the chrome one step above the canvas. Resist it, or the theme reads lighter than a dark theme should. The edge comes from the alpha border instead, steady at 1.19:1 to 1.24:1 on every layer.
Text
Three tiers, one job each. The gaps carry the hierarchy once a monitor is dimmed.
on-surface-dim has to clear 4.5:1 on every surface it renders on, not just the canvas: line numbers sit on bg, placeholders on surface-1. It clears both, at 5.34:1 and 4.94:1.
The primary text keeps a lavender lean at chroma 0.016, hue 286. Low enough to read as white, close enough to the family not to fight the tiers beside it.
Accent
on-primary is the background, not white. White on primary measures 3.47:1 and fails normal text; the canvas color gives 5.54:1. Filled buttons carry dark text.
Borders
Two tokens, both in alpha, so one value works on all four layers.
Composited, border holds between 1.19:1 and 1.24:1 against whatever sits under it. That is the argument for alpha: an opaque border vanishes on the light layers and shouts on the dark ones, which is how palettes end up with two border values meaning the same thing.
| Under | Composited | Separation |
|---|---|---|
bg |
#1E202C |
1.19:1 |
surface-1 |
#242839 |
1.22:1 |
surface-2 |
#2B3045 |
1.24:1 |
surface-3 |
#333952 |
1.24:1 |
Signals
These three mean something, so nothing cosmetic borrows them. They are also the syntax identity, which is why they sit outside the hue family.
warning already sits at 100% of the chroma sRGB allows for its lightness and hue. It cannot get more vivid, and a P3 display will not change that.
Contrast, measured
Every pair the generator checks. Recomputed from the hexes on each build, alpha composited before measuring.
| Foreground | Background | Ratio | Minimum |
|---|---|---|---|
on-surface |
bg |
16.70:1 | 4.5:1 |
on-surface |
surface-3 |
12.26:1 | 4.5:1 |
on-surface-muted |
bg |
7.72:1 | 4.5:1 |
on-surface-muted |
surface-1 |
7.14:1 | 4.5:1 |
on-surface-dim |
bg |
5.35:1 | 4.5:1 |
on-surface-dim |
surface-1 |
4.94:1 | 4.5:1 |
primary |
bg |
5.55:1 | 3:1 |
primary-60 |
bg |
7.18:1 | 4.5:1 |
primary-70 |
bg |
9.10:1 | 4.5:1 |
secondary |
bg |
11.41:1 | 4.5:1 |
on-primary |
primary |
5.55:1 | 4.5:1 |
success |
bg |
12.88:1 | 4.5:1 |
warning |
bg |
12.82:1 | 4.5:1 |
error |
bg |
4.93:1 | 4.5:1 |
Ports
Roles map 1:1 wherever the target has a vocabulary for them. Where it does not, the port derives from the nearest role in OKLCH: same hue and chroma, move L.
VS Code
589 keys in colors, mapped by value with three key-name exceptions: border patterns go to border, the slider group to border at higher alpha, find-match highlights to primary at low alpha. tokenColors keeps its own hexes apart from the three text tiers. Nineteen chrome keys sit on bg; the twenty-one on surface-1 are all widgets, popups or controls.
| Key | Value |
|---|---|
sideBar.background |
#0D0E17 |
statusBar.background |
#0D0E17 |
terminal.background |
#0D0E17 |
editorWidget.background |
#141726 |
tab.activeBackground |
#1C2033dd |
button.background |
#7386D0 |
button.foreground |
#0D0E17 |
textLink.foreground |
#B9C6F5 |
editorCursor.foreground |
#A2B0EA |
editorLineNumber.foreground |
#7E86A4 |
panel.border |
#9BA3C41F |
tab.border |
#9BA3C41F |
Windows Terminal
Terminal and editor share bg, so an integrated terminal has no seam. The ANSI blues and cyans take the accent ramp.
| Slot | Value |
|---|---|
background |
#0D0E17 |
foreground, white, brightWhite |
#EEEEFA |
black |
#141726 |
brightBlack |
#7E86A4 |
blue |
#7386D0 |
brightBlue |
#A2B0EA |
cyan |
#B9C6F5 |
brightCyan |
#8A9BE0 |
selectionBackground |
#252A42 |
cursorColor |
#A2B0EA |
This site
The slate scheme already named its variables the way the token set does, so it was a repoint, not a restructure. Values land as oklch() because the default scheme already uses that notation. --header-background and --sidebar sit on bg, same rule as the editor chrome; --card and --popover keep surface-1, because a card floats.
mpv
uosc.conf carries the whole theme on one line.
color=foreground=A2B0EA,foreground_text=0D0E17,background=141726,background_text=EEEEFA,curtain=0D0E17,success=49EF95,error=CA5F71
stats.conf is still the brown upstream default, FFD9CE, 36231E, F3AB5D, and reads as a different application.
Oh My Posh
The three prompt backgrounds take the three surface levels, the three gray tokens the three text tiers.
| Token | Value | Role |
|---|---|---|
main_background |
#141726 | surface-1 |
secondary_background |
#1C2033 | surface-2 |
tertiary_background |
#252A42 | surface-3 |
terminal_brightgray |
#EEEEFA | on-surface |
terminal_lightgray |
#9BA3C4 | on-surface-muted |
terminal_gray |
#7E86A4 | on-surface-dim |
terminal_blue |
#7386D0 | primary |
terminal_lightblue |
#8A9BE0 | primary-60 |
terminal_bluegray |
#B9C6F5 | secondary |
Every color lives in the palette block, so nine edits cover the whole prompt. The dated snapshots in themes/ are history and stay untouched.
The usage_0 to usage_5 battery and CPU ramp is the one place Moonlight leaves the blue-violet family on purpose.
PowerShell
Profile.ps1 colors PSReadLine. The syntax roles were already in the palette; the neutrals were not.
| Function | Value | Role |
|---|---|---|
CDefault |
#EEEEFA | on-surface |
CComment |
#9BA3C4 | on-surface-muted |
CListPredictionTooltip |
#9BA3C4 | on-surface-muted |
CContinuationPrompt |
#7E86A4 | on-surface-dim |
CInLinePrediction |
#7E86A4 | on-surface-dim |
CSelection, CListPredictionSelected background |
#252A42 | surface-3 |
CEmphasis |
#A2B0EA | primary-70 |
CMember |
#B9C6F5 | secondary |
CType |
#B0B9E5 | Functions and types |
The old neutrals were pure grays with no hue, next to an interface where everything else carries hue 274.
bash
.blerc configures ble.sh, which takes an xterm-256 index rather than a hex, so each role is quantized to the nearest index measured in OKLab.
| Variable | Index | Target |
|---|---|---|
COMMENT_COLOR |
110 | #9BA3C4 |
DEFAULT_TOKEN_COLOR |
255 | #EEEEFA |
DOCUMENT_COLOR |
153 | #B9C6F5 |
EMPHASIS_COLOR |
146 | #A2B0EA |
ERROR_COLOR |
167 | #CA5F71 |
KEYWORD_COLOR |
167 | #CA5F71 |
NUMBER_COLOR |
111 | #B0C8FF |
PARAMETER_COLOR |
140 | #B58EE8 |
PREDICTION_COLOR |
103 | #7E86A4 |
STAR_COLOR |
211 | #F279B2 |
TYPE_COLOR |
146 | #B0B9E5 |
VARIABLE_COLOR |
68 | #7386D0 |
Two collisions survive the quantization: EMPHASIS_COLOR and TYPE_COLOR land on 146, DOCUMENT_COLOR and STRING_COLOR on 153. NUMBER_COLOR keeps 111 rather than its true nearest, 153, which is already taken twice. The 256-color cube has nothing between them; 24-bit hex would fix all three.
YASB
The status bar keeps its palette in one :root block, and it was Catppuccin Mocha. Catppuccin names by color and Moonlight names by role, so this port is a name-to-role map rather than a value swap.
| Catppuccin | Value | Role |
|---|---|---|
base |
#141726 |
surface-1, since the bar floats over the desktop |
crust |
#0D0E17 |
bg |
surface0, surface1, surface2 |
#1C2033, #252A42, #2F3654 |
the layers above it |
text |
#EEEEFA |
on-surface |
subtext0, subtext1, overlay2 |
#9BA3C4 |
on-surface-muted |
overlay0, overlay1 |
#7E86A4 |
on-surface-dim |
blue |
#7386D0 |
primary |
sapphire |
#8A9BE0 |
primary-60 |
sky |
#A2B0EA |
primary-70 |
lavender |
#B9C6F5 |
secondary |
green |
#49EF95 |
success |
yellow |
#FFCB6B |
warning |
red |
#CA5F71 |
error |
Catppuccin has five neutral tiers between text and background where Moonlight has three, so subtext0, subtext1 and overlay2 collapse onto one value: a role the target does not have is a role it does not need. The warm accents map onto the syntax family, which is where Moonlight keeps its warmth.
Windhawk
Three styler mods, also on Catppuccin, and the whole look rode on two values: #181825 for the flyout and bar background, #45475a for the hairline border. They become surface-1 and surface-3, with every Opacity attribute left alone, so only the hue moves.
Settings live in the registry rather than in files, so the port ships as .reg exports.
Android
Material3 wants container levels and on-color pairs, so the TachiyomiJ2K fork derives its scale rather than copying hexes.
The chrome rule lands on one line in themes.xml: colorSurface points at moonlight_background, so app bars sit flat while the colorSurfaceContainer* ramp keeps its steps and dialogs stay lifted. Flattening the moonlight_surface color itself would have taken the dialogs down with it.
| Token | Value |
|---|---|
moonlight_background |
#0D0E17 |
moonlight_surface |
#141726 |
moonlight_primary_variant |
#1C2033 |
moonlight_surface_container_high |
#252A42 |
moonlight_surface_container_highest |
#2F3654 |
moonlight_on_background |
#EEEEFA |
moonlight_action_bar_text |
#EEEEFA |
moonlight_primary |
#A2B0EA |
moonlight_secondary |
#7386D0 |
moonlight_secondary_variant |
#252A42 |
moonlight_tab_bar_icon_inactive |
#7E86A4 |
Sunshine
The gym app names its primitives by color and its semantic tokens by job, and only the semantic layer reaches a component. Three surfaces, not four: cards sit on surface-1, the counter and photo on surface-3, and nothing needs the step between. Green does not exist in the app, and blue means one thing only, interactive.
| Token | Value | Role |
|---|---|---|
--background |
#0D0E17 | bg |
--surface |
#141726 | surface-1 |
--surface-raised |
#252A42 | surface-3 |
--text |
#EEEEFA | on-surface |
--text-secondary |
#9BA3C4 | on-surface-muted |
--text-unit |
#B9C6F5 | secondary |
--accent, --focus |
#8A9BE0 | primary-60 |
--done, --gain |
#B0B9E5 | Functions and types |
--alert |
#FFCB6B | warning |
--drop |
#FFAFAF | Parameters |
Two swaps came out of measuring on the surface that actually renders the text. error fails AA over the counter fill at 3.6:1, so a dropped load takes the light warm tone from the syntax family instead. The unit word beside a number, "rep", "kg", "min", takes secondary, the only tier between light and muted that clears 4.5:1 there.
Syntax
26 roles from the VS Code theme's tokenColors. Semantic tokens resolve to the same values, so there is one list, not two.
# Comments and annotations
@dataclass # decorator
class MoonlightPalette: # class name
"""Docstrings and strings look like this."""
accent: str = "#7386D0" # string literal
steps: int = 12 # numeric literal
def nearest_role(self, hex_value): # function definition
if hex_value in KNOWN_ROLES: # keyword + operator
return KNOWN_ROLES[hex_value]
return None
| Swatch | Hex | Role |
|---|---|---|
#9BA3C4 |
Comments and annotations | |
#CDCFE7 |
Type hints and separators | |
#B0B9E5 |
Functions and types | |
#ACB9E6 |
Markdown formatting | |
#9CB6ED |
Classes and decorators | |
#97B7FA |
SQL keywords, member variables | |
#A5CFFF |
Strings, docstrings, regex | |
#78A1FA |
Member access, object properties | |
#7386D0 |
Keywords, operators, commands, links, builtin functions | |
#B0C8FF |
Numeric literals | |
#79C0FF |
Headings, self, dunders |
|
#49EF95 |
Diff insertions | |
#F2B199 |
Diff modifications | |
#FFAFAF |
Parameters, attributes, inheritance | |
#CA5F71 |
Diff deletions | |
#C9A0EE |
String quotes, delimiters, escapes, f/r-string prefixes | |
#F279B2 |
Uppercase constants | |
#FF98BA |
Symbolic values | |
#FDBAF2 |
References, namespaces | |
#EEEEFA |
Accessed variables | |
#B58EE8 |
Declarations | |
#A5A1E9 |
Punctuation, storage types | |
#D388F0 |
Config variables | |
#D8A8F0 |
Constants, enums | |
#CED9FF |
Enum members | |
#698CFF |
Method calls |
Bracket pairs cycle through six, then the error color when unmatched:
Git and diagnostics
| Swatch | Hex | Role |
|---|---|---|
#49EF95 |
Added | |
#99E6B3 |
Untracked | |
#ACB9E6 |
Modified | |
#CA5F71 |
Deleted | |
#F2B6B6 |
Conflicting | |
#7386D0 |
Renamed | |
#72788C |
Ignored | |
#D9CECC |
Submodule |
Diff backgrounds are those accents at low alpha: #49EF952A in, #CA5F713A out.
Mermaid
Named hues for flowchart, pie and sequence coloring. Tint and mid-tone arrays derive from these, so a new chart type inherits the family. Full gallery: every Mermaid diagram in Moonlight.
| Swatch | Hex | Role |
|---|---|---|
#0D0E17 |
bg |
|
#141726 |
card |
|
#EEEEFA |
text |
|
#7386D0 |
primary |
|
#8A9BE0 |
blue |
|
#A2B0EA |
sky |
|
#B9C6F5 |
azure |
|
#ACB9E6 |
lavender |
|
#A5A1E9 |
violet |
|
#B86CB3 |
pink |
|
#CA5FA6 |
magenta |
|
#CA5F71 |
red |
|
#49EF95 |
green |
|
#99E6B3 |
mint |
|
#FFCB6B |
yellow |
Porting it somewhere new
- Read
moonlight-tokens.json, not a screenshot of another port. - Map the target's vocabulary onto these roles first. Most systems need three surface levels, not four.
- Need a role that does not exist? Derive it in OKLCH from the nearest one: same hue and chroma, move L. New hues only for genuinely new kinds of thing.
- Measure every text-on-surface pair in the target, on the surface it actually renders on, not just on the canvas. This is the step that gets skipped.
- A shared role changes on this page first, then
just tokens, then the ports.