Accessibility fixes in your Shopware theme’s code

Validiscan fixes accessibility failures in your Shopware 6 theme plugin’s Twig templates, SCSS, storefront JavaScript and snippet files. Nothing reaches your live shop until your developer merges or applies the changes and deploys the theme.

Last updated:

What does Validiscan change in a Shopware theme?

Validiscan changes only files in the folders where a Shopware 6 storefront theme keeps its sources, Resources/views, Resources/app/storefront/src and Resources/snippet, and its theme.json. A change to any other file fails validation and is never delivered, and no fix installs a plugin or adds an overlay script.

Settings that you keep in the Shopware Administration, such as meta titles or theme colours saved there, are not files of the theme, so the fixes do not change them. A project covers one domain and one theme; third-party embeds such as payment or chat widgets are not included in it.

Details: the folders, what a change looks like, and the two recipes that change the storefront script

Validiscan changes only files in the folders where a Shopware 6 storefront theme keeps its sources: the Twig templates in Resources/views, the SCSS and JavaScript sources in Resources/app/storefront/src, the snippet files with the storefront’s text in Resources/snippet, and the theme’s theme.json. A change to any other file, such as the compiled files in Resources/app/storefront/dist or the plugin’s PHP code and configuration, fails validation. A change that fails validation cannot be approved, so it is never delivered.

Every change is an ordinary edit to a theme file that your developer can read line by line. Small changes, such as an added attribute or a corrected colour value, are made where the code already is. New style rules and new blocks of script sit between comments that name the recipe, for example /* validiscan:focus-visible-styles */, so they are easy to find. Two recipes change the theme’s own storefront script: one connects a pause button to the image slider, and the other keeps a script’s autoplay from starting when a visitor has asked for less motion. No fix installs a plugin or adds a script that corrects your pages after they load, as an overlay does.

How does Validiscan get access to your Shopware theme?

Through a GitHub repository, or a ZIP of the theme plugin uploaded in the customer portal, because Validiscan prepares every fix on a copy of your Shopware theme plugin’s source code. Which one fits depends on where the code lives: a repository gets a pull request, a ZIP gets a patch bundle.

The two ways into a Shopware theme plugin
Way in You give Changes come back as
Through your GitHub repository Access to the repository that holds the theme plugin, through Validiscan’s GitHub App or an access token A pull request
From a ZIP of the theme plugin The theme plugin’s folder under custom/plugins, as one ZIP of up to 100 MB, uploaded in the customer portal by an admin of your organization A patch bundle

Through your GitHub repository

If the code of your theme plugin is kept in a GitHub repository, you give Validiscan access to that repository: you install Validiscan’s GitHub App on it, or give Validiscan an access token for it. Validiscan’s setup specifies the App’s permissions as read and write access to the repository’s contents and pull requests and read access to its metadata, and nothing else. A token needs the same permissions on that one repository.

The theme plugin can sit at the top of the repository or deeper in it, as in custom/plugins/YourTheme/src/Resources: the recipes find the theme’s files by Shopware’s folder conventions. The changes then arrive as a pull request.

From a ZIP of the theme plugin

If the code is not in a GitHub repository, your developer makes a ZIP of the theme plugin. The customer portal’s instruction for Shopware reads: “Ask your developer for a ZIP of your theme plugin: its folder under custom/plugins, as it is deployed to your shop.” An admin of your organization then uploads the file in the customer portal, as one ZIP file of up to 100 MB. Validiscan prepares the changes on that copy of your theme and sends them back as a patch bundle.

Without either of the two, Validiscan has no code to change, and no fix is prepared.

How do the changes reach your shop?

The changes arrive as a pull request or as a patch bundle, and your developer decides when they go live. Validiscan delivers only changes it has reviewed and approved and never changes your live Shopware shop’s theme or code; delivery as an unpublished theme copy exists only on Shopify.

From delivery to your live shop, in five steps
  1. Validiscan delivers the changes it has reviewed and approved: a pull request on a new branch, or a patch bundle for your developer.
  2. Your developer reviews the changes. For a pull request, its checklist asks for a preview or staging environment, not the live shop, and a check of the affected pages, also with the keyboard alone.
  3. Your developer merges the pull request or applies the patch bundle.
  4. Your developer builds the theme: SCSS changes take effect once the theme is compiled again, and JavaScript changes once the storefront is built again.
  5. Your developer deploys the theme. Once the changes are live, a verification scan checks the affected pages again.

A pull request

With access to your repository, Validiscan creates a new branch named validiscan/a11y-<date>-<id>, commits each fix on its own and opens one pull request with a summary table, an explanation of each change and a review checklist. Nothing is pushed to your default branch. The checklist asks your developer to deploy the branch to a preview or staging environment, not to the live shop, and to check the affected pages there, also with the keyboard alone, before merging.

A patch bundle

From the uploaded ZIP, Validiscan prepares a zip archive and sends it to your developer. It contains PATCH.md with every change and how to apply it, all changes in one patch file that applies with git apply, one patch file per fix, the complete changed files and a README.md. Your developer applies the changes, tests them and deploys the theme. What a pull request and a patch bundle contain

Building the theme

A Shopware shop does not serve the theme’s SCSS and JavaScript sources as they are: they are built first. Changes to the SCSS take effect once the theme is compiled again, for example with bin/console theme:compile, as Shopware’s developer documentation on theme styling describes. Changes to the storefront JavaScript take effect once the storefront is built again, as described in Shopware’s documentation on storefront JavaScript. Your developer runs these steps when deploying the changed theme. How accessibility fixes reach a Shopware theme, and what a theme update can undo

What do the recipes change in a Shopware theme?

Each of Validiscan’s 20 recipes for Shopware themes is a deterministic change for one kind of failure: the same code always gets the same change. A recipe makes a change only where the theme’s code shows what the right change is; where it does not, it changes nothing.

Page structure and navigation

  • Page language in the base layout html-lang The <html> element of the base layout gets a valid lang attribute where it is missing or where the theme prints a locale code such as en_GB.

    The <html> element of the storefront’s base layout gets a valid lang attribute, so screen readers pronounce the page in the right language. Where the attribute is missing, the recipe adds the code of the shop’s active language, for example de-DE, the value Shopware’s own base template prints. Where the theme prints a locale code such as en_GB, which is not a valid language tag, the template now turns it into en-GB. A page whose content is written in another language than the shop’s, such as a German legal text in an English shop, is left to you: the language of that content is set in the Administration or marked in the text itself.

    Rules it serves
    • html-has-lang AUTO checked through vs-lang
    • html-lang-valid AUTO checked through vs-lang
    • vs-lang AUTO
    File types it changes
    .html.twig
    normalises the locale code of the base layout; German content pages are left to the merchant Example data

    Resources/views/storefront/base.html.twig Changes to Resources/views/storefront/base.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/base.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    9 9 {% block base_doctype %}<!DOCTYPE html>{% endblock %}
    10 10
    11 11 {% block base_html %}
    12 Removed <html lang="{{ page.lang }}" itemscope="itemscope" itemtype="https://schema.org/WebPage">
    12 Added <html lang="{{ page.lang|replace({'_': '-'}) }}" itemscope="itemscope" itemtype="https://schema.org/WebPage">
    13 13 {% endblock %}
    14 14
    15 15 {% block base_head %}
  • A title on every page document-title-template Where the layout prints only the meta title, the shop name becomes its fallback, so no page has an empty title.

    Where the theme’s layout writes only the page’s meta title into the <title>, categories and CMS pages without a meta title get an empty title. The recipe keeps the theme’s own title and gives it a fallback: the shop name, taken from where the theme already prints it or, if it does not, from the shop name in Shopware’s basic settings. No page then has an empty title. A title that is only the shop name still does not say what the page is, so those pages need a meta title in the Administration. Titles that already have a fallback or fixed text are left alone.

    Rules it serves
    • document-title AUTO
    File types it changes
    .html.twig
    gives the layout title a shop-name fallback and the empty CMS title is gone Example data

    Resources/views/storefront/layout/meta.html.twig Changes to Resources/views/storefront/layout/meta.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/layout/meta.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    36 36 {% endblock %}
    37 37
    38 38 {% block layout_head_title %}
    39 Removed <title>{{ page.metaTitle }}</title>
    39 Added <title>{{ page.metaTitle|default(shop.name) }}</title>
    40 40 {% endblock %}
    41 41
    42 42 {% block layout_head_stylesheet %}
  • Headings without skipped levels heading-levels A heading that skips a level moves up, together with the template’s headings at its level or deeper, and keeps its look.

    A heading that skips a level, such as an h3 right after an h1, moves up to the next level, and the other headings of the same template at that level or a deeper one move with it, so the template’s own outline stays intact. Where the template sets the heading’s tag in a variable, the variable is changed. Classes stay, and where no class sets the size, a Bootstrap heading class keeps the old look. A page without a main heading gets the element that already titles it in its own template turned into the h1, with its look kept. If a page has no such element, the recipe adds no heading, because a new heading needs wording.

    Rules it serves
    • heading-order AUTO checked through vs-headings-landmarks
    • page-has-heading-one AUTO checked through vs-headings-landmarks
    • vs-headings-landmarks AUTO
    File types it changes
    .html.twig
    moves a static grid title from h3 to h2; the skipped level is gone Example data

    Resources/views/storefront/block/cms-block-product-grid.html.twig Changes to Resources/views/storefront/block/cms-block-product-grid.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/block/cms-block-product-grid.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    5 5 {% block block_product_grid %}
    6 6 <div class="cms-block cms-block-product-grid">
    7 7 <div class="cms-block-container">
    8 Removed <h3 class="cms-block-title">{{ block.title }}</h3>
    8 Added <h2 class="cms-block-title">{{ block.title }}</h2>
    9 9 <div class="row cms-listing-row">
    10 10 {% for product in block.products %}
    11 11 {% sw_include '@Storefront/storefront/component/product/card/box-standard.html.twig' with { product: product } %}

