Appearance
Architecture & tokens
For developers
Namespace Kommandhub\BaseTheme\ → src/. Plugin class Kommandhub\BaseTheme\KmhBaseTheme extends Plugin and implements ThemeInterface; getThemeConfigPath() returns theme.json. The plugin has no services, entities, migrations, admin module, routes or system config — it is a pure theme.
Structure
text
src/
KmhBaseTheme.php
Resources/
theme.json theme definition and config fields
config/plugin.png, kommandhub.png plugin icon, master logo artwork
snippet/storefront.{en-GB,de-DE}.json
views/storefront/… Twig overrides (see below)
app/storefront/src/
assets/ logo, favicon, preview.png
scss/
overrides.scss loaded BEFORE @Storefront: Bootstrap/Shopware !default variables
base.scss loaded AFTER @Storefront: tokens + all CSS rules
abstract/ _palette, _config, _tokens-css, _mixins
base/ reboot, focus, buttons, forms, core-colors (dark-mode bridge)
component/ product-card, page-header, cms, slider, listing, line-item, card, alert, modal
layout/ container, header, offcanvas, footer, chrome
plugins/ optional skins: click-and-pick, bank-details, dial-code-phone
page/ product-detail, product-tabs, checkout, accounttheme.json
| Key | Value | Meaning |
|---|---|---|
views | @Storefront, @Plugins, @KmhBaseTheme | Template inheritance: the theme's overrides win over plugins' templates. |
style | overrides.scss, @Storefront, base.scss | @Storefront itself pulls in @Plugins, so plugin styles compile between core and base.scss — the theme's rules (and plugin skins) come last. |
script | @Storefront | The theme ships no JavaScript bundle. Dark mode is two inline scripts in Twig (below). |
asset | @Storefront, app/storefront/src/assets | |
previewMedia | app/storefront/src/assets/preview.png | Card in Content → Themes. |
config | 7 blocks, 38 fields, no tabs/sections | All fields land on the default tab. No configInheritance. Field reference: Theme configuration. |
Block keys: themeColors, statusColors, typography, eCommerce, media (Shopware's own block names) and kmhLayout, kmhComponents.
How config fields reach the CSS
Shopware injects every config field as an SCSS variable of the same name ($kmh-border-radius, $sw-color-success, …) before the theme's files. abstract/_config.scss resolves the raw values (with !default fallbacks mirroring theme.json) into token values:
| Field | Consumed in | Result |
|---|---|---|
kmh-border-radius | _config.scss | $radius-base (0 / 6 / 8 / 999px) → --radius; --radius-sm = min(6px, base×0.75), --radius-lg = min(16px, base×1.5), --radius-xl = min(20px, base×2) |
kmh-content-max-width | layout/_container.scss, overrides.scss | .container, .container-xxl max-width; $container-max-widths: (xxl: …) |
kmh-container-type | layout/_container.scss | container-fluid → .container max-width 100% (CSS only, markup unchanged) |
kmh-section-spacing | layout/_container.scss | --kmh-section-spacer 0.7 / 1 / 1.5 × 2.5rem on .cms-sections .cms-section |
kmh-nav-layout | layout/_header.scss | dropdown → compact .main-navigation-menu .dropdown-menu, teaser and close button hidden |
kmh-footer-columns | layout/_footer.scss | .footer-columns > .footer-column width 100% / n from 768px |
kmh-button-variant | base/_buttons.scss | outline → .btn-primary, .btn-buy transparent until hover |
kmh-card-style | component/_product-card.scss | .product-box bordered / elevated / flat |
kmh-product-image-ratio | component/_product-card.scss | aspect-ratio on .product-image-wrapper and the stretched link |
kmh-grid-columns-* | component/_product-card.scss | .cms-listing-row > .cms-listing-col width 100% / n (< 768 / ≥ 768 / ≥ 1200px) |
kmh-rating-color | abstract/_palette.scss | $color-rating → --color-rating, $icon-review-color, $progress-bar-review |
sw-color-success/info/warning/danger | abstract/_palette.scss | Status base + derived -text, -bg, -border (light mode only) |
sw-font-family-base/headline | Shopware core | $font-family-base, $headings-font-family |
kmh-sticky-header, kmh-show-topbar, kmh-show-breadcrumb, kmh-dark-mode-toggle, kmh-logo-dark | Twig, via theme_config() | Markup gates (see below) |
Standard colour fields are shadowed
overrides.scss assigns $primary, $secondary, $body-bg, $body-color, $headings-color, $border-color, $input-*, $dropdown-border-color etc. without !default, before core's $primary: $sw-color-brand-primary !default and friends. Price and buy-button colours are re-pointed at tokens in base.scss. So sw-color-brand-primary, sw-color-brand-secondary, sw-border-color, sw-background-color, sw-text-color, sw-headline-color, sw-color-price, sw-color-buy-button and sw-color-buy-button-text have little or no effect. Change the palette in a child theme instead.
Design tokens
All tokens are CSS custom properties on :root, emitted once in abstract/_tokens-css.scss. Components use var(--…) only; dark mode re-values the same names under [data-theme='dark'].
| Group | Tokens |
|---|---|
| Text | --color-text-primary, -secondary, -muted, -inverse |
| Surfaces | --color-bg-base, --color-bg-subtle, --color-bg-muted, --color-surface, --color-surface-sunken |
| Borders | --color-border-subtle, --color-border, --color-border-strong, --color-border-input |
| Action | --color-action, -hover, -active, --color-action-text |
| State | --color-disabled-bg/-border/-text, --color-focus-ring, --color-selected, --color-selected-bg, --color-overlay |
| Status | --color-{success,warning,danger,info} + -text (AA), -bg (tint), -border |
| Rating | --color-rating, --color-rating-empty |
| Type | --font-size-2xs … --font-size-3xl (11 → 34px, h1 fluid), --font-weight-regular/medium/semibold/bold, --line-height-tight/snug/base, --tracking-tight/snug/wide |
| Space | --space-1 … --space-16 (4px grid: 1, 2, 3, 4, 5, 6, 8, 10, 12, 16) |
| Controls | --control-height-sm / --control-height / --control-height-lg (32 / 40 / 46px) |
| Radius | --radius-sm, --radius, --radius-lg, --radius-xl, --radius-full |
| Elevation | --shadow-xs/sm/md/lg, --focus-halo |
| Motion | --ease-standard, --transition-fast, --transition-base |
Compile-time SCSS counterparts live in abstract/_palette.scss: the neutral scale $mono-0 … $mono-1000 and semantic aliases ($color-action, $color-text-primary, …), all !default.
Mixins (abstract/_mixins.scss): focus ring, elevation, and kmh-motion-safe — wraps transitions in @media (prefers-reduced-motion: no-preference).
Dark-mode bridge (base/_core-colors.scss): Shopware bakes some colours in at compile time (and Bootstrap runs some through colour math, so they cannot be CSS variables). This file re-points those elements at the tokens. When a core element shows up dark-on-dark, add it there (or in your child theme).
Dark mode
| Piece | Where | What |
|---|---|---|
| No-flash script | layout/meta.html.twig, block layout_head_stylesheet | Before the stylesheet: reads localStorage['kmh-theme']; if dark or light, sets data-theme on <html>. In meta.html.twig rather than base_head because some pages (product page) replace base_head. |
| Toggle button | layout/header/header.html.twig, new block layout_header_actions_theme_toggle inside layout_header_actions_account | <button data-kmh-theme-toggle aria-pressed> with snippet kmhBaseTheme.toggleDarkMode as aria-label/title. |
| Wiring script | base.html.twig, block base_body_script | Binds every [data-kmh-theme-toggle], toggles data-theme, writes localStorage['kmh-theme'], syncs aria-pressed. |
All three render only when kmh-dark-mode-toggle is on. No prefers-color-scheme handling: without a stored value the page is light. Any element with data-kmh-theme-toggle works as a toggle (rendered before base_body_script).
Template overrides
Every override uses sw_extends and keeps core's inner blocks.
| Template | Block | Change |
|---|---|---|
base.html.twig | base_body_script | Dark-mode wiring script (after parent()) |
layout/meta.html.twig | layout_head_stylesheet | No-flash dark-mode script (before parent()) |
layout/header.html.twig | header | Wraps base_header + base_navigation in .header-sticky-wrapper (.is-sticky when kmh-sticky-header); base_offcanvas_navigation stays outside. Sticky is CSS only. |
layout/header/header.html.twig | layout_header_actions_account | Prepends new block layout_header_actions_theme_toggle |
layout/header/logo.html.twig | layout_header_logo_image | Prepends <img class="header-logo-main-img-dark"> when dark mode is on and kmh-logo-dark set; CSS hides the <picture> in dark mode. Without it, [data-theme='dark'] .header-logo-main-img gets filter: invert(1). |
layout/header/top-bar.html.twig | layout_header_top_bar | Renders only if kmh-show-topbar or more than one language or currency |
layout/breadcrumb.html.twig | layout_breadcrumb_inner | Gated by kmh-show-breadcrumb; picks up product if passed |
layout_breadcrumb_list_item | Current crumb as <span class="breadcrumb-current"> with aria-current="page"; folders as text; on product pages appends the product as last crumb. schema.org BreadcrumbList kept. | |
layout_breadcrumb_placeholder | Emptied (separators are CSS ::before) | |
page/content/product-detail.html.twig | cms_breadcrumb | Passes product: page.product into the breadcrumb include |
layout/footer/footer.html.twig | layout_footer_navigation_hotline_headline | Prepends new block layout_footer_brand: desktop logo (+ dark logo) linking home, alt = shop name |
component/review/review-widget.html.twig | component_review_overview | Prepends the average score <p class="kmh-review-score" aria-hidden="true"> (stars carry the accessible value) |
New blocks you can extend: layout_header_actions_theme_toggle, layout_footer_brand.
Snippets
| Key | en-GB | de-DE |
|---|---|---|
kmhBaseTheme.toggleDarkMode | Toggle dark mode | Dunkelmodus umschalten |
Public CSS hooks
.page-headercomponent (component/_page-header.scssdocuments the markup: title, description, meta badges, actions, tabs, toolbar) — reuse it for plugin pages.- CMS block classes
kmh-cms-surface,kmh-cms-cards,kmh-cms-center; rich-text.kmh-eyebrow,.lead— see Shopping Experiences. - Any
<select class="form-control">gets the.form-selectchevron.
Plugin skins
scss/plugins/ targets only each plugin's own markup (no Twig, no dependency); with the plugin absent nothing matches.
| File | Plugin | Selectors (examples) |
|---|---|---|
_click-and-pick.scss | KmhClickAndPickSW | pickup labels/selects; pickup card — one extra class of specificity beats the plugin's inline <style> |
_bank-details.scss | Paystack / Flutterwave | .bank-details-content label/value pairs |
_dial-code-phone.scss | SMS | .kmh-dial-code-phone > .kmh-dial-code-phone__national outer radius |
Tests
| Suite | Command | Covers |
|---|---|---|
| PHPUnit | make test | Plugin class is a theme reading theme.json; theme.json inherits from the Storefront, every field has a type, en-GB label and declared block; radius options match the SCSS map |
| Static analysis | make check | php-cs-fixer dry-run, PHPStan, PHPUnit |
| Playwright | make e2e-setup, make e2e | Desktop (1440×900) and phone (Pixel 7): header, breadcrumb, listing, wishlist, checkout + order, address manager, sliders, product tabs, CMS, plugin skins, WCAG AA contrast light + dark |
| Store compliance | make validate-plugin | shopware-cli validation |
The repository has its own dockware stack (Shopware 6.7.8) on http://localhost:8100: make up once, then the commands above. Against any storefront that has the theme assigned:
bash
E2E_BASE_URL=https://staging.example.com npx playwright testEach run registers a throwaway customer and places one real order (cash on delivery) — never point it at production. Paths default to the KmhDemoDataSW catalogue; override with E2E_SIDEBAR_CATEGORY, E2E_TOOLBAR_CATEGORY, E2E_SEARCH, E2E_PRODUCT + E2E_PRODUCT_NAME.
Demo CMS homepage: tests/e2e/fixtures/seed-cms-home.mjs builds a homepage with 32 core blocks and assigns it to a sales channel's root category (idempotent, fixed layout id; the previous layout is kept for rollback). make e2e-cms on the stack, or:
bash
SW_URL=http://localhost SW_CLIENT_ID=… SW_CLIENT_SECRET=… SW_SALES_CHANNEL=Storefront \
node tests/e2e/fixtures/seed-cms-home.mjsmake e2e-cms creates a new integration kmh-e2e-cms on each run; delete old ones under Settings → Integrations.
Releasing
Bump version in composer.json, add the entry to CHANGELOG.md and CHANGELOG_de-DE.md, pass make check, make e2e and make validate-plugin, build the store ZIP with make zip (into build/), tag (git tag 1.2.0) and push the tag — Packagist picks it up; attach the ZIP to the GitHub release.