Theme & Styling — SCSS Workflow and Caveats

SkillMedia

Guides your agent through writing and debugging SCSS themes, applying designs, and switching light/dark styles.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Theme & Styling — SCSS Workflow and Caveats skill

About this capability

The SCSS workflow and its traps, where styling actually compiles, custom-variables.scss, themesource directories, hot reload, design-property errors, and the mxcli theme commands (apply, create --from a design, switchable sets, light/dark). Use when writing or debugging SCSS, when applying or build

What this skill tells your AI

The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/theme-styling/SKILL.md and read by ahel’s review.

When to Use This Skill

Use this skill when working with:

  • SCSS compilation, custom-variables.scss, or themesource/ directories
  • Applying or building a theme (mxcli theme apply | create | switcher)
  • Giving an app a brand palette, or turning design tokens into a theme
  • CSS hot-reload during Docker development
  • Debugging styling crashes or design property issues

Do not hand-write a theme scaffold. mxcli theme apply writes a complete, verified one; mxcli theme create <name> --from <design-file> makes a theme the project owns and seeds its palette from --mxt-* declarations. See "A theme of your own" below. Hand-editing inside a generated block works exactly once — the digest fence refuses it on the next apply.

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:

@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:

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:

: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

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.

A JavaScript action's parameter arrives lowercased

Not theme-specific, but the theme switcher is where it bit. mxbuild lowers the first letter of a parameter when it generates the action wrapper:

create or modify javascript action Mod.SetAppSkin(Skin: String) ...
// javascriptsource/mod/actions/SetAppSkin.js — regenerated on every build
export async function SetAppSkin(skin) {      // ← lowercased

So the body must read skin, not Skin. Using the modelled spelling is a ReferenceError on the first click, and nothing catches it before then: mx check reports 0 errors because the action is well-formed and the body is opaque user code, mxcli check says nothing about JavaScript, and the file carries Only the following code will be retained — it is rewritten on every build, so it cannot be patched in place. The fix has to go back through MDL.

Model the parameter capitalised, as Mendix does; read it lowercased.

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:

# 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:

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:

-- 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:

    .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:

    .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:

-- 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:

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

Default styling + runtime theme switching (mxcli theme list/show/create/apply/remove/switcher, mxcli new --theme)

three embedded themes (signal light-first, ledger light-first, console dark-first), each a palette in theme/web/custom-variables.scss + a shared Atlas wiring partial + a theme partial imported from theme/web/main.scss (which compiles last), plus vendored fonts. No model changes, so it hot-applies under run --local --watch and cannot affect a build. Generated regions are digest-fenced: a block carrying local edits is refused rather than overwritten. Applying a theme removes the previous one. --variant auto (default) ships both palettes — the app follows prefers-color-scheme before first paint and honours a theme-light/theme-dark class on <html>; light/dark bakes one. theme switcher install is the only part that writes to the model (JS actions + a nanoflow for a toggle button). A project can add its own themes under theme/mxcli-themes/<name>/ (committed, not compiled); theme create <name> [--from <theme|design-file>] scaffolds one from an existing theme, renaming the identifiers built from the name and optionally seeding the palette from --mxt-* declarations in a design artifact. A local theme shadows a built-in of the same name. Package: cmd/mxcli/theme/. See docs/11-proposals/PROPOSAL_default_styling.md

Signals

GitHub stars
122
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
theme-styling
Source
github.com/mendixlabs/mxcli