Skip to content
V
For developers
Architecture, conventions, the core and security
Look and themes / Frontend themes

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

CMS_THEME_OVERRIDE=template2 php -S 127.0.0.1:8099 -t www
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#

  1. 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.
  2. The SCSS belongs in template1/desktop/assets/scss/; the build:
sass assets/scss/style.scss assets/css/style.css --style=compressed
  1. The JavaScript goes into assets/js/{components,behaviors,pages}/; the build:
npx webpack --config webpack.config.js
  1. Look at the result in the component catalogue (ui-demo/) — it is the only place showing most of the elements at once.
  2. 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#

  1. Copy the whole base of an existing theme; keep your differences in a layer of their own (custom/), not scattered across the tree.
  2. Verify it with diff -r against 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.
  3. Do not switch it on in the settings until the files are deployed everywhere (see the warning above). Use CMS_THEME_OVERRIDE for development.
  4. Go through the device variants — a missing mobile falls back to desktop, 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:

php bin/theme-previews.php              # all themes
php bin/theme-previews.php template5    # one theme only

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:

{ "label": "Cihlová", "description": "Teplá cihlová červeň", "color": "#ec3013", "order": 10 }
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

  1. Create desktop/assets/skins/<name>/skin.css and override only tokens in it. Do not copy the skin for tablet and mobile — ThemeHelper::getAssetPath() falls back to the desktop file.
  2. Add a skin.json with label, color and order. Without color the dot colour is looked up only as a hex value of --color-primary in skin.css; any other notation (rgb(), var()) leaves the dot empty.
  3. Check it via ?_skin=<name> and in the administration under Nastavení → Vzhled webu.
  4. 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#

  1. View the page source and find <script type="application/ld+json">. Inside must be valid JSON — no unevaluated {=…} or {latte:raw …}.
  2. 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 single WebSite node (@id is …/#website, inLanguage for example cs-CZ).
  3. 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:

  1. 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),
  2. the same in the desktop variant,
  3. Templates/<Presenter>/<view>.latte next to the presenter in app/.

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

  1. Put {control frontSystemCustomAssets 'body_start'} into the layout right after <body>.
  2. Check that the layout includes the shared head.latte and bottom.latte (they carry the head and body_end placements).
  3. Use the same component name in all three calls, no multicontrol.
  4. 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

Cookie settings