theme-styling · git:20260825.97b4155 · 2026-08-25 · sha256 1fd3871037241476

theme-styling git:20260825.97b4155A

Immutable. This exact content is served forever at /api/v1/blob/1fd3871037241476.

---
name: theme-styling
description: "The SCSS workflow and its traps — where styling actually compiles, custom-variables.scss, themesource directories, hot reload, and design-property errors. Use when writing or debugging SCSS, or when styling silently fails to appear."
---

# Theme & Styling — SCSS Workflow and Caveats

## When to Use This Skill

Use this skill when working with:
- SCSS compilation, `custom-variables.scss`, or `themesource/` directories
- CSS hot-reload during Docker development
- Debugging styling crashes or design property issues

For **MDL styling commands** (`show design properties`, `describe styling`, `alter styling`, inline `designproperties:`, `update widgets`), see:
- Existing proposal: `docs/11-proposals/page-styling-support.md`
- Working examples: `mdl-examples/doctype-tests/12-styling-examples.mdl` (595 lines)
- Implementation: `mdl/executor/cmd_styling.go`, `mdl/executor/theme_reader.go`

## SCSS Compilation Chain

### Directory Structure

```
MyProject/
├── theme/                          # project-level overrides
│   └── web/
│       ├── main.scss               # SCSS entry point (import chain)
│       ├── custom-variables.scss   # project variable overrides
│       ├── exclusion-variables.scss # Exclude unwanted Atlas components
│       └── settings.json           # Theme settings
│
├── themesource/                    # module-level theme definitions
│   ├── atlas_core/                 # base framework (always present)
│   │   └── web/
│   │       ├── design-properties.json  # widget design properties
│   │       ├── variables.scss          # Color/spacing/font variables
│   │       └── ...                     # Component SCSS files
│   ├── datawidgets/                # DataGrid2, gallery, etc.
│   ├── atlas_web_content/          # Web content styles
│   └── <module_name>/              # Each module can contribute styles
│       └── web/design-properties.json
│
└── theme-cache/web/                # Compiled CSS output (build artifact)
```

### Compilation Order

`atlas_core/web/main.scss` imports in order:
1. Default variables (`atlas_core`)
2. Exclusion variables (disable Atlas components)
3. Project custom variables (`theme/web/custom-variables.scss`)
4. Bootstrap framework
5. MXUI components
6. Core styles (base, animations, spacing, flex)
7. Widget-specific styles

Then each **module's** `themesource/<module>/web/main.scss`, and **last of all**
`theme/web/main.scss`.

Variables declared earlier are overridden by later declarations (with `!default` flag). This means `custom-variables.scss` overrides `atlas_core/web/variables.scss` values.

### Where to put app-level styling — three rules that are not obvious

Verified against a real Mendix 11.13 project (probe rules compiled with
`mxbuild --target=deploy`, then grepped out of `theme-cache/web/theme.compiled.css`).

**1. `theme/web/main.scss` compiles LAST — it is the right home for app styling.**
After Atlas Core *and* after every module theme source, so a partial imported
here overrides any Atlas rule with **no `!important`**. It is a three-line file of
Mendix's own imports, not an Atlas-owned file; appending one `@import` is safe:

```scss
@import "custom-variables";
@import "theme-dark";
@import "theme-neutral";
@import "my-app";          // -> theme/web/_my-app.scss
```

**2. A `themesource/<name>/` folder is only compiled when `<name>` is a real module.**
mxbuild walks the model's modules and pulls each one's theme source; it never
globs the directory. An invented folder (`themesource/my_theme/`) is **silently
skipped** — build succeeds, rules simply absent. Use a module's theme source only
when the styling belongs to that module (it then exports with the `.mpk`).

> Debugging "my CSS doesn't apply": first prove the file is compiled *at all* —
> grep a unique probe selector in `theme-cache/web/theme.compiled.css`. Absent and
> overridden look identical in the browser, and only one of them is a
> specificity problem.

**3. `theme/web/custom-variables.scss` is imported once PER MODULE** (8× in a
blank app). It must hold **declarations only** — a CSS rule there is emitted once
per module. Tokens go here; rules go in the Layer-2 partial.

### Mendix 11: CSS custom properties, not SCSS variables

The stock `theme/web/custom-variables.scss` is a `:root { --brand-primary: … }`
block plus a few SCSS switches (`$font-family-import`, `$btn-bordered`,
`$use-css-variables`). Legacy Sass variables are still mapped
(`_css-variables-mappings.scss`), but the modern idiom is `:root` declarations.
The derived ramp (`--brand-primary-50…900`) is built with CSS `color-mix()`
against `var(--brand-primary)`, so retuning the primary re-derives the whole ramp
live — no SCSS recompilation of variants needed.

