<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Accessibility on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/accessibility/</link><description>Recent content in Accessibility on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.168.0</generator><language>en-us</language><lastBuildDate>Thu, 24 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/accessibility/feed.xml" rel="self" type="application/rss+xml"/><item><title>Adding a working skip link to Waaseyaa's operator shell</title><link>https://jonesrussell.github.io/blog/operator-shell-skip-link-accessibility/</link><pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/operator-shell-skip-link-accessibility/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>accessibility</blog:tag><blog:tag>twig</blog:tag><description>How Waaseyaa&amp;rsquo;s shared operator shell added a real skip link and hid decorative icons from assistive technology, including the focus trick that makes the link&amp;rsquo;s own jump work in every browser.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/waaseyaa/framework">Waaseyaa</a>&rsquo;s <code>operator</code> package ships a shared admin shell — sidebar, nav, dashboard grid — that every hosting instance&rsquo;s <code>/admin/anokii</code> reuses. It was missing two baseline accessibility requirements: a skip link as the first focusable element, and <code>aria-hidden</code> on its decorative SVG icons. Both landed in one fix, along with a browser quirk worth knowing if you&rsquo;re building a skip link yourself: a <code>&lt;main&gt;</code> you make focusable stays focusable for every click, not just the skip.</p>
<h2 id="what-was-missing">What Was Missing</h2>
<ul>
<li><strong>No skip link.</strong> The first focusable element in the shell was the first sidebar nav item. A keyboard or screen-reader user had no way to jump straight to the page content — they had to tab through the entire sidebar on every page.</li>
<li><strong>Decorative icons were exposed to assistive technology.</strong> The nav-link and dashboard-tile SVGs are pure decoration next to a text label, but nothing told screen readers to skip them. Each one added noise to the accessible name of its link or tile.</li>
</ul>
<h2 id="the-skip-link-and-why-it-needs-a-script">The Skip Link, and Why It Needs a Script</h2>
<p>The fix adds a <code>&lt;a class=&quot;anokii-skiplink&quot; href=&quot;#anokii-main-content&quot;&gt;</code> as the very first element in <code>&lt;body&gt;</code>, hidden until focused:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-css" data-lang="css"><span style="display:flex;"><span>.<span style="color:#a6e22e">anokii-skiplink</span>:<span style="color:#a6e22e">not</span><span style="color:#f92672">(</span>:<span style="color:#a6e22e">focus</span><span style="color:#f92672">)</span>{ <span style="color:#66d9ef">width</span>:<span style="color:#ae81ff">1</span><span style="color:#66d9ef">px</span>; <span style="color:#66d9ef">height</span>:<span style="color:#ae81ff">1</span><span style="color:#66d9ef">px</span>; <span style="color:#66d9ef">margin</span>:<span style="color:#ae81ff">-1</span><span style="color:#66d9ef">px</span>; <span style="color:#66d9ef">padding</span>:<span style="color:#ae81ff">0</span>; <span style="color:#66d9ef">overflow</span>:<span style="color:#66d9ef">hidden</span>; <span style="color:#66d9ef">clip</span>:rect(<span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0</span>); <span style="color:#66d9ef">white-space</span>:<span style="color:#66d9ef">nowrap</span>; <span style="color:#66d9ef">border</span>:<span style="color:#ae81ff">0</span>; }
</span></span></code></pre></div><p>That part is the standard pattern. The less obvious part is what happens when the link is activated. The target is <code>&lt;main class=&quot;anokii-main&quot; id=&quot;anokii-main-content&quot;&gt;</code> — and <code>&lt;main&gt;</code> isn&rsquo;t focusable by default, so a plain <code>href=&quot;#anokii-main-content&quot;</code> jump would scroll the page without moving keyboard focus there. The usual fix is <code>tabindex=&quot;-1&quot;</code> on the target. But adding that permanently changes the page: any ordinary mouse click inside <code>&lt;main&gt;</code> would now focus it too, and in Chromium the next <kbd>Tab</kbd> press would start over from the top of <code>&lt;main&gt;</code> instead of from wherever the user clicked.</p>
<p>The shell avoids that by making <code>&lt;main&gt;</code> focusable only for the moment of the skip:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span>(() =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">skip</span> <span style="color:#f92672">=</span> document.<span style="color:#a6e22e">querySelector</span>(<span style="color:#e6db74">&#39;.anokii-skiplink&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">main</span> <span style="color:#f92672">=</span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;anokii-main-content&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>(<span style="color:#a6e22e">skip</span> <span style="color:#66d9ef">instanceof</span> <span style="color:#a6e22e">HTMLAnchorElement</span>) <span style="color:#f92672">||</span> <span style="color:#f92672">!</span>(<span style="color:#a6e22e">main</span> <span style="color:#66d9ef">instanceof</span> <span style="color:#a6e22e">HTMLElement</span>)) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">skip</span>.<span style="color:#a6e22e">addEventListener</span>(<span style="color:#e6db74">&#39;click&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">main</span>.<span style="color:#a6e22e">setAttribute</span>(<span style="color:#e6db74">&#39;tabindex&#39;</span>, <span style="color:#e6db74">&#39;-1&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">main</span>.<span style="color:#a6e22e">addEventListener</span>(<span style="color:#e6db74">&#39;blur&#39;</span>, () =&gt; <span style="color:#a6e22e">main</span>.<span style="color:#a6e22e">removeAttribute</span>(<span style="color:#e6db74">&#39;tabindex&#39;</span>), { <span style="color:#a6e22e">once</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span> });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>})();
</span></span></code></pre></div><p>Clicking the skip link adds <code>tabindex=&quot;-1&quot;</code> right before the browser&rsquo;s own anchor jump runs, so <code>&lt;main&gt;</code> is focusable exactly when the jump needs it to be. The <code>blur</code> listener removes the attribute afterward, so <code>&lt;main&gt;</code> goes back to being non-focusable for everything else. A test locks in that <code>&lt;main&gt;</code> never carries <code>tabindex</code> in the rendered markup — only the script adds it, and only transiently.</p>
<h2 id="hiding-decorative-icons">Hiding Decorative Icons</h2>
<p>The nav-link and dashboard-tile icons got two attributes each:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">svg</span> <span style="color:#a6e22e">viewBox</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;0 0 24 24&#34;</span> <span style="color:#a6e22e">fill</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;none&#34;</span> <span style="color:#a6e22e">aria-hidden</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;true&#34;</span> <span style="color:#a6e22e">focusable</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;false&#34;</span>&gt;{{ item.icon|raw }}&lt;/<span style="color:#f92672">svg</span>&gt;
</span></span></code></pre></div><p><code>aria-hidden=&quot;true&quot;</code> removes the SVG from the accessibility tree; <code>focusable=&quot;false&quot;</code> stops old IE/Edge from tabbing into it (SVG elements were focusable by default in those browsers). The visible text label next to each icon — the <code>&lt;span&gt;</code> in a nav link, the <code>&lt;b&gt;</code> in a dashboard tile — already carries the accessible name, so hiding the icon doesn&rsquo;t lose any information. A regression test walks every icon-bearing element and asserts the exposed accessible text still starts with the visible label.</p>
<h2 id="testing-contrast-not-just-markup">Testing Contrast, Not Just Markup</h2>
<p>The skip link is only visible while focused, sitting on top of whatever chrome an instance has themed. <code>OperatorShellAccessibilityTest</code> doesn&rsquo;t just assert the CSS rule exists — it computes WCAG relative luminance from the shell&rsquo;s hardcoded colors and checks:</p>
<ul>
<li><strong>Text-to-background contrast</strong> on the link itself is at least <strong>4.5:1</strong>.</li>
<li><strong>Fill-or-border contrast against the sidebar</strong> is at least <strong>3:1</strong>, checked against the default sidebar color, an example instance&rsquo;s themed sidebar, and a light-mode sidebar.</li>
</ul>
<p>That&rsquo;s what justifies the fixed (non-CSS-variable) colors on <code>.anokii-skiplink</code>: a themeable color could pass contrast against one instance&rsquo;s chrome and fail against another&rsquo;s, and nothing would catch it until a real screen-reader user hit an invisible-on-focus link.</p>
<h2 id="what-ships-where">What Ships Where</h2>
<p>This is a shared package, so the fix reaches consumers on refresh, not on deploy:</p>
<table>
	<thead>
			<tr>
					<th>Scenario</th>
					<th>What happens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Default shell</td>
					<td>Skip link and hidden icons ship automatically once the host updates the <code>operator</code> package — there&rsquo;s no per-instance opt-in.</td>
			</tr>
			<tr>
					<td>Host overrides the <code>nav</code> block</td>
					<td>The host supplies its own markup and must add <code>aria-hidden=&quot;true&quot;</code> to its own icons the same way — the package can&rsquo;t reach into markup it doesn&rsquo;t render.</td>
			</tr>
	</tbody>
</table>
<h2 id="why-this-matters-beyond-waaseyaa">Why This Matters Beyond Waaseyaa</h2>
<ul>
<li><strong>A skip link&rsquo;s target needs <code>tabindex=&quot;-1&quot;</code> only for the moment it&rsquo;s used.</strong> Leave it on permanently and every mouse click inside the target changes tab order, not just the skip.</li>
<li><strong><code>aria-hidden</code> and <code>focusable=&quot;false&quot;</code> are a pair for inline SVG, not either/or.</strong> The first hides the icon from screen readers. The second stops IE11 and early Edge, where SVG was focusable by default, from tabbing into it anyway.</li>
<li><strong>Assert contrast ratios in tests, not just markup shape, when a component&rsquo;s colors are hardcoded for a reason.</strong> A themeable swap can pass against one instance&rsquo;s chrome and fail against another&rsquo;s, and nothing catches it until a screen-reader user hits an invisible-on-focus link.</li>
</ul>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>