Responsive and Accessible Markup

2 views Markdown

The measurable contract a theme's markup must satisfy, and how each rule is proven.

Overview

Responsiveness and accessibility are not a review pass at the end. They are a few shell declarations plus rules you can measure on a live page.

Two obligations belong to the theme alone: the viewport declaration in every layout, and the root language and direction attributes.

Prerequisites

  • A theme with at least one layout: any template that opens its own document head.
  • Bootstrap 5 semantics. A version 4 class name is not an error: it is silently unstyled.
  • A browser you can measure in.

Structure

Layout Declarations

Three declarations per layout; the engine adds none.

  1. The viewport meta, exactly width=device-width, initial-scale=1. Without it a phone lays out at desktop width.
  2. The root language and writing direction, from the engine's variables.
  3. The direction-aware stylesheet choice before first paint, inside the head guard.

Audit, per theme: grep -L 'name="viewport"' templates/website/<Theme>/layouts/*.tpl must print nothing.

Engine Variables

VariableShapeResolved from
$ui_lang A language code, such as en or tr. The selected language, resolved again on every request.
$ui_dir Exactly ltr or rtl, never anything else. The language pack's own direction flag.
$setting Every manifest field, merged with saved values. The settings schema; operator-owned layout switches only.

Reference

Theme API

signatures
// coremio/classes/Theme.php
// Behind {asset path='...'}. Appends ?v= with the file's modification time for css and js ONLY.
// Fonts and images stay query-less on purpose: a versioned font preload would never match the
// query-less url() inside the font stylesheet, the preload is wasted and the face misses first paint.
public function assetUrl(string $path = ''): string;

// Behind {lang key='...' var='...'}. Every named argument except 'key' and 'g' becomes a
// {var} replacement in the value, which is how an accessible name carries a real number.
// Smarty only: the Twig function takes the key alone and forwards no replacements, so a
// Twig theme substitutes outside the call rather than shipping a literal {count} to a reader.
public function lang(string $key, array $vars = []): string;

// True when views/<view> exists in this theme, used to gate a shell before rendering into it.
public function viewExists(string $view): bool;
direction source
// coremio/classes/Language.php
public static function selected(): string;
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;
resolved per request
// The template only prints the result; it never resolves the direction itself.
$ui_lang = Language::selected();
$ui_dir  = Language::g("package/rtl") ? 'rtl' : 'ltr';
Theme::assetUrl() Resolves and versions a path inside the theme's assets. A hand-written link has no cache buster.
Theme::lang() Every visible string, and every invisible one: aria labels are text too.
Language::selected() The code in the root language attribute. Screen readers read pronunciation from it.

Breakpoints

A fourth query is a decision, not a detail.

QueryTierWhat belongs here
max-width: 575.98px Below the small tier Phone compaction: hiding a label, collapsing a toolbar.
max-width: 767.98px Below the medium tier Layout changes that outlive the phone.
min-width: 992px Large and up Desktop-only chrome. Written as a minimum, so the small screen is the default.
print Print Chrome that has no meaning on paper. One block.

Type Scale

A new size is never invented next to an existing one.

ClassValueAt a 16px rootUse for
fs-70.85rem13.6pxSecondary text: card bodies, list rows, table cells.
fs-80.8rem12.8pxHints and helper lines under a control.
fs-90.7rem11.2pxThe floor. Badges and micro labels only.
HeadingsBootstrap scaleThe fifth heading step is 20pxSection titles. A helper line is never larger than its label.

Motion and Focus

PreferenceWhat the theme doesHow to prove it
Reduced motion A global block clamps every animation to a hundredth of a millisecond and one iteration. Count elements still at zero opacity; the answer must be zero.
Keyboard focus Rings appear for keyboard traversal only. Tab through: every stop must be visible. Then click: no ring may remain.

Off-Screen Text

NeedCorrect markupWhat the wrong one does
A control with no visible text An aria label from the language file. Hard-coded text stays English everywhere else.
Text for screen readers only visually-hidden sr-only is the Bootstrap 4 name; Bootstrap 5 does not define it. A bundled icon library keeps it alive.
A landmark for keyboard users A nav element with an aria label, plus the current page marked. Unnamed navigation landmarks are indistinguishable in a landmark list.

Wide Content

ContentWrapperWithout it
A table with more columns than a phone can show A responsive table wrapper around the table element. The page itself scrolls sideways, and every surface on it inherits that.
A long unbroken string (a key, a token, a domain) Wrapping on the cell, not a width removal on the container. Widening the container pushes the last columns past the edge.
A code block or payload Its own scroll container. The same page-level sideways scroll, on pages with a long line.

Wide content scrolls inside its own box; the page body never does.

Example

The obligations, in layout-head order.

layouts/default.tpl
<html lang="{$ui_lang|default:'en'}" dir="{$ui_dir|default:'ltr'}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {* Before first paint: resolve the stored direction and write the matching Bootstrap file,
       so the first painted frame is already correct instead of flipping a frame later. *}
    <script>
      var storedDir = localStorage.getItem('acme-dir');
      var rtl = storedDir ? storedDir === 'rtl' : document.documentElement.getAttribute('dir') === 'rtl';
      document.documentElement.setAttribute('dir', rtl ? 'rtl' : 'ltr');
      document.write('<link rel="stylesheet" href="' +
        (rtl ? '{asset path="css/bootstrap.rtl.min.css"}' : '{asset path="css/bootstrap.min.css"}') + '">');
    </script>

    <link rel="stylesheet" href="{asset path='css/default.css'}">
</head>

The two markup patterns that carry most of the accessibility work.

partials/client-subnav.tpl
<nav class="client-subnav" aria-label="{lang key='website/index/subnav-aria'}">
    <ul>
        <li>
            <a class="client-subnav-link{if $subnav == 'services'} active{/if}"
               {if $subnav == 'services'} aria-current="page"{/if}
               href="{link route='services'}">
                <i class="bi bi-hdd-stack"></i>{lang key='website/index/subnav-services'}
                {if $client_badges.services > 0}
                    {* The number alone is meaningless out of context, so the badge names itself. *}
                    <span class="client-subnav-badge"
                          aria-label="{lang key='website/index/subnav-badge-services' count=$client_badges.services}">
                        {$client_badges_text.services}
                    </span>
                {/if}
            </a>
        </li>
    </ul>
</nav>

{* Bootstrap 5 name. 'sr-only' is not a Bootstrap 5 class; do not rely on it. *}
<span class="visually-hidden">{lang key='website/index/footer-payment-methods'}</span>

Those two variables arrive with the client page data, not from the engine.

$client_badges Integers keyed services, domains, invoices, support. A key is zero rather than absent when the section is off.
$client_badges_text The same keys, as display strings; past two digits it becomes a plus form. Print this inside the badge, the raw one inside the label.

The acceptance list, measured on a live page.

No sideways page scroll At 320 CSS pixels wide, document scroll width must not exceed window inner width.
Tap targets At least 44 CSS pixels on the shorter axis. Measure the box, not the icon.
Focus visible on keyboard, absent on pointer Tab through: every stop is visible. Click: no ring survives the press.
Reduced motion leaves nothing hidden With the preference on, no element stays at zero opacity.
Contrast 4.5 to 1 for body text, 3 to 1 for large text. Measure in light and in dark.
Right to left Flip the root direction attribute and re-walk. Nothing overlaps, nothing is clipped.

Pitfalls

A Bootstrap 4 class name is silently unstyled

Copied markup keeps working until the missing class was the one doing the work. Find the rule that styles it first.

One layout without the viewport meta breaks a whole flow

The obligation is per layout, not per theme. A checkout shell without it puts the purchase flow at desktop width.

Widening the container hides more than it shows

Removing a cell's width limit pushes the last columns off the edge. Let the value wrap instead.

Invisible text is still text

Aria labels are the easiest strings to hard-code, because nobody sees them in review.

A fix in one theme is a fix in all of them

Everything here is behaviour, not visual identity. A fix in one theme only leaves the same defect elsewhere.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.