Implementing Dark and Light Theme Inheritance for Web Maps

Replace duplicate style JSON files with a single token-driven resolver that generates fully resolved dark and light GL-style outputs at build time, ensuring a single design token change propagates across every dependent layer without manual editing.

Core Algorithm and Workflow

The inheritance chain has three tiers that must be kept strictly separate:

  1. Design tokens — semantic names (bg_primary, label_halo, halo_width) mapped to theme-specific values. Semantic naming survives palette shifts; literal names like dark_blue_2 do not.
  2. Base template — a single base_map_style.json containing {{token_name}} placeholders in every paint and layout property that should vary. All other fields remain concrete values.
  3. Resolver — a recursive function that traverses the nested JSON, substitutes placeholders, casts numeric fields back to the correct type, then writes one resolved file per theme.

This separation is the core of the theme inheritance systems approach: the base template is the single source of truth for structure; the token dict is the single source of truth for visual values. Neither can reference the other directly, which is what makes the architecture deterministic.

The resolution sequence per theme variant:

  1. Deep-copy the base template (never mutate the original).
  2. Walk the copied structure recursively.
  3. On every string value, apply a regex substitution for {{key}} patterns.
  4. After recursion, run a second pass to cast known numeric paint properties (fill-opacity, line-opacity, text-halo-width, etc.) back from str to float.
  5. Validate the resolved dict against the GL style JSON schema.
  6. Write the output to dist/styles/{theme}_map_style.json.

The diagram below shows the data flow from tokens through resolution to the two exported style files.

Dark/light theme token resolution pipeline Data-flow diagram showing how THEMES dict tokens and the base GL-style template are fed into the recursive resolver, which produces separate light and dark style JSON files for consumption by a MapLibre/Mapbox renderer. THEMES dict light / dark tokens base_map_style {{placeholder}} JSON resolve_tokens() + numeric cast pass light_map_style.json dist/styles/ dark_map_style.json dist/styles/ 1. deep-copy template 2. substitute placeholders 3. cast numerics 4. validate

Production-Ready Python Implementation

The following script is a complete, copy-pasteable implementation. It handles nested GL-style JSON structures, casts numeric paint properties correctly, and validates the schema before writing output.

import json
import re
import copy
from pathlib import Path
from typing import Any, Dict, Set

# ---------------------------------------------------------------------------
# 1. Semantic design tokens
#    Use semantic prefixes (bg_, text_, stroke_) — names that survive
#    palette shifts without requiring layer edits.
# ---------------------------------------------------------------------------
THEMES: Dict[str, Dict[str, Any]] = {
    "light": {
        "bg_primary":    "#f8f9fa",
        "bg_secondary":  "#e9ecef",
        "text_primary":  "#212529",
        "text_secondary":"#495057",
        "water_fill":    "#a3d5ff",
        "road_fill":     "#e0e0e0",
        "road_stroke":   "#ffffff",
        "label_halo":    "#ffffff",
        "opacity_base":  0.95,
        "halo_width":    1.5,
    },
    "dark": {
        "bg_primary":    "#121212",
        "bg_secondary":  "#1e1e1e",
        "text_primary":  "#e0e0e0",
        "text_secondary":"#a0a0a0",
        "water_fill":    "#1a3a5c",
        "road_fill":     "#3a3a3a",
        "road_stroke":   "#2c2c2c",
        "label_halo":    "#000000",
        "opacity_base":  0.85,
        "halo_width":    2.0,
    },
}

# ---------------------------------------------------------------------------
# 2. GL-style paint properties that must be numeric, not string.
#    The resolver converts token values to str during substitution;
#    these keys are cast back to float in the post-resolve pass.
# ---------------------------------------------------------------------------
NUMERIC_PAINT_KEYS: Set[str] = {
    "fill-opacity", "line-opacity", "text-opacity", "icon-opacity",
    "text-halo-width", "text-halo-blur", "line-width", "line-blur",
    "circle-radius", "circle-opacity", "background-opacity",
}


# ---------------------------------------------------------------------------
# 3. Recursive token resolver
# ---------------------------------------------------------------------------
def resolve_tokens(data: Any, tokens: Dict[str, Any]) -> Any:
    """
    Recursively replace {{token_name}} placeholders in a nested GL-style
    JSON structure. Dicts and lists are traversed; strings are substituted;
    all other types are returned unchanged.
    """
    if isinstance(data, dict):
        return {k: resolve_tokens(v, tokens) for k, v in data.items()}
    if isinstance(data, list):
        return [resolve_tokens(item, tokens) for item in data]
    if isinstance(data, str):
        return re.sub(
            r"\{\{(\w+)\}\}",
            lambda m: str(tokens.get(m.group(1), m.group(0))),
            data,
        )
    return data


# ---------------------------------------------------------------------------
# 4. Post-resolve numeric cast pass
#    Walks every layer's paint dict and casts known numeric keys back to float.
#    This must run after resolve_tokens() because substitution serialises
#    numeric token values (e.g. 0.85) to strings ('0.85').
# ---------------------------------------------------------------------------
def cast_numeric_paint_properties(style: Dict[str, Any]) -> Dict[str, Any]:
    """
    In-place cast of string values back to float for paint properties that
    the GL spec requires to be numeric.
    """
    for layer in style.get("layers", []):
        paint = layer.get("paint", {})
        for key in NUMERIC_PAINT_KEYS:
            if key in paint and isinstance(paint[key], str):
                try:
                    paint[key] = float(paint[key])
                except ValueError:
                    pass  # leave expression arrays untouched
    return style


