Front-end themes#
A theme is the site's whole visual package — layouts, templates, SCSS and JavaScript
together. www/themes/, with the selection logic in app/Core/Theme/.
Two follow-up chapters expand on its parts: SCSS structure and Design system and tokens.
Structure#
www/themes/
admin/default/desktop/ the administration, default theme
admin/template1/desktop/ the administration, second theme (see Administration themes)
frontend/template1/desktop/ one of 18 front-end themes (template1–template18)
layouts/ the layouts and their parts
assets/{scss,css,js,dist,imgs}
assets/skins/<name>/ colour variants (skins), see below
ui-demo/ the component catalogue
webpack.config.js
frontend/template2/desktop/ another theme, switched from the administration
A theme has device variants: DefaultThemeResolver picks mobile, tablet or
desktop from DeviceDetector — and when the variant does not exist in the theme, it
falls back to desktop.
Which theme is active#
The theme is not chosen in code but by a setting in the database: theme.frontend
(the name) and theme.frontend.path (the relative path, computed). It is no longer
edited by hand today — an administrator picks it together with the skin in
Nastavení → Vzhled webu (ThemeSettingPresenter), see
Site appearance. There are 18 themes today,
template1–template18 (counted with ls www/themes/frontend/*/theme.json | wc -l).
The theme in the settings applies to every site on the same database
The settings table has no column for the environment. If several installations run
on the same database (say a development one and a live one), changing
theme.frontend.path would not merely repaint the live site — if that installation does
not have the new theme's files, so every front-end page would break
on missing layouts. This risk applies to the "Vzhled webu" screen too — it is just a
different interface over the same settings keys.
Locally the theme is switched by an environment variable, not by the setting
Without the variable it is a no-op — production never sets it. A non-existent directory is ignored so that a typo cannot take the site down.Switching the appearance for one visitor#
Besides the database setting, DefaultThemeResolver can override the theme and the skin
for the current request only — the database is not changed. The choice is looked up
in this order and the first one found wins:
| Order | Theme | Skin | Note |
|---|---|---|---|
| 1 | ?_theme=<directory> |
?_skin=<name> |
stored in a session cookie, so it survives further clicks |
| 2 | cookie cms_theme_override |
cookie cms_skin_override |
disappears when the browser closes |
| 3 | env CMS_THEME_OVERRIDE |
env CMS_SKIN_OVERRIDE |
the default choice of the whole server run |
| — | setting theme.frontend |
setting theme.frontend.skin |
when none of the above applies |
The value off, reset, default, none (or an empty one) deletes the cookie and falls
back to the env choice, and only when env is not set to the database. The URL ranks above
env on purpose: a server started with CMS_THEME_OVERRIDE would otherwise silently ignore
?_theme=.
Switching through the URL and cookie is on for every visitor, because it is part of the
CMS showcase (the ThemeSwitcher appearance switcher). It is turned off by environment
variables, each separately:
| Variable | Turns off | Does not turn off |
|---|---|---|
CMS_THEME_SWITCH=0 |
?_theme= and the theme cookie |
skins |
CMS_SKIN_SWITCH=0 |
?_skin= and the skin cookie |
themes |
The CMS_*_OVERRIDE env still applies with switching turned off. To lock the appearance
completely, set both *_SWITCH variables to 0.
You can only switch to a theme that has desktop/
The offer (availableThemes()) takes only directories with a desktop/ variant,
because without it no layout would be found. A name outside [a-z0-9_-] or a
non-existent directory is ignored and the theme from the settings stays.
Switching applies to the front-end theme only
The resolver is one service shared with the administration. If the admin theme were
switched too, the administration would look for layouts in the front-end theme,
findLayoutTemplateFile() would silently return null and the whole admin would render
as bare content without <html>, CSS and JS, with nothing in the log. The branch check
therefore sits in applyLocalOverride() before ?_theme= is even read. The
administration has its own parameters and cookies, see
Administration themes.
The appearance switcher on the site#
The ThemeSwitcher component offers the visitor the templates and their skins as colour dots.
A dot selects a pair of template + skin; the first dot of a template is always the base look
without a skin. The choice is stored only in the cookie of whoever clicked; the site setting does
not change.
| Situation | What the component offers |
|---|---|
both CMS_THEME_SWITCH=0 and CMS_SKIN_SWITCH=0 |
nothing, it does not render |
only CMS_THEME_SWITCH=0 |
only the active template and its skins; other templates would be dead links because the resolver ignores ?_theme= |
| fewer than two dots in total | nothing: an empty panel would only occupy a screen corner |
colour with duplicateColor |
the dot is dropped, except for the currently active skin (otherwise the check mark would have nothing to attach to) |
A dot's link is the current URL with ?_theme=…&_skin=…, so the visitor stays on the same page.
It is not a signal (handle…): that would need a CSRF token and would change with every redraw,
so the link could be neither shared nor cached.
A choice equal to the site setting must send off
The dot of the default template sends _theme=off and the dot of the default skin
_skin=off, which cancels the choice. Sending the name would store a hard cookie and a later
appearance change in the administration would never reach that visitor. The skin is compared
with the setting only for the template from the setting; a same-named skin of another
template does not send off.
_theme and _skin always go together
The base-look dot must cancel the skin (_skin=off). Otherwise, after a template switch, the
skin from the previous choice would stay although it does not belong to the new template.
The mount belongs outside {snippet page}
{control frontBaseThemeSwitcher} sits in layouts/parts/shared/bottom.latte of every
template, next to the cookie bar. Inside the snippet an AJAX page redraw would destroy it.
Administration themes#
The administration has two themes in www/themes/admin/: default and template1. Both
have the same layouts and identical JavaScript (desktop/assets/js is the same tree in
both); they differ only in SCSS:
| Theme | Where its look lives | Build |
|---|---|---|
default |
the shared base; styles of the administration's functional elements (breadcrumb, row actions, bulk action panel, form state, list and detail) in assets/scss/modules/system/_admin_features.scss |
npm run build:admin |
template1 |
the shared base, and the theme's deviations only in assets/scss/custom/ (_admin_data_grid, _admin_edit_layout, _admin_form_facelift, …) |
npm run build:admin:t1 |
Both commands run from the project root (package.json) and build the theme's JS (webpack)
and CSS (sass).
Make an admin script change in both themes
bin/theme-js-sync.php watches front-end themes only. A script changed only in
admin/default is missing in template1, and the element silently does not work there
even though the markup is the same.
A template1 deviation belongs in custom/
The other SCSS folders are the shared base. A rule meant only for template1 written
elsewhere changes the default theme too.
A component template for an admin theme#
A component looks up the template for a specific admin theme the same way as on the front
end: a file in Templates/Template1/default/… wins over Templates/Default/…. This also
applies to single parts through resolveThemeTemplatePath(), see
Components. Both admin themes currently share
the templates from Templates/Default/ (row actions, grid footer, form action bar); only the
styles differ.
The user's own admin theme#
The default admin theme and skin are the settings theme.admin and theme.admin.skin
(edited in Admin settings). Besides that, a logged-in user picks their own theme with the
switcher in the admin header (ThemeSwitcherComponent, {control adminSystemThemeSwitcher}
in layouts/parts/shared/_header.latte). DefaultThemeResolver::applyAdminOverride()
processes the choice:
| Parameter | What it does |
|---|---|
?_admin_theme=<directory> |
stores the theme in the cms_admin_theme cookie (one year) and deletes the skin cookie |
?_admin_skin=<name> |
stores the skin in the cms_admin_skin cookie |
?_admin_theme=reset (off, none, empty) |
deletes both cookies, the settings apply again |
?_admin_skin=reset |
deletes only the skin cookie |
After storing, the resolver redirects (on GET) to the same address without the parameters.
A theme is accepted only when ThemeCatalog::exists() finds a directory with theme.json in
www/themes/admin/, a skin only when the theme has it; anything else is ignored, so a
hand-crafted cookie leads nowhere else. The switcher links are plain GETs without a signal,
so there is nothing to forge.
An existing theme wins over a reset word
The base admin theme is called default, which is also a reset word. The resolver
therefore first checks whether a theme of that name exists: ?_admin_theme=default
switches to the default theme and stores it in the cookie, it does not return to the
settings. The way back to the settings is ?_admin_theme=reset.
Paths into the theme from templates#
A template must not be tied to the theme's name. Two functions are available (they work as filters too) plus one test:
| Notation | What it does |
|---|---|
{=themeAsset('assets/dist/bundle.js')} |
a path to a file inside the active theme |
{=themeUrl('…')} |
an absolute URL into the active theme |
{if $x is isTheme('template2')} |
branching by theme |
They are registered by app/Core/Latte/Extensions/ThemeLatteExtension.php.
A hardcoded path stops working when the theme is switched
And nobody finds out until the theme is switched — typically at a customer with their
own template. In template1 themeAsset() is used in 30 places.
An asset belongs with a ?v= timestamp
Otherwise the browser keeps the old file in its cache and the change "does not appear".
Procedure: changing the running front end's appearance#
- Check which branch the build actually uses.
themes/holds plenty of dead branches —desktop_bck,scss-backup,scss_2,js2,ui-demo_old. Editing a dead branch is the most common waste of time in this part of the system. - The SCSS belongs in
template1/desktop/assets/scss/; the build:
- The JavaScript goes into
assets/js/{components,behaviors,pages}/; the build:
- Look at the result in the component catalogue (
ui-demo/) — it is the only place showing most of the elements at once. - Click through the mobile variant too, if the theme has one.
npx sass is NOT the same as sass
It is a different compiler version and produces a different output. Use sass.
Without a build an SCSS change does not appear — not even after a hard reload
The application reads css/style.css, not scss/. On top of that, the browser holds
the old version in its cache.
Procedure: creating a new theme#
- Copy the whole base of an existing theme; keep your differences in a layer of their
own (
custom/), not scattered across the tree. - Verify it with
diff -ragainst the source theme — it should show only your layer, the component catalogue and the build outputs. Anything else is an unintended divergence that will drift the next time the source theme is fixed. - Do not switch it on in the settings until the files are deployed everywhere (see the
warning above). Use
CMS_THEME_OVERRIDEfor development. - Go through the device variants — a missing
mobilefalls back todesktop, which is fine only when the desktop layout is responsive.
How to add a theme#
A new theme is a directory www/themes/frontend/<name>/ with the same structure as
the others (desktop/, optionally mobile//tablet/). Next to it belongs a
theme.json manifest:
{
"label": "Přírodní kosmetika",
"description": "Jemný e-shop s přírodní kosmetikou",
"color": "#c9785a",
"group": "eshop",
"subpage": "/eshop"
}
| Key | Meaning |
|---|---|
label |
the name shown in the theme list |
description |
a short description under the name |
color |
the swatch colour / fallback tile shown when there is no screenshot |
group |
the group slug, see below |
subpage |
the subpage address for the theme gallery; only bin/theme-previews.php reads it |
The order in the offer is given by the directory name, naturally (template2 before
template10), not by the manifest — ThemeCatalog does not read an order key. The
manifest texts are deliberately not translation keys: a missing translation takes the page
down, a missing manifest only loses a nice name.
Without theme.json the theme is missing from the administration
The visitor switcher offers every directory with desktop/, under the directory name
when there is no manifest. The Vzhled webu screen and the preview generator,
however, take only directories that contain theme.json — a theme without it cannot be
selected in the administration.
group is a slug from the dictionary App\Core\Theme\ThemeCatalog::GROUPS
(eshop, classifieds, company, magazine, other), not free text. The group
name shown in the administration is a CMS text and must be translated into
cs/sk/en — free text from the manifest could never make it into the translations.
An unknown or missing slug falls back to other, so a typo in a manifest can never
break the screen.
Previews for the administration#
The Vzhled webu screen needs a home-page screenshot for every theme and its skin. It is generated with:
The script photographs the home page of the locally running site at the fixed
address 127.0.0.1:8000 (for example php -S 127.0.0.1:8000 -t www) with headless Chrome
and saves:
<theme>/preview.webp— the base colour,<theme>/previews/<skin>.webp— every other colour variant.
The format is WebP, not PNG — the same set of previews was 9.2 MB in PNG and 1.4 MB in WebP.
Do not run the generator while someone is working on the site
It photographs the site exactly as it is at that moment — work in progress on files or a theme switched in the administration ends up straight in the preview. The failure is not caught automatically: the script only checks that Chrome finished and that the file was created, not that the snapshot did not capture an error page. This has happened, and was only noticed by eye.
Regenerate the previews after reworking a theme
They are not generated automatically by the build or by deployment. Without running the script by hand, the administration keeps showing the old appearance.
A missing preview is not an error — the theme's or skin's card renders fine in the
administration without an image, showing a colour tile from color in the
manifest instead.
Syncing the script between themes#
The frontend scripts (www/themes/frontend/templateN/desktop/assets/js) are a separate
copy of the same tree in every template. A fix to a component in one template therefore
does not reach the others by itself. Diverged copies are watched and unified by
bin/theme-js-sync.php:
php bin/theme-js-sync.php # only reports differences (exit 1 = diverged)
php bin/theme-js-sync.php --canonical=template1 # the same with another canonical template
php bin/theme-js-sync.php --canonical=template1 --apply # overwrites diverged copies with the canonical one
The canonical template is the one whose copy counts as correct. Without --canonical
it is template2 (the default value in the script's source), so always distribute a
changed component script with an explicit --canonical= naming the template where you
fixed it. Files that are meant to differ (today pages/homepage.js) are listed in the
EXPECTED_DIFFERENT constant and --apply does not overwrite them. The script does not
handle SCSS or images.
Skins#
A skin is a colour variant of one theme: the directory
<theme>/desktop/assets/skins/<name>/ with a skin.css file containing only CSS variable
overrides (:root { --color-primary: …; }). It is linked after style.css, so its
tokens win; it changes neither layouts nor components. The path for templates comes from
ThemeHelper::getSkinPath().
Next to skin.css there may be a skin.json:
| Key | Meaning | When missing |
|---|---|---|
label, description |
name and description in the offer | the directory name, no description |
color |
the dot colour in the switcher | the last --color-primary from skin.css |
order |
skin order in the offer, label breaks ties |
1000, i.e. last |
hidden |
true leaves the skin out of the offers |
the skin is offered |
The skin list is built by SkinCatalog, which counts only a directory with skin.css as a
skin. It does not depend on the resolver, because the administration needs it too and there
the resolver holds the admin theme; the theme path is therefore passed in as a parameter.
Procedure: adding a skin
- Create
desktop/assets/skins/<name>/skin.cssand override only tokens in it. Do not copy the skin for tablet and mobile —ThemeHelper::getAssetPath()falls back to the desktop file. - Add a
skin.jsonwithlabel,colorandorder. Withoutcolorthe dot colour is looked up only as a hex value of--color-primaryinskin.css; any other notation (rgb(),var()) leaves the dot empty. - Check it via
?_skin=<name>and in the administration under Nastavení → Vzhled webu. - Regenerate the previews (
php bin/theme-previews.php <theme>).
A missing skin does not break the site
Unlike a theme, a skin is a safe choice: the resolver checks it only against the
resulting theme (after a theme switch) and a name that has no directory with skin.css
in that theme (Theme::skinExists(), the same rule as SkinCatalog) comes back as
NULL, i.e. the base colours, not a link to a 404. A half-made directory without
skin.css is therefore switched on neither from the settings nor through ?_skin=.
A hidden skin still works
It only disappears from the offer. A direct ?_skin=<name> link or
theme.frontend.skin in the settings still turn it on. That is right for a colour kept
as a backup that should not be selectable; a skin that must not work has to be deleted.
A dot with the same colour is only marked
ThemeCatalog::variants() returns the base appearance followed by the skins; a variant
whose colour is already in the list gets duplicateColor: true (the base appearance
wins, then the lower order). It is not dropped: the switcher must show the currently
active skin even with a duplicate colour, otherwise the check mark would have nothing to
sit on. Hiding is the caller's decision.
Structured data in the head#
parts/shared/head/schema-org.latte (in each of the 18 themes) writes a JSON-LD of type
WebSite into the head: @id and url from $baseUrl, name from
$ogData->getSiteName(), description from $metaData->getDescription() and inLanguage
from $ogData->getLocale().
It used to write an unevaluated text {latte:raw json_encode(…)} into the page, because
the block had n:syntax="off" — a search engine did not see valid data. Now the JSON is
generated by the expression
{=json_encode([...], JSON_UNESCAPED_UNICODE + JSON_UNESCAPED_SLASHES + JSON_HEX_TAG)|noescape}
inside <script type="application/ld+json">.
In a Latte print tag | starts a filter
The json_encode flags are therefore added (A + B + C), not joined with |.
JSON_HEX_TAG makes sure a < in the site name or description does not close the
<script> element.
When adding another theme copy this version
Do not carry over the older shape with n:syntax="off". Other types (Organization,
BreadcrumbList) are not there yet; the file holds a TODO for dynamic generation.
Procedure: verify the structured data#
- View the page source and find
<script type="application/ld+json">. Inside must be valid JSON — no unevaluated{=…}or{latte:raw …}. - From the command line:
curl -s https://your-domain.com/ | grep -o '<script type="application/ld+json">.*</script>'. The home page returns, for example, a graph with a singleWebSitenode (@idis…/#website,inLanguagefor examplecs-CZ). - For a check by a search engine, paste the page address into Google's Rich Results Test.
An empty description is fine
When the page has an empty meta description, it is
"description": "". It does not break the structure; fill in the page description in the
administration.
Finding the action template#
DefaultThemeResolver::formatTemplateFiles() returns Nette the candidates for the action
template in this order:
- the theme, active variant:
<variant>/layouts/modules/<module>/presenters/<presenter>/<action>.latte(presenter name parts with a lower-case first letter, e.g.front/system/presenters/home/default.latte), - the same in the
desktopvariant, Templates/<Presenter>/<view>.lattenext to the presenter inapp/.
The candidate list must not be empty
When nothing is found, Nette takes $files[0] to name the missing template in the 404
message. Over an empty array that is a TypeError in strtr(), i.e. HTTP 500
instead of 404 for every missing front-end template. The method therefore only sorts
the candidates by existence and must not start filtering them when changed.
Finding the layout#
ThemeInitTrait::formatLayoutTemplateFiles() takes the layout name from the page
(cms_system_pages.layout, for a logged-in user loggedLayout first, @layout.latte when
empty) and looks it up in <variant>/layouts/ and then in desktop/layouts/ of the active theme.
The layout column is one for all themes, so a page set to @text_layout_two.latte wants that
file even in a theme that lacks it. Hence:
| Situation | What happens | Log |
|---|---|---|
| the theme has the layout | it is used | — |
| the theme lacks the layout | the fallback @layout_one.latte is added |
warning: "Layout '…' není v tématu …" |
@layout_one.latte is missing too (or the page asks for it directly) |
the page renders without a layout, HTTP 200 | error: "Náhradní layout … CHYBÍ" |
All 18 themes have @layout_one.latte (one column, shared header and footer) today (counted
with ls www/themes/frontend/*/*/layouts/@layout_one.latte). The fallback is not
@layout.latte: that one is a bare Nette skeleton without header, footer or styles.
A new theme must have @layout_one.latte
Without it, no page with a layout the theme does not know has anywhere to fall back to.
Such a page returns HTTP 200 without header, footer and styles, so uptime monitoring does not
notice it; only an error line in the log shows it.
Error page#
The front-end error page (404, 403, 410 and other 4xx) is drawn in the active theme and the
request language by Front:System:Error (ErrorPresenter). It is reached in two ways: by a
forward from Error4xxPresenter when a front presenter or the router ends with
BadRequestException, and directly by the route without a page whitelist, where for example
/?slug=missing lands.
The page does not exist in the database, but the layout needs a page entity. loadPageEntity()
therefore returns a non-persisted Page with the layout @layout_one.latte. renderDefault()
sets the HTTP code itself (the direct route would not, and a missing address would return 200),
startup() resets the persistent slug, and a forged language in the address falls back to the
main website language.
The error page layout is always @layout_one.latte
If the theme lacks it, the error page comes out without header, footer and styles. Custom code from the admin is not rendered on it (the page has no id, see below).
A server error (500) is not drawn in the theme — neither the theme nor the database
may be working at that moment. It is a standalone simple page
app/UI/Error/Error5xx/500.phtml in Czech, English and Slovak: the language comes from
the address prefix (/en/, /sk/), then the browser's Accept-Language header,
otherwise Czech. It links to the home page of that language version. Neither the look nor
the texts are set in the administration; to change them, edit this file directly (no DI
and no translator, plain PHP and HTML only).
Custom code from the admin#
The library Web → Pages → Custom code inserts CSS, JS or HTML into pages (tracking codes,
chat, domain verification). A page runs exactly the codes assigned to it (relation
cms_system_custom_asset_page_relations); there is no site-wide scope, "on all pages" in the
admin is only an action that creates the relations to all pages.
The theme calls the frontSystemCustomAssets component once per placement:
| Call | Where it is in all 18 themes |
|---|---|
{control frontSystemCustomAssets 'head'} |
layouts/parts/shared/head.latte, inside <head> |
{control frontSystemCustomAssets 'body_start'} |
in every layout right after <body> (all 26 layouts of a theme) |
{control frontSystemCustomAssets 'body_end'} |
layouts/parts/shared/bottom.latte, before </body> |
All three calls use the same name, so they get the same component instance: the page codes load
with one query (loadAssets(), the relation filter is in the query) and are only split by
placement. A change of relations bumps the CustomAsset class cache, so the result never goes
stale.
| Type | What is printed |
|---|---|
| CSS | <style>…</style>, </style in any form is removed from the code |
| JS | <script>…</script>, </script is removed from the code; with a cookie category filled in, type="text/plain" + data-cookie-category, run by cookie-bar.js only after consent |
| HTML | unchanged |
Procedure: a new theme layout
- Put
{control frontSystemCustomAssets 'body_start'}into the layout right after<body>. - Check that the layout includes the shared
head.latteandbottom.latte(they carry theheadandbody_endplacements). - Use the same component name in all three calls, no
multicontrol. - Assign a test code to one page in the admin and check all three placements in the page source.
The content is not sanitized
That is the point of the library. The protection is the author's privilege
(:Admin:System:CustomAsset:edit, assignment :Admin:System:CustomAsset:assign), not code.
Removing the closing tag in CSS and JS only prevents breaking out of the element and writing
arbitrary HTML into the page; the HTML type has no safeguard and cannot have one. Calling the
component under a different name per placement costs three queries per request.
Without a page entity nothing is rendered
The codes are looked up by the page id. The error page has a non-persisted page without an id, so custom code does not run on it.
Accessibility#
Build disclosure widgets with <details> wherever possible
It works without JavaScript, screen readers handle it, and it needs no extra ARIA attributes.
Skills for working with templates#
| Skill | For |
|---|---|
cs-template-layout |
layouts |
cs-template-subpage |
a subpage |
cs-template-from-image |
a template from an image |
cs-template-from-sample |
a template from a sample |
cs-ui-audit |
reviewing a finished design |
Where to look#
| I want | Where |
|---|---|
| the theme, skin and variant selection | app/Core/Theme/DefaultThemeResolver.php |
the theme offer, theme.json, groups |
app/Core/Theme/ThemeCatalog.php |
the skin offer, skin.json |
app/Core/Theme/SkinCatalog.php |
the path to skin.css |
app/Core/Utils/Helpers/ThemeHelper.php → getSkinPath() |
| the visitor switcher | app/UI/Front/Base/Components/ThemeSwitcherComponent/ |
the themeAsset / themeUrl functions |
app/Core/Latte/Extensions/ThemeLatteExtension.php |
| the active front-end theme | www/themes/frontend/template1/desktop/ |
| the component catalogue | www/themes/frontend/template1/desktop/ui-demo/ |
| the administration theme | www/themes/admin/default/desktop/, www/themes/admin/template1/desktop/ |
| the admin theme switcher | app/UI/Admin/System/Components/ThemeSwitcherComponent/, DefaultThemeResolver::applyAdminOverride() |
| a component part's template per theme | app/Core/Base/BaseComponent.php → resolveThemeTemplatePath() |
Follow-up chapters: SCSS structure · Design system and tokens · Latte templates · Components