15 — Theming: tokens, SCSS, your own theme
For developers. How the styling is organised, and how to build a theme on top of yavi_core.
No build step
SCSS is compiled server-side by scssphp, which comes with Bootstrap Package. There is no npm, no webpack, no dist/. Edit the SCSS, flush the cache, reload:
ddev exec vendor/bin/typo3 cache:flushThe compiled file lands in typo3temp/assets/bootstrappackage/css/theme-<hash>.css. The hash is derived from the source, so a stale file is never served — but only after the cache has been flushed.
Structure and Skin
Core's SCSS is split along one line:
| Folder | Contains | A theme… |
|---|---|---|
Scss/Structure/ | Geometry, layout, behaviour — what makes a card a card | rarely touches it |
Scss/Skin/ | Colour, shadow, border, decoration — what makes it look like this theme | replaces it per component family |
A theme replaces a whole skin file for a component family instead of overriding individual declarations. There are no cascade layers; the theme's file simply comes later.
packages/yavi-lucerne/Resources/Public/Scss/
├── theme.scss entry point
├── _variables.scss the theme's own tokens
├── _structure.scss generated: the list of core structure partials
├── _skin.scss generated: the list of skin partials, core's or the theme's
├── _custom.scss free space for this theme
└── Skin/ the skin files this theme owns
├── audioplayer.scss
├── buttons.scss
├── cards.scss
├── carousel.scss
├── cta.scss
└── menu.scsstheme.scss is short on purpose:
@import "EXT:yavi_core/Resources/Public/Scss/structure";
@import "variables";
@import "structure";
@import "skin";
@import "custom";Keeping the lists in sync
_structure.scss and _skin.scss are generated. When core gains a partial, regenerate every theme's lists:
ddev exec php packages/yavi-core/Build/sync-theme-scss.php
ddev exec php packages/yavi-core/Build/sync-theme-scss.php --check # writes nothing, fails on driftThe --check variant runs in the test suite, so a forgotten partial fails the build instead of silently disappearing from one theme.
The token system
Every configurable value is a CSS custom property with exactly one owner.
// 1. yavi_core/_variables-defaults.scss — the theme-independent default
--card-radius: calc(12px * var(--theme-radius-scale, 1));
// 2. the theme's _variables.scss — this theme's value
--card-radius: 25px;
// 3. site settings — emitted into the page head by ThemeCssVariablesRenderer
--card-radius: calc(18px * var(--theme-radius-scale, 1));Rules that follow from this:
- Defaults live in core, overrides in the theme. A theme declares only what differs — but declares it completely, never as "the other value plus 2px".
- A token has one meaning.
--card-radiusis the card corner knob; a second one that also affects cards would make both unreliable. - Inputs and outputs are separate tokens. Where a token is both configured and computed, the input is named
…-baseand the resolved value keeps the plain name. Spacing works this way: you set--section-spacing-md-base, the stylesheet reads--section-spacing-md.
Fallback chains
A token that may be overridden from several layers reads them in order of authority:
--megamenu-link-border-radius:
var(--megamenu-link-radius, /* inline override — absolute px */
var(--nav-menu-radius, /* backend field */
calc(8px * var(--theme-radius-scale-mega, var(--theme-radius-scale, 1))))); /* theme */Read it as: whoever is more specific wins, and an unset token falls through.
Literal, then derived
Brand tokens are declared twice:
--nav-link-active-bg: #{derive($secondary, 1.0674, 0.7915, 6.13)};
--nav-link-active-bg: oklch(from var(--theme-secondary) calc(l * 1.0674) …);The first line is a static SCSS-computed colour for browsers without relative colour syntax; the second overwrites it where oklch(from …) is supported. Both lines are needed — the first is not dead code.
Building a new theme
- Copy the skeleton. Take
yavi_lucerneas the template:composer.json,ext_emconf.php,Configuration/Sets/Full/,Resources/Public/Scss/. - Set the Composer name and extension key, e.g.
webagentur-yahya/yavi-zurich/yavi_zurich. - Declare the Set:
``yaml # Configuration/Sets/Full/config.yaml name: yavi-zurich/full label: 'Yavi Zurich' dependencies: - yavi-core/full ``
- Give it its own product code in
Configuration/Sets/Full/settings.yaml:
``yaml licence: product: yavi-zurich ``
Miss this and the licence check fails exactly like a wrong key.
- Declare the design in
settings.yaml— colours, fonts, heights, radii. Completely, not as a deviation from Lucerne. - Own the skins you need in
Scss/Skin/, then runsync-theme-scss.php. - Shape the navigation through the nav tokens plus
Skin/navigation.scss. The eight variants themselves stay in core: they are imported through an interpolated path and cannot be replaced per theme.
Overriding templates
Template, partial and layout root paths are configured per Set. A theme adds its own paths at a higher index; TYPO3 falls back to core's file when the theme does not provide one. Copy only the file you actually change — every copied template is one you have to maintain when core's changes.
Things worth knowing before you debug for an hour
- A CSS custom property is substituted at the element that declares it.
:root { --bs-card-border-radius: var(--card-radius) }is resolved once, at:root— a per-card override of--card-radiuswill not reach it. - A gradient token cannot be a colour stop inside another gradient. It is already a full
linear-gradient(…)value. - Bootstrap can be overridden by source order, since both sets of rules have the same specificity. That is why the order of the imports in
theme.scssmatters. - Bootstrap Package's negative frame margin eats spacing when a utility class overrides the frame's
padding-top. Check the frame before adding another margin.