Keyboard use and focus

  • A visible focus outline focus-visible-styles One outline for keyboard focus, in the theme’s brand colour or the nearest shade of its hue with a contrast of at least 3:1 against the page background.

    The main stylesheet gets one outline for keyboard focus: 2 pixels, solid, 2 pixels away from the element, in the theme’s brand colour. Where that colour is too light, the nearest shade of the same hue that has a contrast of at least 3:1 against the page background is used. Theme rules that remove the outline on focus now apply only to focus by mouse: a click keeps the theme’s look, and keyboard focus shows the outline. Rules that show focus in another way are kept. On dark areas, such as a top bar, the outline may need a lighter colour, which your developer checks in the preview.

    Rules it serves
    • vs-focus-visible AUTO
    File types it changes
    .scss
    adds a brand-coloured :focus-visible ring and scopes the outline resets Example data

    Resources/app/storefront/src/scss/_variant-broken.scss Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 5 added, 5 removed

    Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 5 added, 5 removed
    Line before Line after Change Code
    21 21 }
    22 22
    23 23 /* "Remove the ugly blue ring" (vs-focus-visible). */
    24 Removed .header-actions-btn:focus,
    25 Removed .header-search-btn:focus,
    26 Removed .main-navigation-link:focus,
    27 Removed .product-name:focus,
    28 Removed .footer-link:focus {
    24 Added .header-actions-btn:focus:not(:focus-visible),
    25 Added .header-search-btn:focus:not(:focus-visible),
    26 Added .main-navigation-link:focus:not(:focus-visible),
    27 Added .product-name:focus:not(:focus-visible),
    28 Added .footer-link:focus:not(:focus-visible) {
    29 29 outline: 0;
    30 30 box-shadow: none;
    31 31 }

    Resources/app/storefront/src/scss/base.scss Changes to Resources/app/storefront/src/scss/base.scss: 8 added, 0 removed

    Changes to Resources/app/storefront/src/scss/base.scss: 8 added, 0 removed
    Line before Line after Change Code
    1708 1708 margin: 8px 0 0;
    1709 1709 font-size: 0.875rem;
    1710 1710 }
    1711 Added
    1712 Added /* validiscan:focus-visible-styles */
    1713 Added /* Visible keyboard focus (WCAG 2.4.7): brand colour sw-color-brand-primary, 8.9:1 on #ffffff. */
    1714 Added :focus-visible {
    1715 Added outline: 2px solid #6b3f1d;
    1716 Added outline-offset: 2px;
    1717 Added }
    1718 Added /* /validiscan:focus-visible-styles */
  • A tab order that follows the page remove-positive-tabindex A tabindex above 0 is removed or set to 0, so the Tab key follows the order of the page, unless the template’s code writes it.

    A tabindex above 0 pulls an element to the front of the keyboard order. The recipe removes it from links, buttons, form fields and other elements that take focus anyway, and sets it to 0 on other elements, so they stay reachable while the Tab key follows the order of the page. A tabindex that the template’s code writes is left to a person. A scan reports such a forced order twice: through the axe-core rule tabindex, whose findings get this change, and through Validiscan’s check vs-focus-order, whose tier is MANUAL, so its findings come with guidance.

    Rules it serves
    • tabindex AUTO
    • vs-focus-order MANUAL
    File types it changes
    .html.twig
    removes tabindex="1" from the buy button; vs-focus-order and axe tabindex stop firing Example data

    Resources/views/storefront/page/product-detail/buy-widget.html.twig Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    82 82 </div>
    83 83 <div class="buy-widget-submit">
    84 84 <button class="btn btn-primary btn-buy"
    85 Removed title="{{ 'detail.addProduct'|trans }}" tabindex="1">
    85 Added title="{{ 'detail.addProduct'|trans }}">
    86 86 {{ 'detail.addProduct'|trans }}
    87 87 </button>
    88 88 </div>

