---
title: MonorailCSS integration
description: Why Pennington discovers CSS classes by scanning compiled assemblies and watched source files instead of pre-building a static stylesheet.
canonical_url: https://usepennington.net/explanation/rendering/monorail-css/
sidecar_url: https://usepennington.net/explanation/rendering/monorail-css.md
content_hash: sha256:4113e23de0dbd6eedbbdf703c4149c49c2a180117b38a07754b09f426f6c11b2
tokens: 1707
uid: explanation.rendering.monorail-css
reading_time_minutes: 5
---

Under the Hood
# MonorailCSS integration

Why Pennington discovers CSS classes by scanning compiled assemblies and watched source files instead of pre-building a static stylesheet.

 
Utility-first CSS normally needs a build step that scans source files and regenerates a stylesheet — so how does Pennington emit a correct stylesheet when there is no `npm run build` in the loop and new classes can appear the instant someone edits a markdown file?

 
## Context

 
Utility-first CSS frameworks like Tailwind and [MonorailCSS](https://monorailcss.github.io/MonorailCss.Framework/) (the Tailwind-compatible .NET JIT compiler Pennington integrates) ship a vast class surface and rely on a scanner to collect only the classes in use, keeping the final stylesheet small. Traditional setups solve this with a pre-build step that globs source files. That model fights a runtime-rendering content engine in two ways: markdown is rendered at runtime through Markdig extensions, so classes do not exist on disk until a request renders them, and adding a Razor component or a new page would require rerunning a separate tool.

 
Pennington uses the `MonorailCss.Discovery` package. Discovery force-loads every non-BCL referenced assembly at startup, walks the IL for string literals that parse as utility candidates, and watches source files in development for live updates. The discovered set is exposed through an `IClassRegistry`. The `/styles.css` endpoint reads the current class set and runs it through a fresh `CssFramework` on every request, so an option change or a newly observed class shows up on the next fetch without a process restart.

 
## How it works

 
### Classes are discovered by scanning compiled output

 
`AddMonorailCss` calls `services.AddMonorailClassDiscovery()`, which registers the runtime scanner. At startup the scanner enumerates every assembly the entry app references (skipping the BCL), force-loads each one if needed, and walks IL string literals through Pennington's configured `CssFramework` to keep only the candidates the framework actually recognizes. The same theme drives both halves of the pipeline: the framework that validates discovery candidates and the one that generates the stylesheet are built from the same options, so a class survives discovery only if it would render.

 
Because the scan reads compiled IL rather than source text, every `class="bg-primary-500"` literal in a Razor component, every string constant in a C# helper, and every utility token in `Pennington.UI`'s shipped components participates without any per-project glob configuration. In development, Discovery also watches the source files behind the loaded assemblies and re-scans on edits, so a new utility added to a `.razor` or `.cs` file shows up on the next `/styles.css` fetch. If a `wwwroot/app.css` is present, Discovery treats it as the source CSS prefix.

 
### The stylesheet generates on demand, every request

 
`UseMonorailCss` maps a `GET /styles.css` endpoint that calls `MonorailCssService.GetStyleSheet()`. Each hit builds a fresh `CssFramework` from the current `MonorailCssOptions`, runs it over `IClassRegistry.GetClasses()`, and prepends Pennington's content-visibility preamble plus any configured `ExtraStyles`. The per-call rebuild is what lets hot-reload edits to `CustomCssFrameworkSettings` or theme tokens flow into the next stylesheet without restarting the process.

 
Pennington is a static content engine: the build is one-shot and the dev server is the only other consumer, so per-call regeneration is cheap enough to make caching unnecessary. The first page load primes the registry with whatever classes that page emits; the browser then fetches `/styles.css` and gets a stylesheet generated from the current class set. A subsequent navigation that introduces a new class is reflected on the very next stylesheet fetch.

 
A static build leans on the same ordering. The build fetches every HTML page first — priming the registry with every class the rendered site actually emits — and fetches `/styles.css` last, after all that markup has run through the pipeline. So the `styles.css` written to the output directory is a single tree-shaken file: exactly the utilities the site uses, nothing more, generated once and served as a plain static asset with no runtime regeneration in production.

 
### Color schemes: named vs algorithmic

 
`ColorScheme` on `MonorailCssOptions` ships in two flavors. `NamedColorScheme` is the choice when a designer says "I want Tailwind Purple for primary": it maps `primary`, `accent`, and `base` onto built-in palettes by name. `AlgorithmicColorScheme` is the choice when the starting point is a brand hue expressed in degrees and the whole palette needs to be derived from it: it synthesizes everything from a single `PrimaryHue`. See [Pennington.MonorailCss.MonorailCssOptions](https://usepennington.net/reference/api/monorail-css-options.md) for the full parameter surface.

 
Syntax-highlight colors are deliberately kept off the brand scheme. `SyntaxTheme` on `MonorailCssOptions` holds the five roles `.hljs-*` token classes consume — keyword, string, variable, function, and comment — each mapped to its own Tailwind palette. The default picks a tuned combination (Sky / Emerald / Rose / Amber / Slate) that reads well against either a light or dark code background, so a site can pick primary and accent purely for brand reasons without constraining how code renders.

 
### OKLCH palette generation

 
From a single `PrimaryHue`, the algorithmic scheme synthesizes a full palette — each color as the familiar 11-stop ramp keyed by `50` through `950` shade names, derived in the OKLCH color space rather than HSL.

 
OKLCH is the right choice here because of perceptual uniformity. It is a cylindrical coordinate system over the OK-Lab color space — lightness, chroma, and hue tuned so equal numeric steps look equal to the eye. That is not true of HSL, where a 500-weight green at HSL lightness 40% looks brighter than a 500-weight blue at the same value. OKLCH makes the generated scheme feel visually coherent without per-hue handwork, which is what makes "give me a palette from hue 214" a reasonable thing to ask.

 
## Further reading

 
 - Reference: [MonorailCssOptions](https://usepennington.net/reference/api/monorail-css-options.md) — the full option surface with defaults.
 - How-to: [Customize MonorailCSS](https://usepennington.net/how-to/theming/monorail-css.md) — swapping schemes, injecting `CustomCssFrameworkSettings`, and authoring extra styles.
 
 
[Previous
                
                The head subsystem](https://usepennington.net/explanation/core/head-subsystem.md)[Next
                    
                The syntax-highlighting cascade](https://usepennington.net/explanation/rendering/highlighting.md)