### Fonts: vendor them under `theme/web/`

`theme/web/<subdir>/` is copied to the deployment web root, and
`theme.compiled.css` is served from that root — so fonts at
`theme/web/fonts/x.woff2` are referenced as `url("./fonts/x.woff2")`. Prefer this
over `@import url('…fonts.googleapis…')`: no `@import`-ordering trap, no
third-party request per page load, and the app renders correctly air-gapped.

`mxcli theme apply` does exactly this — see `mxcli theme show signal`.

### A theme of your own: `mxcli theme create`

Don't hand-edit a generated block to get a brand palette. The block is
digest-fenced, so the next `theme apply` refuses to touch it and reports your
file as modified — you have taken the theme out of mxcli's hands to change one
colour. Scaffold a theme the project owns instead:

```bash
mxcli theme create acme -p app.mpr                    # scaffold from signal
mxcli theme create acme -p app.mpr --from console     # ...or from console
mxcli theme create acme -p app.mpr --from design.css  # ...and seed the palette
mxcli theme apply acme -p app.mpr
```

It lands in `theme/mxcli-themes/<name>/` — committed (unlike `.mxcli/`, which
`mxcli init` gitignores) and not compiled (mxbuild's entry point is
`theme/web/main.scss`; it does not glob `theme/`). From then on it is a theme
like any other: `theme list -p` shows it marked `local`, `theme apply` installs
it, `theme remove` takes it out. A local theme named after a built-in shadows it.

**Seeding from a design.** `--from <file>` reads `--mxt-*` declarations out of
any CSS-shaped text — a stylesheet, an SCSS partial, or the `<style>` blocks of
an HTML export:

```css
:root { --mxt-brand: #7f5af0; --mxt-ground: #fffffe; }
@media (prefers-color-scheme: dark) { :root { --mxt-ground: #16161a; } }
```

A dark block (`prefers-color-scheme: dark`, `.theme-dark`, `[data-theme="dark"]`)
seeds the dark palette; everything else seeds the light one. Tokens the design
does not name keep the base theme's value.

If you are driving a design step (`/design` or similar) that will feed this, ask
it to **emit a token block** rather than inferring one from the mockup. Two greys
in a design do not say which is the app ground and which is a hovered row;
`mxcli theme show signal` prints the exact vocabulary to target. A `--mxt-*` name
the base theme does not declare is refused, because nothing would read it — the
theme would apply cleanly and render unchanged.

### Several themes at once — the app switches skins at runtime

```bash
mxcli theme apply signal ledger console -p app.mpr   # first named is the default
mxcli theme switcher install -p app.mpr --module MyFirstModule
```

All of them compile into one stylesheet; the app picks one with a class on
`<html>`. No rebuild, no reload. This is the CSS Zen Garden result for Mendix:
the DOM Mendix renders never changes and neither does the model — brand, ground,
ink, radius, type and card treatment all move on a class swap. Measured on a real
11.13 app: signal `#0f6e6b`/4px/IBM Plex with shadowed cards, ledger
`#1f3a5f`/2px/Source Sans with hairlines, console `#2dd4bf`/6px/Space Grotesk
flat.

Two things make it work, and both are worth knowing if you write a theme:

1. **Nothing outside the palette may name a colour.** The Atlas map, the recipe
   layer and the widget layer resolve everything through `var(--mxt-*)`, so one
   copy of them serves every theme. A literal in any of those files survives the
   swap and is wrong under every theme but one.
2. **The default theme's scope is `:root` minus the other skins' classes**, not a
   bare `:root`. Bare keeps matching once another class is set, so its rules leak
   under every other theme and the winner comes down to specificity. Negation
   makes the scopes mutually exclusive.

A single installed theme is emitted exactly as before — bare `:root`, skin rules
unscoped — so this costs a one-theme project nothing.

### Light/dark: Mendix ships the slot, not the switcher

`theme/web/_theme-dark.scss` and `_theme-neutral.scss` declare `:root.theme-dark`
and `:root.theme-neutral`. **Nothing in Atlas ever applies those classes** — grep
`themesource/` and you will find no reference. They are a slot for you to drive.

Three consequences worth knowing before building any light/dark support:

1. **A token flip really does repaint Atlas.** Adding `theme-dark` to `<html>` on
   a running Mendix 11 app turns the page ground, cards, form controls, sidebar,
   buttons and DataGrid2 dark, with no per-widget CSS. This is materially better
   than Atlas 3, where the same trick left widgets light. And because the class
   is on `<html>`, popups and modals rendered at `<body>` follow it too.
