# Responsive and Accessible Markup

https://dev.wisecp.com/es/responsive-and-accessible-markup

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

| Variable | Shape | Resolved 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

```php
// 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;
```

```php
// coremio/classes/Language.php
public static function selected(): string;
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;
```

```php
// 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.

| Query | Tier | What 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.

| Class | Value | At a 16px root | Use for |
| --- | --- | --- | --- |
| `fs-7` | 0.85rem | 13.6px | Secondary text: card bodies, list rows, table cells. |
| `fs-8` | 0.8rem | 12.8px | Hints and helper lines under a control. |
| `fs-9` | 0.7rem | 11.2px | The floor. Badges and micro labels only. |
| Headings | Bootstrap scale | The fifth heading step is 20px | Section titles. A helper line is never larger than its label. |

### Motion and Focus

| Preference | What the theme does | How 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

| Need | Correct markup | What 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

| Content | Wrapper | Without 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.

```html
<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.

```smarty
<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.

## Related Articles

- [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy)
- [Theme Assets](https://dev.wisecp.com/en/theme-assets)
- [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme)
- [Page Surfaces](https://dev.wisecp.com/en/page-surfaces)