Zoom and tap targets

  • Zoom allowed on phones meta-viewport-zoom The viewport tag loses user-scalable=no and any maximum-scale below 2, so visitors can enlarge text to at least 200 % on a phone.

    The recipe removes user-scalable=no and any maximum-scale below 2 from the viewport tag of the storefront templates, usually in layout/meta.html.twig, so visitors can enlarge text to at least 200 % on a phone. For the best-practice rule meta-viewport-large the limit is 5. The other viewport settings stay as they were. If the lock was there to stop iOS from zooming into form fields, a font size of at least 16 pixels for the fields does that without blocking zoom.

    Rules it serves
    • meta-viewport AUTO
    • meta-viewport-large AUTO
    File types it changes
    .html.twig
    removes the zoom lock from the fixture theme and the rule stops firing Example data

    Resources/views/storefront/layout/meta.html.twig Changes to Resources/views/storefront/layout/meta.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/layout/meta.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    6 6
    7 7 {% block layout_head_meta_tags_viewport %}
    8 8 {# Added by the merchant's agency to stop "zoom jumps" on iOS. #}
    9 Removed <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    9 Added <meta name="viewport" content="width=device-width, initial-scale=1">
    10 10 {% endblock %}
    11 11
    12 12 {% block layout_head_meta_tags_general %}
  • Larger tap targets target-size-padding Buttons and links the scan found smaller than 24 by 24 pixels get that minimum size, where their class identifies them.

    Buttons and links that the scan found smaller than 24 by 24 pixels, such as header icons, quantity buttons or swatches, get a minimum size of 24 by 24 pixels. The minimum is added to the theme’s own style rule that made the control small, next to the declaration that shrank it, so the order of the theme’s styles stays the same and the icons inside keep their size. A control that is small without such a rule gets a minimum size for its class in the main stylesheet. A control the recipe cannot identify by its class is left to a person.

    Rules it serves
    • vs-target-size AUTO
    File types it changes
    .scss
    gives the header icons, steppers and swatches a 24px minimum; no page reports small targets Example data

    Resources/app/storefront/src/scss/_variant-broken.scss Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 6 added, 2 removed

    Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 6 added, 2 removed
    Line before Line after Change Code
    10 10
    11 11 .header-actions-btn {
    12 12 width: 18px;
    13 Added min-width: 24px;
    13 14 height: 18px;
    14 Removed min-height: 0;
    15 Added min-height: 24px;
    15 16 padding: 0;
    16 17 }
    17 18
    96 97 /* 18 px colour swatches, 20 px quantity and remove buttons (vs-target-size). */
    97 98 .product-detail-configurator-option-input {
    98 99 width: 18px;
    100 Added min-width: 24px;
    99 101 height: 18px;
    102 Added min-height: 24px;
    100 103 margin-right: 4px;
    101 104 }
    102 105
    103 106 .quantity-selector-group .btn,
    104 107 .line-item-remove-button {
    105 108 width: 20px;
    109 Added min-width: 24px;
    106 110 height: 20px;
    107 Removed min-height: 0;
    111 Added min-height: 24px;
    108 112 padding: 0;
    109 113 }
    110 114

Motion, slideshows and sound

  • Less motion when a visitor asks for it reduced-motion-guard When a visitor’s device asks for reduced motion, the animations and transitions that stylesheets define end at once and smooth scrolling is turned off.

    When a visitor’s device is set to reduce motion, a block in the main stylesheet makes animations and transitions end at once and turns smooth scrolling off. The block applies to every element of the page, so it also covers animations that other stylesheets define, including those of plugins. Where the theme’s storefront script starts a timer, such as a slideshow’s autoplay, only when an autoplay option is set, that condition now also checks the visitor’s setting. Other motion that a script starts is listed for the reviewer, not guessed at.

    Rules it serves
    • vs-motion AUTO
    File types it changes
    .js.scss
    guards the pulsing badge and the slider autoplay; the animation stops under reduce Example data

    Resources/app/storefront/src/main.js Changes to Resources/app/storefront/src/main.js: 1 added, 1 removed

    Changes to Resources/app/storefront/src/main.js: 1 added, 1 removed
    Line before Line after Change Code
    139 139 });
    140 140 });
    141 141
    142 Removed if (options.autoplay === true) {
    142 Added if (options.autoplay === true && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    143 143 window.setInterval(
    144 144 function () {
    145 145 show(current + 1);

    Resources/app/storefront/src/scss/base.scss Changes to Resources/app/storefront/src/scss/base.scss: 14 added, 0 removed

    Changes to Resources/app/storefront/src/scss/base.scss: 14 added, 0 removed
    Line before Line after Change Code
    1708 1708 margin: 8px 0 0;
    1709 1709 font-size: 0.875rem;
    1710 1710 }
    1711 Added
    1712 Added /* validiscan:reduced-motion-guard */
    1713 Added /* Honour the visitor's reduced-motion setting: animations and transitions end at once, no smooth scrolling. */
    1714 Added @media (prefers-reduced-motion: reduce) {
    1715 Added *,
    1716 Added *::before,
    1717 Added *::after {
    1718 Added animation-duration: 0.01ms !important;
    1719 Added animation-iteration-count: 1 !important;
    1720 Added transition-duration: 0.01ms !important;
    1721 Added scroll-behavior: auto !important;
    1722 Added }
    1723 Added }
    1724 Added /* /validiscan:reduced-motion-guard */
  • A pause button for moving slideshows slider-pause-control Shopware’s base slider, behind the image slider of the CMS, gets a visible, translated button to pause and play it.

    Shopware’s base slider, the slider behind the image slider of the CMS, gets a visible, translated button to pause and play it, next to its previous and next arrows. A block of code in the theme’s storefront script connects the button to the slider’s autoplay through Shopware’s own slider plugin. Where the theme runs the autoplay with its own timer, that timer gets one line that skips a step while the slider is paused. The button stays hidden until the script has connected it, so it never appears without an autoplay to pause. For visitors whose device asks for reduced motion, the slider starts paused. Sliders built with other libraries are left to a person.

    Rules it serves
    • vs-carousel AUTO
    File types it changes
    .html.twig.js.json
    adds a wired pause button to the hero slider; the carousel is no longer unstoppable Example data

    Resources/app/storefront/src/main.js Changes to Resources/app/storefront/src/main.js: 52 added, 0 removed

    Changes to Resources/app/storefront/src/main.js: 52 added, 0 removed
    Line before Line after Change Code
    142 142 if (options.autoplay === true) {
    143 143 window.setInterval(
    144 144 function () {
    145 Added if (slider.getAttribute('data-autoplay-paused') === 'true') return;
    145 146 show(current + 1);
    146 147 },
    147 148 Number(options.autoplayTimeout) || 5000,
    453 454 initFilterRanges();
    454 455 initToggles();
    455 456 })();
    457 Added
    458 Added /* validiscan:slider-pause-control */
    459 Added /*
    460 Added * Pause / play button of auto-advancing sliders (WCAG 2.2.2): wired to the
    461 Added * slider's autoplay; sliders start paused when the visitor prefers reduced motion.
    462 Added */
    463 Added (function sliderPauseControl() {
    464 Added 'use strict';
    465 Added
    466 Added function tinySlider(slider) {
    467 Added const manager = window.PluginManager;
    468 Added if (!manager || typeof manager.getPluginInstanceFromElement !== 'function') return null;
    469 Added const plugin = manager.getPluginInstanceFromElement(slider, 'BaseSlider');
    470 Added return plugin && plugin._slider ? plugin._slider : null;
    471 Added }
    472 Added
    473 Added function autoplays(slider) {
    474 Added try {
    475 Added const options = JSON.parse(slider.getAttribute('data-base-slider-options') || '{}') || {};
    476 Added return Boolean(options.slider && options.slider.autoplay);
    477 Added } catch {
    478 Added return false;
    479 Added }
    480 Added }
    481 Added
    482 Added function init() {
    483 Added const reduceMotion =
    484 Added window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    485 Added document.querySelectorAll('[data-slider-pause]').forEach(function (button) {
    486 Added const slider = button.closest('[data-base-slider]');
    487 Added if (!slider || !autoplays(slider)) return;
    488 Added
    489 Added function setPaused(paused) {
    490 Added slider.setAttribute('data-autoplay-paused', paused ? 'true' : 'false');
    491 Added button.textContent = button.getAttribute(paused ? 'data-label-play' : 'data-label-pause');
    492 Added const instance = tinySlider(slider);
    493 Added if (instance) instance[paused ? 'pause' : 'play']();
    494 Added }
    495 Added
    496 Added button.hidden = false;
    497 Added button.addEventListener('click', function () {
    498 Added setPaused(slider.getAttribute('data-autoplay-paused') !== 'true');
    499 Added });
    500 Added if (reduceMotion) setPaused(true);
    501 Added });
    502 Added }
    503 Added
    504 Added if (document.readyState === 'complete') init();
    505 Added else window.addEventListener('load', init);
    506 Added })();
    507 Added /* /validiscan:slider-pause-control */

    Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 6 added, 0 removed

    Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 6 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
    194 194 "backToHome": "Zurück zur Startseite",
    195 195 "searchInstead": "Unsere Kaffees durchsuchen"
    196 Added },
    197 Added "a11y": {
    198 Added "slider": {
    199 Added "pause": "Diashow anhalten",
    200 Added "play": "Diashow abspielen"
    201 Added }
    196 202 }
    197 203 }

    Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 6 added, 0 removed

    Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 6 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "The page you are looking for does not exist or has moved.",
    194 194 "backToHome": "Back to the homepage",
    195 195 "searchInstead": "Search our coffees"
    196 Added },
    197 Added "a11y": {
    198 Added "slider": {
    199 Added "pause": "Pause slideshow",
    200 Added "play": "Play slideshow"
    201 Added }
    196 202 }
    197 203 }

    Resources/views/storefront/element/cms-element-image-slider.html.twig Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 1 added, 0 removed

    Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 1 added, 0 removed
    Line before Line after Change Code
    57 57 <button type="button" class="base-slider-controls-next image-slider-controls-btn" data-slide-next>
    58 58 {% sw_icon 'arrow-head-right' %}
    59 59 </button>
    60 Added <button type="button" class="btn btn-light base-slider-pause" data-slider-pause="true" data-label-pause="{{ 'a11y.slider.pause'|trans|striptags }}" data-label-play="{{ 'a11y.slider.play'|trans|striptags }}" hidden>{{ 'a11y.slider.pause'|trans|sw_sanitize }}</button>
    60 61 </div>
    61 62 {% endblock %}
    62 63 </div>
  • No sound that starts by itself mute-autoplay Video that plays on its own is muted and audio no longer starts by itself, except autoplay that template code or a script sets and players in frames; on Shopware the recipe is not used for vs-media findings.

    A video that plays on its own gets muted and playsinline, so it keeps playing silently, as a background video does, and plays inside the page in Safari on phones instead of opening the full-screen player. Audio that starts by itself no longer does; visitors start it themselves. Autoplay that template code or a script sets, and players embedded in frames, are left to a person. In a Validiscan scan, sound that starts by itself is reported by Validiscan’s media check vs-media, which takes the place of the axe-core rule no-autoplay-audio. vs-media, which also covers captions and transcripts, is MANUAL, so on Shopware such a finding comes with guidance rather than with this change.

    Rules it serves
    • no-autoplay-audio AUTO checked through vs-media
    • vs-media MANUAL
    File types it changes
    .html.twig
    mutes a seeded autoplaying background video; vs-media no longer reports autoplay Example data

    Resources/views/storefront/block/cms-block-video.html.twig Changes to Resources/views/storefront/block/cms-block-video.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/block/cms-block-video.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    9 9 <h2 class="cms-block-title">{{ block.title }}</h2>
    10 10 <p class="cms-video-text">{{ block.text }}</p>
    11 11 <div class="cms-element-video">
    12 Removed <video class="cms-video" autoplay loop preload="none" width="1280" height="720" poster="{{ block.posterUrl }}">
    12 Added <video class="cms-video" autoplay loop preload="none" width="1280" height="720" poster="{{ block.posterUrl }}" muted playsinline>
    13 13 <source src="{{ block.videoUrl }}" type="video/mp4">
    14 14 {% if block.captionsUrl %}
    15 15 <track kind="captions" src="{{ block.captionsUrl }}" srclang="en" label="{{ 'general.captionsEnglish'|trans }}" default>

