Status: reference (living document). This is the consolidated summary of the
styling architecture that resulted from the SCSS token-migration (Parts One–Four).
It documents the three cooperating layers — design tokens, the framework
theme layer at :root, and the per-component CSS API — and the single read
chain that ties them together.
It is a summary; the authoring rules live in .claude/rules/scss.md
and the cross-cutting decisions in docs-internal/adr/.
The phase-by-phase migration working notes (specs/) were removed once the migration
landed; they remain in git history.
OutSystems UI styles every property through a four-hop chain. Each hop is a single, well-defined layer with one job, and each hop has a sensible default so the layer below is optional:
property → var(--osui-{component}-{prop}) ← Tier 4 · Component CSS API (per-instance override)
--osui-… → var(--{role}) ← Tier 3 · Framework theme layer (:root, app/theme override)
--{role} → $token-{…} ← Tier 2 · Design-token SCSS vars (compile-time)
$token-… → var(--token-{…}, <primitive>) ← Tier 1 · Design tokens at :root (runtime override surface)
Concrete example (the Card background), top to bottom:
.card {
--osui-card-background: var(--color-background-surface); // Tier 4 → Tier 3
}
background-color: var(--osui-card-background); // property → Tier 4// Tier 3 — src/scss/01-foundations/_root.scss
:root { --color-background-surface: #{$token-bg-surface-default}; } // → Tier 2// Tier 2 — src/scss/tokens/_variables.scss (generated)
$token-bg-surface-default: var(--token-bg-surface-default, var(--token-primitives-base-white, #ffffff)); // → Tier 1The fully-resolved CSS that ships is therefore:
.card { --osui-card-background: var(--color-background-surface); background-color: var(--osui-card-background); }
:root { --color-background-surface: var(--token-bg-surface-default, var(--token-primitives-base-white, #ffffff)); }Anyone can intercept at any hop: an app sets --token-bg-surface-default to
re-skin globally, a theme sets --color-background-surface to change just the
surface role, and a single component instance sets --osui-card-background to
override one card — none of them touch a component rule.
The bottom layer is the outsystems-design-tokens
package (a dependency, pinned in package.json). It is generated, never
hand-edited:
npx build.tokens --dest src/scss/tokens/ --prefix token # runs in prebuild / predevThis emits three files into src/scss/tokens/ (all gitignored):
| File | Contents | Layer it represents |
|---|---|---|
_root.scss |
--token-* custom properties at :root (raw hex/rem/px values) |
The runtime override surface |
_variables.scss |
$token-* SCSS vars, each = var(--token-*, <fallback>) |
The compile-time surface |
_utilities.scss |
token-backed maps for utility-class generation | — |
Tokens are themselves layered (primitives → semantics → component), and the fallback chain encodes that relationship:
$token-primitives-base-white → var(--token-primitives-base-white, #ffffff) · raw value
$token-bg-surface-default → var(--token-bg-surface-default, var(--token-primitives-base-white, #ffffff)) · semantic role
$token-elevation-1, $token-scale-600, $token-border-radius-200 … · component/scale tokens
A semantic token (bg-surface-default) falls back through a primitive
(primitives-base-white) which falls back to a literal — so a component is
correct even if no --token-* are defined at runtime.
$token-*SCSS vars are what component SCSS writes. They give compile-time typo-checking, IDE autocomplete, and a baked-in fallback.- In a CSS property value →
$token-*directly:padding: $token-scale-600; - In a CSS custom-property declaration → interpolate:
--osui-card-padding: #{$token-scale-600};
- In a CSS property value →
--token-*is the public theming surface, not something the bundle ships.
Verified subtlety: the compiled bundle (
dist/*.OutSystemsUI.css) does not emit the--token-*:rootblock — it relies entirely on thevar(--token-*, fallback)fallbacks (grep -c '--token-primitives-neutral-100:' dist/…css→0). The generatedtokens/_root.scssis the canonical definition / override set a DTE or app supplies at:rootto re-skin; the bundle renders correctly with or without it. Wiring: onlytokens/_variablesis@imported into the bundle (via00-abstract/_setup-global-vars.scss).
Retired — never reintroduce: --font-size-*, --shadow-*,
--border-size-*, and the helper functions get-background-color() /
get-text-color() / get-border-color(). Use $token-* instead.
File: src/scss/01-foundations/_root.scss (hand-authored, checked in).
This is OUI's stable, framework-owned theming contract — a set of role knobs
that sit between the design tokens and the components. It is deliberately
un-prefixed (no --os-) to stay backward-compatible with the historical
public theming surface (--color-primary, etc.). Each knob defaults through
a $token-*, so overriding the token still cascades.
:root {
// surfaces / text / borders
--color-background-surface: #{$token-bg-surface-default};
--color-text: #{$token-text-default};
--color-border: #{$token-border-default};
// brand / status / neutral (also read by TS GetColorValueFromColorType)
--color-primary: #{$token-semantics-primary-base};
--color-error: #{$token-semantics-danger-base};
--color-neutral-0 … --color-neutral-10: #{$token-primitives-neutral-*};
// radius — shape tier slots holding the active profile (soft defaults here);
// .shape-soft / .shape-round / .shape-rectangular remap the whole set, and a
// component reads the tier for its category (controls -> xs, surfaces -> sm, ...)
--border-radius-2xs … --border-radius-2xl: var(--border-radius-default, #{$token-shape-soft-*});
// legacy aliases, kept for TS GetBorderRadiusValueFromShapeType (no -default indirection)
--border-radius-none: #{$token-border-radius-0}; // 0px
--border-radius-soft: #{$token-shape-soft-xs}; // 8px
--border-radius-rounded: #{$token-border-radius-full}; // 999px · fixed pill/circle chrome
// --border-radius-softer is RETIRED — use the tier slot (md) instead
}What lives here:
| Group | Vars | Notes |
|---|---|---|
| Surfaces | --color-background-{body,surface,header,sidemenu,footer,login,input,…} |
|
| Text | --color-text, --color-text-{subtle,subtlest,disabled,inverse} |
|
| Borders | --color-border, --color-border-{subtle,subtlest,input,…} |
|
| Brand / status / neutral | --color-{primary,primary-hover,primary-selected,primary-active,secondary,error,warning,success,info}, --color-neutral-0..10 |
brand + neutrals are Color-entity records read by TS GetColorValueFromColorType; the four status roles are not entity records, just public O11 names |
| Palette | --color-{red,orange,yellow,lime,green,teal,cyan,blue,indigo,violet,grape,pink} |
the 12 Color-entity families; entity-bound, so the names cannot change. The light/dark variants (-lightest … -darkest) are deliberately not roles — their utility classes read $token-* directly |
| Focus ring | --color-focus-outer (translucent wash), --color-focus-inner (solid line on top) |
read by .has-accessible-features :focus |
| Radius | tier slots --border-radius-{2xs,xs,sm,md,lg,xl,2xl} + legacy aliases --border-radius-{none,soft,rounded} |
set --border-radius-default once at :root to re-radius every tier slot, or swap profile with .shape-soft / .shape-round / .shape-rectangular; the three aliases ↔ TS GetBorderRadiusValueFromShapeType. softer is retired |
| Spacing | --space-{none,xs,s,base,m,l,xl,xxl} |
token-backed onto $token-scale-*; also read at runtime by Gallery ItemsGap. Prefer $token-scale-* in new component SCSS |
Renaming any entity-bound name is a breaking change.
Helper.Dom.GetColorValueFromColorTypebuilds'--color-' + <Color entity value>at runtime (ProgressProgressColor/TrailColor). If the var is missing the helper falls through toreturn colorName, writing the literal entity name out as a colour — silently, with no build error. Same shape forGetBorderRadiusValueFromShapeType→--border-radius-{none,soft,rounded}and GalleryItemsGap→--space-*.
Status text/border tiers are intentionally absent from the theme layer:
components read $token-text-danger / $token-border-danger-default directly,
because neither has an entity record or a cross-component consumer.
Also in _root.scss but NOT part of the theme contract (app-layout
plumbing): layout sizes --header-size / --header-size-content / --side-menu-size /
--bottom-bar-size / --footer-height (dev's names, kept deliberately — a public surface
apps read, two of them written from TS via GlobalEnum.CSSVariables),
z-index --layer-global-* / --layer-local-*,
safe areas --os-safe-area-* (the one retained --os- prefix), and the
portaled-pattern --osui-*-layer vars (read off-DOM, so they must live at :root).
The old block of cross-component future-token candidates is gone (ROU-12975):
each entry either moved to the token that now exists, or was inlined at its call
site where no token does. Nothing in _root.scss is a placeholder any more.
Every visual component declares its own custom properties at its root
selector, defaulting either to a Tier-3 role (themeable props) or straight to a
$token-* (structural props), then reads them in property values:
.card {
// ─── Component CSS API ─────────────────────────────────────────────
--osui-card-background: var(--color-background-surface); // → theme role (themeable)
--osui-card-border-color: var(--osui-border-subtle);
--osui-card-border-width: #{$token-border-size-025}; // → token directly (structural)
--osui-card-border-radius: var(--border-radius-soft);
--osui-card-padding: #{$token-scale-600};
--osui-card-shadow: #{$token-elevation-1};
// ───────────────────────────────────────────────────────────────────
background-color: var(--osui-card-background);
border: var(--osui-card-border-width) solid var(--osui-card-border-color);
border-radius: var(--osui-card-border-radius);
box-shadow: var(--osui-card-shadow);
padding: var(--osui-card-padding);
}Rules:
- Naming:
--osui-{component}-{property}(Decision D11). - Property declarations must go through the
--osui-*var, never directly through$token-*/--color-*, so a consumer can override one instance (<div class="card" style="--osui-card-padding: 8px">) without touching tokens or the theme. - Route themeable props through the Tier-3 role (
--color-*,--border-radius-*) and structural/size props straight to$token-*— as Card does above (colour/radius → role, padding/border-width → token). This keeps the theme able to recolour without resizing. - Defaults live on the component root. A theme overrides variables only — it never edits a component rule.
Pattern SCSS (src/scripts/**/scss/) follows the same shape with osui--prefixed
class names; pattern-scoped knobs may also alias a global token or carry a literal
default (e.g. --osui-bottom-sheet-max-height: calc(100vh - 54px)). Portaled
patterns additionally declare their layer var in _root.scss.
A theme is entirely CSS-custom-property overrides scoped under a single class
(e.g. <body class="theme-x">). It overrides Tier-3 role knobs (--color-*,
--border-radius-*, …) and/or the underlying Tier-1 --token-*. It touches no
component rule, no $token-* value, and no pre-existing --osui-* default.
/* Re-skin one role across the whole framework */
:root { --color-primary: #6d28d9; }
/* Re-skin globally by overriding a token (cascades through every role + component) */
:root { --token-bg-surface-default: #1b1b1b; }
/* Round every corner at once */
:root { --border-radius-default: 12px; }
/* Override a single component instance */
.card.is-promo { --osui-card-shadow: var(--osui-elevation-overlay); }Invariant: if a theme ever needs to touch a component rule, that is a leak in that component's CSS API — fix it in the component (add/route the missing
--osui-*knob), not in the theme.
The dark theme is generated, not authored. src/scss/tokens/_theme-dark.scss
is written by npm run build:tokens from the design tokens' dark mode (the whole
src/scss/tokens/ directory is generated and gitignored). It re-maps the ~447
--token-* values that differ in dark and ends with its own
.os-dark-theme { @include token-theme-dark; }, so importing the file is all that
dark mode requires.
Two classes govern dark appearance:
.os-dark-theme— applies the actual dark token overrides. Add it to<html>(document.documentElement) to switch to dark; remove it for the default light palette. Toggled via theSetDarkThemeclient action..os-dark-mode— a signal-only class that reflects the user's system preference (prefers-color-scheme: dark). It has no CSS effect — the framework attaches no rules to it. Automatically added to<html>when the OS is in dark mode, removed when it switches to light. Customers can use it as a styling hook in their own CSS.
It is registered as a normal partial in
gulp/ProjectSpecs/ScssStructure/Root.js — the CSS-variables section, since that
is what it is. That placement matters twice over. The import was previously
hand-added straight into O11/ODC.OutSystemsUI.scss, which every build
regenerates — so the dark token values were silently dropped from the bundle on
any rebuild. And it must stay after 01-foundations/root; see below.
There is no light theme block. .os-dark-theme is the only selector in the
bundle that declares --token-*; the light values are the hardcoded fallbacks
baked into every $token-* expansion ($token-text-default →
var(--token-text-default, #101213)). Light is "no declaration".
A consequence that is easy to get backwards: the dark block's source position is
currently a no-op. :root declares only the 44 --color-* role knobs (plus
--osui-*); tokens/_theme-dark.scss declares only --token-*. They never
declare the same property, so they cannot compete, and custom-property
substitution happens per element at computed-value time rather than by source
order. Moving dark earlier or later changes nothing today.
It stops being a no-op the moment light --token-* values are emitted at
:root — i.e. if build:tokens is ever run with --root true. :root and
.os-dark-theme are both specificity 0-1-0, so at that point later wins, and
dark placed ahead of root would silently lose to light. Hence: after root.
What decides whether dark reaches a component is not source order but which
element carries the class, because that is where --color-* gets substituted.
A var() inside a custom-property declaration is substituted using the computed
custom properties of the element the declaration applies to — and --color-* is
declared at :root, i.e. on <html>:
.os-dark-theme on |
--token-* readers |
--color-* readers (~488 reads) |
|---|---|---|
<body> |
dark ✅ | stay light — the roles already resolved to their light fallbacks on <html> and inherit down as literals |
<html> ← what we do |
dark ✅ | dark ✅ — the tokens are defined on the very element the roles resolve on |
So the class is applied to document.documentElement. 43 of the 44 --color-*
knobs follow the theme this way; the exception is --color-focus-outer, a
deliberate hardcoded yellow. This is what replaced the deleted theme's
--color-* role bridge — the bridge existed only to compensate for a
<body>-level scope. .os-dark-theme is an element-agnostic class selector, so
nothing in the CSS had to change; only the element.
What still will not follow the theme. 17 of the 21 --osui-* defaults
declared at :root are hardcoded literals rather than token reads — each already
carries a // future: --token-* note in _root.scss. Also the layout sizes and
--layer-*, but those are layout plumbing, not colour (§Framework theme layer).
Routing the remaining literals onto tokens is Phase E work.
What was removed. The hand-written 01-foundations/_theme-dark.scss is gone.
It carried two things beyond the palette, and both went with it:
- The
--color-*role bridge. Because--color-*is substituted at:root, a--token-*override on<body>does not reach a component that reads--color-*; the bridge re-declared those roles at the dark scope to force a re-resolve. Without it, dark reaches only what reads--token-*/$token-*directly. Components still routing through--color-*keep their light values under.os-dark-theme. - The "KNOWN CSS-API LEAKS" block (
.header,.app-menu-*,label,::placeholder, validation text) — raw component rules for components with no--osui-*knob. Their removal makes the theme invariant above structurally true: the shipped theme is nothing but variable overrides.
| Path | Role | Source |
|---|---|---|
src/scss/tokens/_root.scss |
Tier 1 — --token-* at :root (override surface) |
generated, gitignored |
src/scss/tokens/_variables.scss |
Tier 1 — $token-* = var(--token-*, fallback) |
generated, gitignored |
src/scss/00-abstract/_setup-global-vars.scss |
@imports tokens/variables; token bridges; utility maps |
checked in |
src/scss/01-foundations/_root.scss |
Tier 3 — framework theme layer + layout plumbing | checked in |
src/scss/04-patterns/**, src/scripts/**/scss/** |
Tier 4 — component CSS APIs | checked in |
src/scss/{O11,ODC}.OutSystemsUI.scss |
generated entry files | never hand-edit (regen on every build) |
dist/{O11,ODC}.OutSystemsUI.css |
compiled bundle (ships fallbacks, not --token-* root) |
build output |
Build order (per generated entry): 00-abstract/setup-global-vars (pulls in
$token-*) → 00-abstract/mixins → 01-foundations/root (theme layer) →
foundations → layout → widgets → patterns → utilities.
npm run build:tokens regenerates Tier 1; npm run tokens:update bumps the
package and regenerates.
When writing any SCSS line, walk down the chain only as far as you need:
- Reading a themeable colour/radius? → use the Tier-3 role:
var(--color-*),var(--border-radius-*). - Reading a structural size/space/elevation/border? → use
$token-*directly. - Exposing it on a component? → declare a
--osui-{component}-{prop}that defaults to (1) or (2), and have the property read the--osui-*var. - Need a value with no token yet? → keep it local: inline it in the property value, or declare an
--osui-{component}-{prop}on the component root. Do not add a global placeholder to_root.scss— that block existed and was retired in ROU-12975, because a global with one reader is harder to find than a literal at its call site. If the value is genuinely cross-component, file the gap upstream inoutsystems-design-tokens.
Red flags (see .claude/rules/scss.md §14): hardcoded hex/rem/px where a
$token-* exists; reintroducing retired --font-size-*/--shadow-*/--border-size-*;
get-*-color() calls; a property reading $token-*/--color-* directly instead
of via its --osui-*; a theme touching a component rule; hand-edits to the
generated entry files.