Theme & Styling — SCSS Workflow and Caveats
SkillMediaGuides 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.
No other account needed.
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, orthemesource/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:
- Default variables (
atlas_core) - Exclusion variables (disable Atlas components)
- Project custom variables (
theme/web/custom-variables.scss) - Bootstrap framework
- MXUI components
- Core styles (base, animations, spacing, flex)
- 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:
- 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. - The default theme's scope is
:rootminus 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:
- A token flip really does repaint Atlas. Adding
theme-darkto<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. - Your dark block must come after Mendix's.
_theme-dark.scsshardcodes stock Mendix blue at:root.theme-dark. Declare the same selector from a file imported later intheme/web/main.scss— same specificity, later wins — or your brand vanishes the moment the class appears. - 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-defaultis 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:
- The names collide with Atlas Core's, and Atlas Core feeds them to Sass colour
functions —
atlas_core/web/_variables.scss:20computesmix($brand-primary, #e7e7e9, 10%). Handingmix()avar()is a compile error, so the app stops building. - The worst offenders are not behind a variable at all:
_three-state-checkbox.scsswrites#264ae5andrgba(#264ae5, 0.4)directly, so overriding$brand-primarywould 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):
-
It is not a
<table>. DataGrid2 emitsrole="grid"/role="row"/role="gridcell"<div>s, soth, td { … }selectors match nothing. Atd { white-space: nowrap }intended to keep€ 5,200on 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"], nottr. -
Sizeis a flex weight, not pixels. On a column,ColumnWidth: manual, Size: 132does 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
styledirectly to DYNAMICTEXT — wrap in a CONTAINER - Design property keys are case-sensitive — match
design-properties.jsonexactly (check -pflags 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 buildthendocker reload --css - Use
describe stylingto verify changes after modification - Check
docs/11-proposals/page-styling-support.mdfor 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