Forms, the cart and embedded content

  • Labels connected to their fields label-association A field whose label text is already on the page, right before it, gets connected to that text.

    A form field whose label text is already on the page, right before the field, gets connected to that text. A <label> without a for gets one that points at the field. A text element that the theme marks as a title or label by its class, such as the name of a variant group on a product page, becomes the field’s <label>, keeping its classes and its own line. Screen readers then read the label when the field gets focus. A field that only has a placeholder is left to a person, because writing a label is a content decision.

    Rules it serves
    • label AUTO checked through vs-forms-ecommerce
    • select-name ASSISTED
    • vs-forms-ecommerce AUTO
    File types it changes
    .html.twig
    turns the variant group titles into labels of their selects; select-name no longer fires Example data

    Resources/views/storefront/page/product-detail/configurator.html.twig Changes to Resources/views/storefront/page/product-detail/configurator.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/page/product-detail/configurator.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    13 13 {% for group in product.configurator %}
    14 14 {% if group.display == 'select' %}
    15 15 <div class="product-detail-configurator-group">
    16 Removed <div class="product-detail-configurator-group-title">{{ group.name }}</div>
    16 Added <label class="product-detail-configurator-group-title" for="configurator-{{ group.id }}">{{ group.name }}</label>
    17 17 <select class="form-select product-detail-configurator-select-input"
    18 18 id="configurator-{{ group.id }}"
    19 19 name="{{ group.id }}"{% if group.price|default(false) %}
  • Account, address and contact fields that browsers can fill in autocomplete-tokens Fields that ask for the customer’s own data, in forms such as registration, address and login, get the autocomplete value for what they ask for, unless template code writes it.

    Fields that ask for the customer’s own data, such as those of the registration, address, login, contact and newsletter forms, get the autocomplete value for what they ask for, such as email, first and last name, postal code, phone number or password. The value is worked out from the field’s type, name, id, label and placeholder. Passwords get new-password in registration forms and current-password elsewhere. Search, voucher, quantity, captcha and message fields are left as they are, and a value that template code writes is left to a person. Browsers can then fill the fields in, and assistive technology can tell what each one is for.

    Rules it serves
    • autocomplete-valid AUTO checked through vs-forms-ecommerce
    • vs-forms-ecommerce AUTO
    File types it changes
    .html.twig
    adds tokens to the register, address, login, contact and newsletter fields; no autocomplete hits remain Example data

    Resources/views/storefront/component/account/login.html.twig Changes to Resources/views/storefront/component/account/login.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/component/account/login.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    19 19 name="username"
    20 20 placeholder="{{ 'account.loginMailPlaceholder'|trans }}"
    21 21 required
    22 Removed autocomplete="off">
    22 Added autocomplete="email">
    23 23 </div>
    24 24
    25 25 <div class="form-group">

    Resources/views/storefront/component/account/register.html.twig Changes to Resources/views/storefront/component/account/register.html.twig: 8 added, 4 removed

    Changes to Resources/views/storefront/component/account/register.html.twig: 8 added, 4 removed
    Line before Line after Change Code
    33 33 id="{{ formId }}FirstName"
    34 34 name="firstName"
    35 35 placeholder="{{ 'account.firstNamePlaceholder'|trans }}"
    36 Removed required>
    36 Added required
    37 Added autocomplete="given-name">
    37 38 </div>
    38 39 <div class="form-group col-sm-6">
    39 40 <input type="text"
    41 42 id="{{ formId }}LastName"
    42 43 name="lastName"
    43 44 placeholder="{{ 'account.lastNamePlaceholder'|trans }}"
    44 Removed required>
    45 Added required
    46 Added autocomplete="family-name">
    45 47 </div>
    46 48 </div>
    47 49 <div class="row g-2">
    52 54 id="{{ formId }}Mail"
    53 55 name="email"
    54 56 placeholder="{{ 'account.emailPlaceholder'|trans }}"
    55 Removed required>
    57 Added required
    58 Added autocomplete="email">
    56 59 </div>
    57 60 <div class="form-group col-sm-6">
    58 61 <label class="form-label" for="{{ formId }}Password">{{ 'account.passwordLabel'|trans }}</label>
    62 65 name="password"
    63 66 minlength="8"
    64 67 aria-describedby="{{ formId }}PasswordHint"
    65 Removed required>
    68 Added required
    69 Added autocomplete="new-password">
    66 70 <small class="form-text" id="{{ formId }}PasswordHint">{{ 'account.passwordHint'|trans }}</small>
    67 71 </div>
    68 72 </div>

    Resources/views/storefront/component/address/address-form.html.twig Changes to Resources/views/storefront/component/address/address-form.html.twig: 8 added, 4 removed

    Changes to Resources/views/storefront/component/address/address-form.html.twig: 8 added, 4 removed
    Line before Line after Change Code
    14 14 id="{{ prefix }}AddressStreet"
    15 15 name="{{ prefix }}[street]"
    16 16 placeholder="{{ 'address.streetPlaceholder'|trans }}"
    17 Removed required>
    17 Added required
    18 Added autocomplete="address-line1">
    18 19 </div>
    19 20 {% endblock %}
    20 21
    26 27 name="{{ prefix }}[zipcode]"
    27 28 placeholder="{{ 'address.zipcodePlaceholder'|trans }}"
    28 29 inputmode="numeric"
    29 Removed required>
    30 Added required
    31 Added autocomplete="postal-code">
    30 32 </div>
    31 33 {% endblock %}
    32 34
    37 39 class="form-control"
    38 40 id="{{ prefix }}AddressCity"
    39 41 name="{{ prefix }}[city]"
    40 Removed required>
    42 Added required
    43 Added autocomplete="address-level2">
    41 44 </div>
    42 45 {% endblock %}
    43 46
    47 50 <select class="form-select"
    48 51 id="{{ prefix }}AddressCountry"
    49 52 name="{{ prefix }}[countryId]"
    50 Removed required>
    53 Added required
    54 Added autocomplete="country">
    51 55 {% for country in countries %}
    52 56 <option value="{{ country.id }}"{% if loop.first %} selected{% endif %}>{{ country.name }}</option>
    53 57 {% endfor %}

    Resources/views/storefront/element/cms-element-form.html.twig Changes to Resources/views/storefront/element/cms-element-form.html.twig: 4 added, 4 removed

    Changes to Resources/views/storefront/element/cms-element-form.html.twig: 4 added, 4 removed
    Line before Line after Change Code
    18 18 </select>
    19 19 </div>
    20 20 <div class="form-group col-md-4">
    21 Removed <input type="text" class="form-control" id="contactFormFirstName" name="firstName" placeholder="{{ 'account.firstNamePlaceholder'|trans }}" required>
    21 Added <input type="text" class="form-control" id="contactFormFirstName" name="firstName" placeholder="{{ 'account.firstNamePlaceholder'|trans }}" required autocomplete="given-name">
    22 22 </div>
    23 23 <div class="form-group col-md-4">
    24 Removed <input type="text" class="form-control" id="contactFormLastName" name="lastName" placeholder="{{ 'account.lastNamePlaceholder'|trans }}" required>
    24 Added <input type="text" class="form-control" id="contactFormLastName" name="lastName" placeholder="{{ 'account.lastNamePlaceholder'|trans }}" required autocomplete="family-name">
    25 25 </div>
    26 26 </div>
    27 27
    28 28 <div class="row g-2">
    29 29 <div class="form-group col-md-6">
    30 30 <label class="form-label" for="contactFormMail">{{ 'account.emailLabel'|trans }}</label>
    31 Removed <input type="email" class="form-control" id="contactFormMail" name="email" placeholder="{{ 'account.emailPlaceholder'|trans }}" required>
    31 Added <input type="email" class="form-control" id="contactFormMail" name="email" placeholder="{{ 'account.emailPlaceholder'|trans }}" required autocomplete="email">
    32 32 </div>
    33 33 <div class="form-group col-md-6">
    34 34 <label class="form-label" for="contactFormPhone">{{ 'contact.phoneLabel'|trans }}</label>
    35 Removed <input type="tel" class="form-control" id="contactFormPhone" name="phone">
    35 Added <input type="tel" class="form-control" id="contactFormPhone" name="phone" autocomplete="tel">
    36 36 </div>
    37 37 </div>
    38 38

    Resources/views/storefront/layout/footer/footer.html.twig Changes to Resources/views/storefront/layout/footer/footer.html.twig: 2 added, 1 removed

    Changes to Resources/views/storefront/layout/footer/footer.html.twig: 2 added, 1 removed
    Line before Line after Change Code
    62 62 id="footerNewsletterMail"
    63 63 class="form-control"
    64 64 placeholder="{{ 'footer.newsletterPlaceholder'|trans }}"
    65 Removed required>
    65 Added required
    66 Added autocomplete="email">
    66 67 <button type="submit" class="btn btn-primary newsletter-submit">{{ 'footer.newsletterSubmit'|trans }}</button>
    67 68 </div>
    68 69 <div class="form-check newsletter-privacy">
  • Price, stock and cart changes announced live-region-cart-variant The price, availability or cart totals that the storefront script updates in place become polite live regions, unless the script replaces the element itself.

    The parts of a page that the storefront script updates in place, such as the price after a customer picks a variant, the availability or the cart totals, become polite live regions. Screen readers then read the new value once, in full, without interrupting. Where a script replaces the element itself instead of its content, a live region would not work, so the recipe leaves that element to a person.

    Rules it serves
    • vs-live-updates AUTO
    File types it changes
    .html.twig
    makes the variant price and the cart totals polite live regions; the updates are announced Example data

    Resources/views/storefront/page/checkout/cart/index.html.twig Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 2 added, 2 removed

    Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 2 added, 2 removed
    Line before Line after Change Code
    59 59 <span class="line-item-unit-price-label">{{ 'checkout.cartHeaderUnitPrice'|trans }}:</span>
    60 60 <span class="line-item-unit-price-value">{{ item.unitPrice|currency }}*</span>
    61 61 </div>
    62 Removed <div class="line-item-total-price">
    62 Added <div class="line-item-total-price" aria-live="polite" aria-atomic="true">
    63 63 <span class="line-item-total-price-label">{{ 'checkout.cartHeaderTotalPrice'|trans }}:</span>
    64 64 <span class="line-item-total-price-value" data-line-total>{{ item.totalPrice|currency }}*</span>
    65 65 </div>
    88 88 <div class="col-lg-4 checkout-aside">
    89 89 <div class="checkout-aside-summary">
    90 90 <h2 class="checkout-aside-summary-header">{{ 'checkout.summaryHeader'|trans }}</h2>
    91 Removed <div class="checkout-aside-summary-totals" data-shipping-threshold="{{ shop.shippingThreshold }}" data-shipping-costs="{{ shop.shippingCosts }}">
    91 Added <div class="checkout-aside-summary-totals" data-shipping-threshold="{{ shop.shippingThreshold }}" data-shipping-costs="{{ shop.shippingCosts }}" aria-live="polite" aria-atomic="true">
    92 92 <dl class="checkout-aside-summary-list">
    93 93 <dt class="checkout-aside-summary-label">{{ 'checkout.summaryPositionPrice'|trans }}</dt>
    94 94 <dd class="checkout-aside-summary-value" data-cart-subtotal>{{ cart.subtotal|currency }}*</dd>

    Resources/views/storefront/page/product-detail/buy-widget.html.twig Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    17 17 {% endblock %}
    18 18
    19 19 {% block buy_widget_price %}
    20 Removed <div class="product-detail-price-container">
    20 Added <div class="product-detail-price-container" aria-live="polite" aria-atomic="true">
    21 21 <meta itemprop="priceCurrency" content="{{ shop.currency.isoCode }}">
    22 22 <meta itemprop="price" content="{{ product.price }}">
    23 23 <p class="product-detail-price{% if product.listPrice %} with-list-price{% endif %}" data-price-display>{{ product.price|currency }}*</p>
  • Titles for embedded maps and videos iframe-title An embedded frame without a title gets a translated title that says what it shows, where the code reveals it.

    An embedded frame without a title, such as a map or a YouTube or Vimeo video, gets a translated title from the theme’s snippet files that says what it shows. The recipe reads what the frame shows from its address, its class and name, or the template around it. A frame whose content the code does not reveal gets no title from the recipe, because an invented title is worse than none.

    Rules it serves
    • frame-title AUTO
    File types it changes
    .html.twig.json
    titles the contact map frame through the snippets; frame-title no longer fires Example data

    Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 5 added, 0 removed

    Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 5 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
    194 194 "backToHome": "Zurück zur Startseite",
    195 195 "searchInstead": "Unsere Kaffees durchsuchen"
    196 Added },
    197 Added "a11y": {
    198 Added "iframe": {
    199 Added "map": "Karte"
    200 Added }
    196 201 }
    197 202 }

    Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 5 added, 0 removed

    Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 5 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "The page you are looking for does not exist or has moved.",
    194 194 "backToHome": "Back to the homepage",
    195 195 "searchInstead": "Search our coffees"
    196 Added },
    197 Added "a11y": {
    198 Added "iframe": {
    199 Added "map": "Map"
    200 Added }
    196 201 }
    197 202 }

    Resources/views/storefront/block/cms-block-map.html.twig Changes to Resources/views/storefront/block/cms-block-map.html.twig: 2 added, 1 removed

    Changes to Resources/views/storefront/block/cms-block-map.html.twig: 2 added, 1 removed
    Line before Line after Change Code
    11 11 <iframe class="cms-map-frame"
    12 12 src="{{ url('/widgets/cms/map') }}"
    13 13 width="1280"
    14 Removed height="360"></iframe>
    14 Added height="360"
    15 Added title="{{ 'a11y.iframe.map'|trans|striptags }}"></iframe>
    15 16 </div>
    16 17 <p class="cms-map-address">{{ shop.company.street }}, {{ shop.company.zip }} {{ shop.company.city }}</p>
    17 18 </div>

