Overview

The repo has one hand-authored canonical style (styles/bright/) and any number of palette-derived variants generated from it by scripts/recolor.mjs. Every directory under styles/ is automatically registered at build time — no manual wiring is needed when adding or renaming a style.

At build time, envsubst replaces template variables (tile server URLs, map center, version) in every style.json and HTML file. The raw source files are therefore not valid JSON until the build runs.

Repo structure

styles/
  bright/            canonical hand-authored style (source of truth)
  night/             GENERATED — do not hand-edit
  dark/              Dark Matter reference, external origin
  lessdark/          lighter Dark Matter variant
scripts/
  recolor.mjs         palette-driven style generator
  palettes/
    night.json        palette mapping for the Night style
  substitute-vars.sh  envsubst wrapper run at container build time
site/                 source for /map.html, /color-editor.html, /docs.html
templates/            endpoint directory index.html
fonts/                TTF sources → compiled to PBF glyphs at build
config/
  production.env      canonical env values (MARTIN_DOMAIN, etc.)
Caddyfile              static file server config inside the container
Justfile               build and run recipes
Containerfile          multi-stage container build

Local development

You need Podman or Docker. The just run recipe builds the full static bundle and starts a local server:

just run           # build + serve at http://localhost:8080/
just run 8081      # use a different port
just stop          # tear it down

After starting, the useful pages are:

The source files under styles/ and site/ are not served directly — they go through the container build first. After any source change, re-run just run to see the result.

Adding a new style

New styles are created by writing a palette JSON that maps every (CSS property, color value) pair in the source style to a new color, then running recolor.mjs to generate the output. The color editor can help you find the right values interactively before committing them to the palette.

1. Write a palette JSON

Create a file in scripts/palettes/. The required fields:

{
  "name": "Night",         // becomes style.name in the output
  "outName": "night",    // output directory: styles/night/
  "source": "bright",    // read from styles/bright/style.json
  "spriteFrom": "bright",// copy sprites from this style (defaults to source)
  "byKey": {
    "fill-color": {
      "#f8f4f0": "#1e2530",  // source color → target color
      "#fff":    "#c8d0db"
    },
    "line-color": {
      "#fff":    "#a3acba"   // same hex, different role → different target
    }
  }
}

Color keys are scoped by CSS property name because the same hex value can legitimately need a different dark-mode target depending on its visual role — a road-fill white and a label-halo white are not interchangeable.

Color values in the palette must exactly match the values in styles/bright/style.json, including whitespace and case. The script normalises them to lowercase with whitespace stripped before comparison, so #F8F4F0 and #f8f4f0 are the same, but the palette still needs to cover every distinct value that appears.

2. Generate the style

node scripts/recolor.mjs scripts/palettes/your-palette.json

If any (property, color) pairs in the source style are not covered by the palette, the script exits non-zero and lists them. Add those entries to the palette and rerun. When the script exits zero, styles/your-style/style.json and its sprites are written.

3. Iterate with the color editor

Run the container and open the color editor to review and tune your style visually:

just run
# open http://localhost:8080/color-editor.html?style=your-style
  1. Select your style from the dropdown in the toolbar.
  2. Click on any area of the map — a panel appears showing every layer rendered at that point with its current color values.
  3. Click a color swatch to open the color picker. The map updates live.
  4. When you are happy, click ↓ style.json to download the modified style.
  5. Compare the downloaded file against your palette JSON to identify which source colors need new target values. Add those to your palette and rerun recolor.mjs.
The color editor shows expr next to properties whose values are MapLibre expressions (e.g. road colour varying by zoom). These cannot be edited interactively — if you need to remap them, locate the relevant layer in styles/bright/style.json and handle them directly in the palette or as a post-processing step.

4. Commit

A complete new-style commit includes both the palette and the generated output:

scripts/palettes/your-palette.json   # the source of truth
styles/your-style/style.json         # generated output
styles/your-style/sprites/           # copied from source

Merge directly to main — no pull requests.

Editing the base style

styles/bright/style.json is the hand-authored master. Edit it directly, then regenerate all palette-derived styles so they stay in sync:

# after editing styles/bright/style.json
node scripts/recolor.mjs scripts/palettes/night.json
# repeat for any other palette-derived styles

If you added new color values to Bright, recolor.mjs will report them as unmapped and exit non-zero. Add them to each palette before committing.

Rules & gotchas

  • Never hand-edit generated styles. styles/night/style.json (and any other palette-derived style) will be overwritten the next time recolor.mjs runs. Edit the palette instead.
  • Never commit a generated style without also committing the palette change that produced it. The two must stay in sync or the next recolor will silently undo your hand edits.
  • Raw style files are not valid JSON. They contain bare ${VAR} placeholders (e.g. 8.5417) that are replaced by envsubst at build time. Do not try to parse them directly outside the build.
  • Always rerun recolor after editing Bright. Derived styles will silently diverge if you skip this step.
  • Color values in palettes must match exactly (case- and whitespace-insensitive). Run recolor.mjs to surface any gaps; it will list all unmapped pairs.