# ---------------------------------------------------------------------------
# 5. Theme generator — entry point
# ---------------------------------------------------------------------------
def generate_themes(template_path: Path, output_dir: Path) -> None:
    """
    Load the base GL-style template, resolve tokens for each theme variant,
    post-process numeric fields, and write per-theme JSON to output_dir.
    """
    with open(template_path, "r", encoding="utf-8") as f:
        base_template = json.load(f)

    output_dir.mkdir(parents=True, exist_ok=True)

    for theme_name, tokens in THEMES.items():
        # Deep-copy ensures the original template is never mutated
        resolved = resolve_tokens(copy.deepcopy(base_template), tokens)
        resolved = cast_numeric_paint_properties(resolved)

        output_file = output_dir / f"{theme_name}_map_style.json"
        with open(output_file, "w", encoding="utf-8") as out:
            json.dump(resolved, out, indent=2)
        print(f"[theme-builder] {theme_name}{output_file}")


if __name__ == "__main__":
    TEMPLATE = Path("base_map_style.json")
    OUTPUT   = Path("dist/styles")
    generate_themes(TEMPLATE, OUTPUT)

Base template structure

Your base_map_style.json should use {{token}} placeholders in every string-valued paint or layout field that should vary by theme. Non-varying properties (source URLs, filter expressions, zoom stop breakpoints) are left as concrete values:

{
  "version": 8,
  "name": "Base Map",
  "sources": {
    "osm": {"type": "vector", "url": "https://example.com/tiles.json"}
  },
  "layers": [
    {
      "id": "background",
      "type": "background",
      "paint": {"background-color": "{{bg_primary}}"}
    },
    {
      "id": "water",
      "type": "fill",
      "source": "osm",
      "source-layer": "water",
      "paint": {
        "fill-color":   "{{water_fill}}",
        "fill-opacity": "{{opacity_base}}"
      }
    },
    {
      "id": "road-casing",
      "type": "line",
      "source": "osm",
      "source-layer": "roads",
      "paint": {
        "line-color": "{{road_stroke}}",
        "line-width": 4
      }
    },
    {
      "id": "place-labels",
      "type": "symbol",
      "source": "osm",
      "source-layer": "place_label",
      "layout": {"text-field": "{name}"},
      "paint": {
        "text-color":      "{{text_primary}}",
        "text-halo-color": "{{label_halo}}",
        "text-halo-width": "{{halo_width}}"
      }
    }
  ]
}

Note that fill-opacity and text-halo-width receive string placeholders here. The cast_numeric_paint_properties() pass converts them back to float after resolution, which is why the resolver and the cast pass are separate steps rather than one combined function.

Performance Tuning and Cartographic Best Practices

  • Token naming stability. Always use semantic names (bg_primary, road_stroke) rather than value-descriptive names (light_gray, off_white). Semantic names mean you can swap from a grey-based to a warm-tinted palette without touching a single layer definition.
  • Halo uniformity across text layers. Apply {{label_halo}} and {{halo_width}} to every symbol layer’s paint block in the base template, not just city labels. Inconsistent halos cause visual noise at zoom transitions — especially visible on dark themes where halo contrast is higher. For projects where text layers are generated dynamically, add a post-resolve transformer that iterates resolved["layers"], filters for type == "symbol", and sets text-halo-color / text-halo-width on any layer missing them.
  • Deep-copy discipline. Always pass copy.deepcopy(base_template) to resolve_tokens(). A shallow copy means token resolution in one theme variant will mutate nested dicts shared with the next variant, producing corrupted output that is difficult to trace in large style files (100+ layers).
  • Numeric token storage. Store opacity_base and halo_width as Python float in the THEMES dict. The resolver will str()-ify them during substitution, but having the authoritative value as a float makes the cast pass unambiguous and keeps the token dict human-readable.
  • Schema validation before deployment. Pipe the resolved dict through ajv (Node.js) or a Pydantic model derived from the MapLibre GL Style Specification before writing the output file. Catching a malformed expression at build time prevents a client-side renderer crash that can be hard to reproduce in headless CI environments.

Integration and Next Steps

Once dist/styles/light_map_style.json and dist/styles/dark_map_style.json exist, they integrate into GL-compatible renderers with a single map.setStyle() call on theme toggle. For runtime switching without a full style reload, prefer map.setPaintProperty() and map.setLayoutProperty() for targeted updates — this avoids WebGL context loss and preserves tile cache state.

For CI/CD, the resolver fits naturally into the automated pipeline that programmatic map styling and label automation workflows depend on:

  • Pre-commit hook: run generate_themes() and fail if any resolved output fails schema validation. This catches missing tokens before they reach staging.
  • GitHub Actions: cache dist/styles/*.json and invalidate only when THEMES, base_map_style.json, or the resolver script changes. Avoid regenerating on every commit — the resolver is fast but the downstream CDN cache invalidation is expensive.
  • Static tile generation: pass resolved JSON directly to mbgl-renderer or tippecanoe post-processing steps. The rule-based styling engines used in batch pipelines can consume the same resolved JSON, so a shared dist/styles/ directory keeps both systems in sync.
  • Label collision validation: after resolving to the dark theme, run the same label collision avoidance algorithms you use on the light theme. Dark halo widths are typically 25–35% wider than light ones, which shifts label bounding boxes and can expose previously hidden collisions at dense zoom levels.

  • Back to Theme Inheritance Systems — the parent page covering multi-tier inheritance architecture, schema design, and recursive merge patterns.
  • Rule-Based Styling Engines — combine token-resolved themes with JSON rule conditions to drive feature-level overrides without duplicating layer definitions.
  • Label Collision Avoidance Algorithms — verify that halo-width changes introduced by dark theme tokens do not shift label bounding boxes into collision with adjacent features.