Contrast, images and controls that need judgement

Text contrast, text alternatives for images and names for unnamed controls usually need judgement: which colour fits your brand, which words describe an image. The rules that report them have the tier ASSISTED, as the lists below show. For a finding of an ASSISTED rule, AI drafts a change only if you agreed to AI drafting, and Validiscan reviews every change before it is delivered.

The four recipes below are not the standard fix of any rule. Each one covers only the narrow cases in which the theme’s code alone proves what the change must be. Two of them, button-semantics and icon-accessible-name, also serve the slideshow check vs-carousel, which is AUTO: in a project the fix engine tries them on its findings when the pause button recipe above cannot fix them. The other two serve only rules of tier ASSISTED.

  • Text colours with enough contrast contrast-tokens A failing text colour defined in exactly one place moves to the nearest shade of its hue with the required contrast; not applied in a project.

    When text fails the contrast check and its colour is defined in exactly one place in the theme, that place changes to the nearest shade of the same hue that reaches the required contrast, so the design stays recognisable. One place can be a colour field in theme.json together with the SCSS variable that repeats it, an SCSS variable or custom property used only for text, or a single color declaration. A colour that is used in several places or also as a background or border, and Shopware’s core colour fields for the brand, borders, backgrounds, status messages and the buy button, are left to a person. A colour saved in the Administration’s theme settings overrides theme.json: if it was changed there, it has to be changed there too. The rule color-contrast has the tier ASSISTED, so in a project a contrast finding takes the ASSISTED way and this recipe is not applied to it.

    Rules it serves
    • color-contrast ASSISTED
    File types it changes
    .scss
    darkens the unit price colour to 4.5:1 with the same hue; axe clears it on both pages Example data

    Resources/app/storefront/src/scss/_variant-broken.scss Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 1 added, 1 removed

    Changes to Resources/app/storefront/src/scss/_variant-broken.scss: 1 added, 1 removed
    Line before Line after Change Code
    45 45 /* Light grey unit prices, 3.1:1 (color-contrast). */
    46 46 .product-price-unit,
    47 47 .product-detail-price-unit {
    48 Removed color: #929292;
    48 Added color: #757575;
    49 49 }
    50 50
    51 51 /* Fixed two-line product names (vs-text-spacing). */
  • Decorative images hidden from screen readers decorative-image-alt Only images the template proves decorative get an empty alt or aria-hidden; not applied in a project.

    An image is marked as decorative only when the template proves it adds nothing: it sits in a link or button that is named another way, it is an icon next to its own text label, or it is a spacer or divider. An <img> gets an empty alt, an image printed with Shopware’s sw_thumbnails tag gets an empty alt in its attributes, and SVG icons get aria-hidden="true", so screen readers skip them instead of reading out a file name. Every other image is content, such as the payment logos in a footer, and its text alternative needs words a person chooses. The rules this recipe serves have the tier ASSISTED, so in a project their findings take the ASSISTED way and this recipe is not applied to them.

    Rules it serves
    • image-alt ASSISTED
    • role-img-alt ASSISTED
    • svg-img-alt ASSISTED
    File types it changes
    .html.twig
    marks a seeded phone icon inside the hotline link decorative; image-alt stops firing there Example data

    Resources/views/storefront/layout/footer/footer.html.twig Changes to Resources/views/storefront/layout/footer/footer.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/layout/footer/footer.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    9 9 <div class="footer-column-content">
    10 10 <p class="footer-contact-hotline">
    11 11 {{ 'footer.serviceHotlineText'|trans }}
    12 Removed <a class="footer-contact-hotline-link" href="tel:+49405550192"><img src="{{ asset('bundles/storefront/assets/icon/default/headset.svg') }}" class="footer-contact-hotline-icon" width="16" height="16"> {{ shop.company.phone }}</a>
    12 Added <a class="footer-contact-hotline-link" href="tel:+49405550192"><img src="{{ asset('bundles/storefront/assets/icon/default/headset.svg') }}" class="footer-contact-hotline-icon" width="16" height="16" alt=""> {{ shop.company.phone }}</a>
    13 13 </p>
    14 14 <p class="footer-contact-hours">{{ 'footer.serviceHours'|trans }}</p>
    15 15 <p class="footer-contact-form">
  • Mouse-only controls turned into buttons button-semantics Mouse-only controls in sliders and cookie banners become real buttons, with their classes, data attributes and ids kept; in a project the recipe is tried on slideshow findings of vs-carousel.

    Clickable <div>, <span>, <i> and <li> elements in sliders and cookie banners, and links without an address, that only react to a mouse become real buttons. Classes, data attributes and ids stay, so the theme’s script finds them as before, and Enter and Space now do what a click does. A list item keeps its place in the list and gets the button inside it. A control without text gets a translated name from its purpose, such as “Show slide 2”, and a small style reset keeps its look. A control without text whose purpose is unclear, or whose styling depends on its tag or its position, is left to a person. In a project this recipe is tried on slideshow findings of vs-carousel; findings of the cookie banner check vs-cookie-banner have the tier ASSISTED and take the ASSISTED way.

    Rules it serves
    • vs-carousel AUTO
    • vs-cookie-banner ASSISTED
    File types it changes
    .html.twig.json.scss
    turns the mouse-only slider dots into named buttons in their list items; vs-carousel stops reporting them Example data

    Resources/app/storefront/src/scss/base.scss Changes to Resources/app/storefront/src/scss/base.scss: 17 added, 0 removed

    Changes to Resources/app/storefront/src/scss/base.scss: 17 added, 0 removed
    Line before Line after Change Code
    1708 1708 margin: 8px 0 0;
    1709 1709 font-size: 0.875rem;
    1710 1710 }
    1711 Added
    1712 Added /* validiscan:button-semantics */
    1713 Added /* Former <div>/<span>/<li> controls, now <button>: drop the browser button styles, keep the theme's (no specificity). */
    1714 Added :where(button.base-slider-dot) {
    1715 Added display: block;
    1716 Added margin: 0;
    1717 Added padding: 0;
    1718 Added border: 0;
    1719 Added background: none;
    1720 Added color: inherit;
    1721 Added font: inherit;
    1722 Added line-height: inherit;
    1723 Added text-align: inherit;
    1724 Added cursor: pointer;
    1725 Added appearance: none;
    1726 Added }
    1727 Added /* /validiscan:button-semantics */

    Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 5 added, 0 removed

    Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 5 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
    194 194 "backToHome": "Zurück zur Startseite",
    195 195 "searchInstead": "Unsere Kaffees durchsuchen"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "showSlide": "Folie %index% anzeigen"
    200 Added }
    196 201 }
    197 202 }

    Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 5 added, 0 removed

    Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 5 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "The page you are looking for does not exist or has moved.",
    194 194 "backToHome": "Back to the homepage",
    195 195 "searchInstead": "Search our coffees"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "showSlide": "Show slide %index%"
    200 Added }
    196 201 }
    197 202 }

    Resources/views/storefront/element/cms-element-image-slider.html.twig Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    51 51 </button>
    52 52 <ol class="base-slider-dots">
    53 53 {% for slide in slides %}
    54 Removed <li class="base-slider-dot{% if loop.first %} is-active{% endif %}" data-slide-to="{{ loop.index0 }}"></li>
    54 Added <li><button class="base-slider-dot{% if loop.first %} is-active{% endif %}" data-slide-to="{{ loop.index0 }}" type="button" aria-label="{{ 'a11y.control.showSlide'|trans({'%index%': loop.index})|striptags }}"></button></li>
    55 55 {% endfor %}
    56 56 </ol>
    57 57 <button type="button" class="base-slider-controls-next image-slider-controls-btn" data-slide-next>
  • Names for icon-only buttons and links icon-accessible-name Icon-only buttons and links get a translated name that says what they do, where the code reveals their purpose; in a project the recipe is tried on the slideshow controls that vs-carousel reports.

    Buttons and links that show only an icon, such as search, cart, account, the logo link, slider arrows, quantity buttons and cookie choices, get a translated aria-label that says what they do. The purpose is read from the control’s classes, its link target, the icon inside it and the component around it. A control whose purpose the code does not reveal, such as a social link whose network is stored as data, is left to a person, because an invented name is worse than none. In a project this recipe is tried on the slideshow controls that vs-carousel reports; other icon-only controls are reported by rules of tier ASSISTED and take the ASSISTED way.

    Rules it serves
    • button-name ASSISTED
    • link-name ASSISTED
    • vs-carousel AUTO
    • vs-cookie-banner ASSISTED
    File types it changes
    .html.twig.json
    names every icon-only button of the home, product and cart pages; button-name no longer fires Example data

    Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 12 added, 0 removed

    Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 12 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
    194 194 "backToHome": "Zurück zur Startseite",
    195 195 "searchInstead": "Unsere Kaffees durchsuchen"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "search": "Suchen",
    200 Added "previousSlide": "Vorherige Folie",
    201 Added "nextSlide": "Nächste Folie",
    202 Added "cookieSettings": "Cookie-Einstellungen",
    203 Added "acceptCookies": "Alle Cookies akzeptieren",
    204 Added "decreaseQuantity": "Menge verringern",
    205 Added "increaseQuantity": "Menge erhöhen",
    206 Added "removeFromCart": "Aus dem Warenkorb entfernen"
    207 Added }
    196 208 }
    197 209 }

    Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 12 added, 0 removed

    Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 12 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "The page you are looking for does not exist or has moved.",
    194 194 "backToHome": "Back to the homepage",
    195 195 "searchInstead": "Search our coffees"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "search": "Search",
    200 Added "previousSlide": "Previous slide",
    201 Added "nextSlide": "Next slide",
    202 Added "cookieSettings": "Cookie settings",
    203 Added "acceptCookies": "Accept all cookies",
    204 Added "decreaseQuantity": "Decrease quantity",
    205 Added "increaseQuantity": "Increase quantity",
    206 Added "removeFromCart": "Remove from cart"
    207 Added }
    196 208 }
    197 209 }

    Resources/views/storefront/element/cms-element-image-gallery.html.twig Changes to Resources/views/storefront/element/cms-element-image-gallery.html.twig: 2 added, 2 removed

    Changes to Resources/views/storefront/element/cms-element-image-gallery.html.twig: 2 added, 2 removed
    Line before Line after Change Code
    31 31
    32 32 {% if media|length > 1 %}
    33 33 <div class="gallery-slider-controls">
    34 Removed <button type="button" class="base-slider-controls-prev gallery-slider-controls-prev" data-gallery-prev>
    34 Added <button type="button" class="base-slider-controls-prev gallery-slider-controls-prev" data-gallery-prev aria-label="{{ 'a11y.control.previousSlide'|trans|striptags }}">
    35 35 {% sw_icon 'arrow-head-left' %}
    36 36 </button>
    37 Removed <button type="button" class="base-slider-controls-next gallery-slider-controls-next" data-gallery-next>
    37 Added <button type="button" class="base-slider-controls-next gallery-slider-controls-next" data-gallery-next aria-label="{{ 'a11y.control.nextSlide'|trans|striptags }}">
    38 38 {% sw_icon 'arrow-head-right' %}
    39 39 </button>
    40 40 </div>

    Resources/views/storefront/element/cms-element-image-slider.html.twig Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 2 added, 2 removed

    Changes to Resources/views/storefront/element/cms-element-image-slider.html.twig: 2 added, 2 removed
    Line before Line after Change Code
    46 46
    47 47 {% block element_image_slider_controls %}
    48 48 <div class="image-slider-controls-container">
    49 Removed <button type="button" class="base-slider-controls-prev image-slider-controls-btn" data-slide-prev>
    49 Added <button type="button" class="base-slider-controls-prev image-slider-controls-btn" data-slide-prev aria-label="{{ 'a11y.control.previousSlide'|trans|striptags }}">
    50 50 {% sw_icon 'arrow-head-left' %}
    51 51 </button>
    52 52 <ol class="base-slider-dots">
    54 54 <li class="base-slider-dot{% if loop.first %} is-active{% endif %}" data-slide-to="{{ loop.index0 }}"></li>
    55 55 {% endfor %}
    56 56 </ol>
    57 Removed <button type="button" class="base-slider-controls-next image-slider-controls-btn" data-slide-next>
    57 Added <button type="button" class="base-slider-controls-next image-slider-controls-btn" data-slide-next aria-label="{{ 'a11y.control.nextSlide'|trans|striptags }}">
    58 58 {% sw_icon 'arrow-head-right' %}
    59 59 </button>
    60 60 </div>

    Resources/views/storefront/layout/cookie/cookie-permission.html.twig Changes to Resources/views/storefront/layout/cookie/cookie-permission.html.twig: 2 added, 2 removed

    Changes to Resources/views/storefront/layout/cookie/cookie-permission.html.twig: 2 added, 2 removed
    Line before Line after Change Code
    22 22 </p>
    23 23 </div>
    24 24 <div class="col-auto cookie-permission-actions">
    25 Removed <button type="button" class="btn cookie-permission-icon-btn js-cookie-configuration-toggle">{% sw_icon 'settings' %}</button>
    26 Removed <button type="button" class="btn btn-primary cookie-permission-icon-btn js-cookie-accept-all-button">{% sw_icon 'checkmark' %}</button>
    25 Added <button type="button" class="btn cookie-permission-icon-btn js-cookie-configuration-toggle" aria-label="{{ 'a11y.control.cookieSettings'|trans|striptags }}">{% sw_icon 'settings' %}</button>
    26 Added <button type="button" class="btn btn-primary cookie-permission-icon-btn js-cookie-accept-all-button" aria-label="{{ 'a11y.control.acceptCookies'|trans|striptags }}">{% sw_icon 'checkmark' %}</button>
    27 27 </div>
    28 28 </div>
    29 29 <p class="cookie-permission-configuration" hidden>{{ 'cookie.configurationText'|trans }}</p>

    Resources/views/storefront/layout/header/search.html.twig Changes to Resources/views/storefront/layout/header/search.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/layout/header/search.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    13 13 autocomplete="off"
    14 14 autocapitalize="off"
    15 15 placeholder="{{ 'header.searchPlaceholder'|trans }}" value="{{ page.searchTerm }}">
    16 Removed <button type="submit" class="btn header-search-btn">
    16 Added <button type="submit" class="btn header-search-btn" aria-label="{{ 'a11y.control.search'|trans|striptags }}">
    17 17 <span class="header-search-icon">{% sw_icon 'search' %}</span>
    18 18 </button>
    19 19 </div>

    Resources/views/storefront/page/checkout/cart/index.html.twig Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 3 added, 3 removed

    Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 3 added, 3 removed
    Line before Line after Change Code
    39 39 </div>
    40 40 <div class="line-item-quantity">
    41 41 <div class="input-group quantity-selector-group" data-quantity-selector="true">
    42 Removed <button type="button" class="btn btn-outline-light btn-minus js-btn-minus">
    42 Added <button type="button" class="btn btn-outline-light btn-minus js-btn-minus" aria-label="{{ 'a11y.control.decreaseQuantity'|trans|striptags }}">
    43 43 {% sw_icon 'minus' style { size: 'xs' } %}
    44 44 </button>
    45 45 <input type="number"
    50 50 max="100"
    51 51 step="1"
    52 52 value="{{ item.quantity }}">
    53 Removed <button type="button" class="btn btn-outline-light btn-plus js-btn-plus">
    53 Added <button type="button" class="btn btn-outline-light btn-plus js-btn-plus" aria-label="{{ 'a11y.control.increaseQuantity'|trans|striptags }}">
    54 54 {% sw_icon 'plus' style { size: 'xs' } %}
    55 55 </button>
    56 56 </div>
    64 64 <span class="line-item-total-price-value" data-line-total>{{ item.totalPrice|currency }}*</span>
    65 65 </div>
    66 66 <div class="line-item-remove">
    67 Removed <button type="button" class="btn btn-light btn-sm line-item-remove-button">
    67 Added <button type="button" class="btn btn-light btn-sm line-item-remove-button" aria-label="{{ 'a11y.control.removeFromCart'|trans|striptags }}">
    68 68 {% sw_icon 'x' style { size: 'xs' } %}
    69 69 </button>
    70 70 </div>

    Resources/views/storefront/page/product-detail/buy-widget.html.twig Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 2 added, 2 removed

    Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 2 added, 2 removed
    Line before Line after Change Code
    64 64 <div class="buy-widget-container">
    65 65 <div class="buy-widget-quantity">
    66 66 <div class="input-group quantity-selector-group" data-quantity-selector="true">
    67 Removed <button type="button" class="btn btn-outline-light btn-minus js-btn-minus">
    67 Added <button type="button" class="btn btn-outline-light btn-minus js-btn-minus" aria-label="{{ 'a11y.control.decreaseQuantity'|trans|striptags }}">
    68 68 {% sw_icon 'minus' style { size: 'xs' } %}
    69 69 </button>
    70 70 <input type="number"
    75 75 max="100"
    76 76 step="1"
    77 77 value="1">
    78 Removed <button type="button" class="btn btn-outline-light btn-plus js-btn-plus">
    78 Added <button type="button" class="btn btn-outline-light btn-plus js-btn-plus" aria-label="{{ 'a11y.control.increaseQuantity'|trans|striptags }}">
    79 79 {% sw_icon 'plus' style { size: 'xs' } %}
    80 80 </button>
    81 81 </div>
    names the logo and account links; social links whose network is data are left for review Example data

    Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 6 added, 0 removed

    Changes to Resources/snippet/de_DE/kaffeekontor-nord.de-DE.json: 6 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
    194 194 "backToHome": "Zurück zur Startseite",
    195 195 "searchInstead": "Unsere Kaffees durchsuchen"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "home": "Zur Startseite",
    200 Added "account": "Ihr Konto"
    201 Added }
    196 202 }
    197 203 }

    Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 6 added, 0 removed

    Changes to Resources/snippet/en_GB/kaffeekontor-nord.en-GB.json: 6 added, 0 removed
    Line before Line after Change Code
    193 193 "notFoundText": "The page you are looking for does not exist or has moved.",
    194 194 "backToHome": "Back to the homepage",
    195 195 "searchInstead": "Search our coffees"
    196 Added },
    197 Added "a11y": {
    198 Added "control": {
    199 Added "home": "Go to the homepage",
    200 Added "account": "Your account"
    201 Added }
    196 202 }
    197 203 }

    Resources/views/storefront/layout/header/actions.html.twig Changes to Resources/views/storefront/layout/header/actions.html.twig: 2 added, 1 removed

    Changes to Resources/views/storefront/layout/header/actions.html.twig: 2 added, 1 removed
    Line before Line after Change Code
    3 3 {% block layout_header_actions_account %}
    4 4 <div class="header-account">
    5 5 <a href="{{ url('/account/login') }}"
    6 Removed class="btn account-menu-btn header-actions-btn">
    6 Added class="btn account-menu-btn header-actions-btn"
    7 Added aria-label="{{ 'a11y.control.account'|trans|striptags }}">
    7 8 {% sw_icon 'avatar' %}
    8 9 </a>
    9 10 </div>

    Resources/views/storefront/layout/header/logo.html.twig Changes to Resources/views/storefront/layout/header/logo.html.twig: 1 added, 1 removed

    Changes to Resources/views/storefront/layout/header/logo.html.twig: 1 added, 1 removed
    Line before Line after Change Code
    2 2 <div class="header-logo-main">
    3 3 {% block layout_header_logo_link %}
    4 4 {# The agency removed the default title="Go to homepage" and the image alt to "avoid duplicate announcements". #}
    5 Removed <a class="header-logo-main-link" href="{{ url('/') }}">
    5 Added <a class="header-logo-main-link" href="{{ url('/') }}" aria-label="{{ 'a11y.control.home'|trans|striptags }}">
    6 6 {% block layout_header_logo_image %}
    7 7 <picture class="header-logo-picture">
    8 8 <img src="{{ asset('bundles/storefront/assets/logo/kaffeekontor-nord-logo.svg') }}"
Details: the tiers, the test theme, the checks on each change, and text in your shop’s languages

A recipe is a deterministic change for one kind of failure: the same code always gets the same change. Validiscan has 20 recipes for Shopware themes. Each one makes a change only where the theme’s code shows what the right change is. Where it does not, the recipe changes nothing. It does not guess.

Under each recipe you find the rules it serves, with each rule’s tier in Validiscan’s catalogue for Shopware. AUTO means a recipe is the rule’s standard fix. ASSISTED means the fix needs judgement, so a change is drafted and then reviewed. MANUAL means the rule needs a person’s decision and comes with guidance, not with a code change. In a project, recipes are applied to findings of rules of tier AUTO. Where a recipe also lists a rule of tier ASSISTED or MANUAL, a finding of that rule takes the ASSISTED or MANUAL way instead. A rule that is checked through another check does not run on its own in a scan; the other check reports the same failure.

The file types and the before and after of each recipe come from Validiscan’s own tests. They run on Validiscan’s Shopware test theme, a theme plugin on Shopware 6’s default storefront with accessibility failures put in on purpose. Each test renders the theme’s templates into pages before and after the change and runs the same rule on both: the failure must be reported before and no longer after, and the change must not cause a new failure of that rule. The pages are rendered by a test renderer, not by a full Shopware installation, and your theme’s files will look different.

Before Validiscan reviews a change, every changed file is checked. Twig templates are parsed with Shopware’s own tags, such as sw_extends and sw_include, known to the parser; they are never compiled or run. Stylesheets get a structure check, snippet files and theme.json are parsed as JSON, and JavaScript gets a syntax check.

When a recipe adds visible text or a name, such as the words of a skip link or the name of an icon button, it uses a snippet in the template. The text goes into the theme’s English snippet file, which the recipe creates if the theme has none, and into its German snippet file where the theme has one. Snippets that already exist keep their text. Text for the other languages of your shop is not added.

What stays MANUAL on Shopware?

Failures that need a decision about content or behaviour that a fixed code change cannot make, such as captions and transcripts for your videos and audio, stay MANUAL on Shopware. For a finding of a MANUAL rule, Validiscan gives guidance on how to fix it, not a change to your code.

The rules below run in scans and have the tier MANUAL on Shopware.

  • meta-refresh Delayed refresh under 20 hours must not be used
  • nested-interactive Interactive controls must not be nested
  • scrollable-region-focusable Scrollable region must have keyboard access
  • server-side-image-map Server-side image maps must not be used
  • vs-keyboard-trap Keyboard focus must never get trapped
  • vs-focus-order Focus order must follow the visual order
  • vs-dragging Drag-only controls need a single-pointer alternative
  • vs-media Video needs captions, audio needs a transcript, and nothing may autoplay with sound
  • vs-documents Linked documents need a manual accessibility review
Details: what a MANUAL finding gets, and what a project leaves out

For a finding of a MANUAL rule, Validiscan gives guidance on how to fix it, not a change to your code. These failures need a decision about content or behaviour that a fixed code change cannot make, for example captions and transcripts for your videos and audio, a way out of a widget that traps keyboard focus, or a second way to use a control that only works by dragging.

The work on MANUAL findings that need your own content decisions is not part of a project, and neither are PDFs. What the scan tests automatically and what is checked by hand

What do you provide?

You provide permission for a full scan, one way into your theme plugin, your answer on whether AI may draft ASSISTED fixes, a developer who reviews, builds and deploys the changes, some Administration settings, new text in languages other than English and German, and your decisions on what stays MANUAL.

  1. Your permission for a full scan of your shop, which Validiscan records before the scan. A full scan puts a product in the cart and opens the first checkout step, without completing a purchase or entering payment data.
  2. One way into your theme: access to the GitHub repository that holds the theme plugin, or a ZIP of the plugin’s folder under custom/plugins, uploaded in the customer portal.
  3. Your answer on whether AI may draft fixes of tier ASSISTED. AI drafting is off until you agree.
  4. A developer who reviews the changes, merges the pull request or applies the patch bundle, builds the theme and deploys it.
  5. The settings in the Shopware Administration that the fixes do not change: meta titles for pages that have none, the language of content written in another language than your shop, and a colour saved in the theme settings when a contrast fix changes that colour.
  6. Text for the languages of your shop other than English and German, where a fix adds new text.
  7. Your decisions on what stays MANUAL, such as captions for your videos.
Details: after the changes are live, and what one project covers

Once the changes are live, a verification scan checks the affected pages again, and the Remediation Evidence Package records the result. How a project runs, step by step

A project covers one shop: one domain and one theme. It is for a storefront built on a theme; a headless storefront is on request.

Sources

2 sources, each checked for this page on 6 October 2026.

Shopware’s developer documentation 2
  1. Add SCSS Styling and JavaScript to a Theme · Shopware · checked
  2. Add custom JavaScript · Shopware · checked

Start with a free check of your shop.

Send your shop’s address. Validiscan scans it and replies with every failure the scan can prove, with the page and the code.

Request a free check

Or write to info@validiscan.com.

What the free check covers, and what it leaves out
You send
your shop’s address
Pages scanned
up to 15
You get
every failure the scan can prove, with the page and the code
On your shop
public pages only; no login, nothing bought, no forms sent
What it is not
a full audit

Write to Validiscan

For a free check, give the address of your shop and its platform.

Validiscan uses what you send to answer you. Privacy notice