Compiling Dark-Mode Tile Styles from a Base

Keep one base style whose colors are named tokens, then compile each theme by overriding only paint color properties — never layer structure, source-layer names, filters, or sources. Because every variant is the same base with a different color table applied, light, dark, and high-contrast stay automatically synchronized with the tile schema: there is exactly one place where a layer can reference a tile attribute, and all variants inherit it.

When to use this

Reach for a compiled base whenever you ship more than one visual theme against the same tiles — light and dark being the universal pair, often joined by high-contrast or a print variant. Hand-forking a full style.json per theme works for exactly one release; by the second tile-schema change you have three styles drifting out of sync and a dark mode referencing a layer the light mode already renamed. Compiling from a base makes structure single-sourced and reduces each theme to the only thing that legitimately differs between them: color.

Specification detail: tokens and the override contract

The base declares colors indirectly through a metadata token table, and each layer’s paint reads a token name that the compiler resolves at build time.

Concept Where it lives Varies per theme?
Color token table metadata["tokens"] in the base Yes — this is the only thing a variant overlay changes
Layer id layers[].id No — must be identical across all variants
source / source-layer layers[] No — the tile contract; identical across variants
filter layers[] No — filters select features, not appearance
layout (visibility, text-field, sort key) layers[] No — structural; a hidden layer is hidden in every theme
paint non-color props (*-width, *-opacity, *-radius) layers[] No — geometry weight is theme-independent
paint color props (*-color) layers[], via token Resolved from the active token table

The override contract is deliberately narrow: a variant overlay is a flat map of token name to color, and the compiler may only write paint.*-color properties. Everything else is copied from the base byte-for-byte. This is what guarantees schema agreement — because no variant can touch a source-layer or a ["get", key], no variant can disagree with the tiles about what those are. It is the same discipline the parent theme inheritance patterns guide applies at the whole-file level, scoped down here to the single dark/light color axis.

Production command block

The base carries a token table and layers that reference tokens by name:

json
{
  "version": 8,
  "name": "base",
  "metadata": {
    "tokens": {
      "bg": "#f8f4f0", "water": "#a8d5e2",
      "road": "#e8c87a", "label": "#333333"
    }
  },
  "sources": { "basemap": { "type": "vector", "url": "https://tiles.example.com/city.json" } },
  "layers": [
    { "id": "background", "type": "background",
      "paint": { "background-color": "@bg" } },
    { "id": "water", "type": "fill", "source": "basemap", "source-layer": "water",
      "paint": { "fill-color": "@water" } },
    { "id": "road-primary", "type": "line", "source": "basemap", "source-layer": "transportation",
      "filter": ["==", ["get", "class"], "primary"],
      "paint": { "line-color": "@road", "line-width": 2 } }
  ]
}

The dark overlay is only the tokens that change — never a layer:

json
{ "bg": "#12121a", "water": "#16213e", "road": "#c9962f", "label": "#e6e6e6" }

The compiler resolves @token references and refuses to touch anything but paint colors, then asserts structural parity before writing the variant:

python
# compile_variant.py
import json, sys
from pathlib import Path

def resolve_colors(layer: dict, tokens: dict) -> dict:
    """Return a copy of layer with @token paint colors resolved. Structure untouched."""
    out = json.loads(json.dumps(layer))          # deep copy
    paint = out.get("paint", {})
    for prop, val in list(paint.items()):
        if prop.endswith("-color") and isinstance(val, str) and val.startswith("@"):
            token = val[1:]
            if token not in tokens:
                sys.exit(f"ERROR layer '{out['id']}': unknown color token '{val}'")
            paint[prop] = tokens[token]
    return out

def structural_key(layer: dict) -> tuple:
    """Everything a variant must NOT change."""
    return (layer.get("id"), layer.get("source"),
            layer.get("source-layer"), json.dumps(layer.get("filter")))