2. **Your dark block must come after Mendix's.** `_theme-dark.scss` hardcodes
   stock Mendix blue at `:root.theme-dark`. Declare the same selector from a file
   imported later in `theme/web/main.scss` — same specificity, later wins — or
   your brand vanishes the moment the class appears.
3. **Never pin an Atlas variable to a literal colour.** Map it to a token
   (`--bg-color: var(--my-ground)`) so a variant restates the tokens, not the
   wiring. A hardcoded `--font-color-default` is invisible on a dark ground.

The rail is the one place Atlas still assumes: several topbar widgets paint text
with `--color-base`, expecting white, because they expect a dark navigation rail.
Keep the rail dark in both variants, or force `color: inherit` on those widgets.

For a working implementation of all of the above, read the generated
`theme/web/_mxcli-atlas-map.scss` in any themed project.

### Tokens stop at Atlas Core — the widget modules bake their colours

Re-pointing Atlas's custom properties covers the app, and then a few things stay
stubbornly off-palette: the Data Grid 2 pager caption, row-select checkboxes,
popover shadows. One cause: the theme source shipped by the **widget modules**
(`themesource/datawidgets`, `atlas_web_content`) styles some things with Sass
variables and literals. Sass resolves those at compile time, before any custom
property exists, so the value is baked into `theme.compiled.css` and **no token
can move it**. Only a later CSS rule can.

The worst case is `datawidgets/web/variables.scss:18`,
`$pagination-caption-color: #0a1325` — the "1–15 of 77" caption, which measured
**1.02:1** on a dark ground. The pager *buttons* beside it were fine, because
they resolve `var(--gray-darker, …)` through Atlas. Same bar, two mechanisms.

**The obvious fix does not work.** Each module's `main.scss` imports
`theme/web/custom-variables` *before* its own `!default` variables, so setting
`$pagination-caption-color: var(--my-muted)` there would win and Sass would
substitute the `var()` into every use site. Tempting, and wrong here:

1. The names collide with Atlas Core's, and Atlas Core feeds them to Sass colour
   functions — `atlas_core/web/_variables.scss:20` computes
   `mix($brand-primary, #e7e7e9, 10%)`. Handing `mix()` a `var()` is a compile
   error, so the app stops building.
2. The worst offenders are not behind a variable at all:
   `_three-state-checkbox.scss` writes `#264ae5` and `rgba(#264ae5, 0.4)`
   directly, so overriding `$brand-primary` would not reach them.

So it is a rule set, in a partial imported after the theme's own — see the
generated `theme/web/_mxcli-widgets.scss`.

**Read the compiled CSS, not the SCSS, when building one.** The sources are full
of `var(--token, #fallback)` declarations that already resolve correctly; only
the bare literals are a problem. In one measured app the stock blue `#264ae5`
appeared in 46 declarations — **24 of them harmless fallbacks**. Grepping the
source would have produced twice the rules for no benefit.

## CSS Hot-Reload Workflow

For theme/styling changes during Docker development:

```bash
# 1. Compile SCSS into deployment package (~55s)
mxcli docker build -p app.mpr

# 2. Push compiled CSS to browsers (instant, no page reload)
mxcli docker reload -p app.mpr --css
```

The `--css` flag calls the M2EE `update_styling` action, which pushes CSS via WebSocket to all connected browsers. **It does NOT compile SCSS** — always run `docker build` first.

For non-CSS changes (Class, Style, DesignProperties on widgets), use normal reload:
```bash
mxcli docker reload -p app.mpr
```

## Caveats

### DYNAMICTEXT + Style Crash

**Never** apply `style` directly to a DYNAMICTEXT widget — it crashes MxBuild with a NullReferenceException. Wrap in a CONTAINER:

```sql
-- WRONG: crashes MxBuild
dynamictext txt (content: 'Hello', style: 'color: red;')

-- CORRECT: style the container
container ctn (style: 'color: red;') {
  dynamictext txt (content: 'Hello')
}
```

This also applies to `alter styling` and `alter page set style` — never target a DYNAMICTEXT widget with Style.

### Clipped navigation labels are the CLOSED sidebar, not the theme

A sidebar item reading `All task` instead of `All tasks` is Atlas's **closed**
sidebar, which is an icon rail: `--navsidebar-width-closed: 48px`, set in Atlas's
own `themesource/atlas_core/web/themes/_theme-default.scss`. Measured against a
real compiled theme in a browser, the `<a>` for "All tasks" is **57px wide inside
a 48px rail** — the same overflow reported from a live app (56 in 48).