def compile_variant(base: dict, overlay: dict, name: str) -> dict:
    tokens = {**base["metadata"]["tokens"], **overlay}   # overlay wins per token
    variant = json.loads(json.dumps(base))
    variant["name"] = name
    variant["layers"] = [resolve_colors(l, tokens) for l in base["layers"]]

    # Parity gate: base and variant must be structurally identical
    for b, v in zip(base["layers"], variant["layers"]):
        if structural_key(b) != structural_key(v):
            sys.exit(f"ERROR structural drift in layer '{b.get('id')}'")
    if len(base["layers"]) != len(variant["layers"]):
        sys.exit("ERROR layer count differs between base and variant")
    return variant

if __name__ == "__main__":
    base = json.loads(Path("styles/base.json").read_text())
    overlay = json.loads(Path("styles/tokens.dark.json").read_text())
    out = compile_variant(base, overlay, "dark")
    Path("dist/style.dark.json").write_text(json.dumps(out, indent=2))
    print("OK  wrote dist/style.dark.json")

Run it per theme:

bash
python compile_variant.py   # base.json + tokens.dark.json -> style.dark.json

The light variant is simply the base tokens with no overlay, so light and dark are produced by the same code path and can never diverge structurally — the parity gate exits nonzero the moment a layer id or source-layer differs.

Interaction effects

Every variant inherits one schema contract. Because layers exist only in the base, the style-in-CI validation gate needs to check source-layer and ["get", key] agreement against the tiles just once, on the base — the variants are structurally identical by construction, so they cannot introduce a new mismatch. Still, run the spec validate() on each compiled output to catch a bad color value in an overlay.

Renamed layers are fixed in one place. When the tile build renames a source-layer, you update the base and recompile; dark, light, and high-contrast all pick up the fix in the same run. This is the opposite of forked styles, where a rename must be hand-applied to each file and the dark variant is the one everyone forgets — the exact failure mode covered in layer filter synchronization.

Pin the base version. Compile against a specific base commit or metadata version string and record it in each variant’s metadata, so a variant on the CDN can always be traced to the base and tile schema it was built from. Coordinate multi-source bases with the layout rules in structuring MapLibre styles for multi-source tiles — the token approach extends unchanged across sources, since tokens are global and layers still name their own source.

Performance impact

Runtime performance is not the concern here — a compiled dark style renders identically to a hand-written one, because the output is a normal style.json with literal colors. The payoff is build correctness and maintenance: one base to edit instead of N drifting files, and a mechanical guarantee that themes never disagree about structure. Compilation itself is a sub-second JSON transform, so it belongs in the same CI job that validates and publishes the styles.

Common mistakes

1. Forking full styles so they drift

Symptom: Dark mode is missing a layer that light mode added last sprint, or renders an old road color long after the palette changed.

Cause: Each theme is a separate hand-maintained style.json; an edit to one is not mirrored to the others.

Fix: Collapse to a single base plus per-theme token overlays and compile. Delete the forked files once the compiled outputs match; from then on, structure lives in exactly one place.

2. Overriding structure, not just color

Symptom: A variant overlay grows to include whole layers entries and starts changing a filter or source-layer “just for dark mode.”

Cause: Treating the overlay as a general patch file instead of a color table.

Fix: Keep overlays to token-to-color maps only, and let the parity gate enforce it — structural drift in layer '…' fails the build the instant a variant changes anything but a paint color. If a layer genuinely must differ structurally between themes, that belongs in the base with a layout.visibility toggle, not in a color overlay.

3. Dark variant referencing a layer the base already fixed

Symptom: After a source-layer rename, light mode works but dark mode renders a blank basemap.

Cause: A leftover forked dark file still pointing at the old layer name.

Fix: Recompile every variant from the corrected base so none can lag, and add the schema-agreement check from validating styles in CI as a required gate. A stale dark file cannot survive a build that only ever emits compiled variants.

4. Contrast failures in the dark palette

Symptom: Dark-mode labels or thin roads are legible on your monitor but vanish for users, failing accessibility review.

Cause: Dark tokens were eyeballed rather than checked against a contrast target.

Fix: Validate the token table against a WCAG contrast ratio (label vs background ≥ 4.5:1) in the same compile step, and fail the build on any token pair below threshold — contrast is a property of the color table, so it is checkable exactly where the tokens are resolved.


Parent: Theme Inheritance Patterns