No mxcli theme sets any navigation *width*; the themes map colours only. So this
reproduces identically under `signal`, `ledger` and `console`, in both variants —
a layout constant, not a palette.

The fix is in the app, not the theme:

- **Give each nav item an icon.** That is what the closed rail is for; the icon is
  what stays visible when the sidebar is closed.
- **Or keep the sidebar open**, where the label has room.
- Shorter labels help, but only until the next one is too long.

Do **not** reach for `text-overflow: ellipsis` on the nav item as a blanket fix.
Tried and rejected: where Atlas does not also set `white-space: nowrap`, the label
wraps to two lines and reads fine — and the ellipsis rule turns that readable
`All / tasks` into `All / t…`. It trades one truncation for a worse one.

### DataGrid2 Renders ARIA `<div>`s, Not a `<table>` — and `Size` Is a Flex Weight

Two surprises when styling a **DataGrid2** matrix/pivot (ledger finding #46):

1. **It is not a `<table>`.** DataGrid2 emits `role="grid"` / `role="row"` /
   `role="gridcell"` **`<div>`s**, so `th, td { … }` selectors match nothing. A
   `td { white-space: nowrap }` intended to keep `€ 5,200` on one line does not
   apply, and amounts wrap. Target the ARIA roles instead:

   ```scss
   .ledger-matrix [role='gridcell'] { white-space: nowrap; }
   ```

   Playwright/tests see the same DOM: assert on `[role="row"]`, not `tr`.

2. **`Size` is a flex weight, not pixels.** On a column, `ColumnWidth: manual,
   Size: 132` does **not** set a 132px width — it divides available width by the
   weights across all columns. To give a wide matrix room, set a min-width on the
   grid and let it scroll:

   ```scss
   .ledger-matrix [role='grid'] { min-width: 1320px; }
   .ledger-matrix { overflow-x: auto; }
   ```

### Design Property Keys Are Case-Sensitive

Keys must match the `name` field in `design-properties.json` exactly:
```sql
-- CORRECT
designproperties: ['Spacing top': 'Large']

-- WRONG (case mismatch — silently ignored)
designproperties: ['spacing top': 'Large']
```

### Compound (Nested) Design Properties

Besides **flat** properties (a key with a single value — an option/dropdown
string or a toggle), `designproperties:` also supports **compound** properties:
one whose value is itself a set of sub-properties (e.g. Atlas's `Spacing` →
`margin-top`, `margin-bottom`, …). A compound value is written as a nested list:

```sql
designproperties: [
  'Column gap': 'Medium',                                       -- flat option
  'Cards style': ON,                                            -- flat toggle
  'Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium'] -- compound
]
```

Supported on the **modelsdk** (`.mpr`) and **MCP** (live Studio Pro) backends.
Sub-property keys are case-sensitive, same as flat keys.

### ALTER STYLING Limitation with Builder-Created Pages

`alter styling` cannot find widgets in pages created by the MDL page builder because `walkPageWidgets` traverses `LayoutCall.Arguments` but the page parser doesn't fully reconstruct the widget tree when re-reading builder-created pages. These commands work on pages originally created in Studio Pro.

## Validation with `mxcli check -p`

When a project is supplied (`mxcli check page.mdl -p app.mpr`), design properties are
validated against the project's theme registry (`themesource/*/web/design-properties.json`):

- **MDL-WIDGET11** — a design-property key not defined for that widget type (with a
  case-sensitivity hint, or the list of valid keys).
- **MDL-WIDGET12** — an option value that isn't allowed; the message **lists the
  allowed values** (case-sensitive), which is the fastest way to fix a casing typo.

Both are warnings (a newer theme may add keys/values), so they inform without blocking.
`show design properties <widget>` lists the same allowed keys/values up front. On the
write side, the value's BSON type is taken from the registry (a `ColorPicker` /
`ToggleButtonGroup` property serializes as a custom value, not a plain option).

## Checklist

- [ ] Never apply `style` directly to DYNAMICTEXT — wrap in a CONTAINER
- [ ] Design property keys are case-sensitive — match `design-properties.json` exactly (`check -p` flags mismatches as MDL-WIDGET11/12)
- [ ] Compound/nested design properties (e.g. grouped Spacing) use a nested list: `'Spacing': ['margin-top': 'Large']`
- [ ] For CSS changes, run `docker build` then `docker reload --css`
- [ ] Use `describe styling` to verify changes after modification
- [ ] Check `docs/11-proposals/page-styling-support.md` for BSON format details