- I made both surfaces friendlier to screen readers.

CMS changes landed in 
[layout.php](/Users/tyemeclifford/Documents/GH/blog/themes/neon/layout.php), 
[style.css](/Users/tyemeclifford/Documents/GH/blog/themes/neon/assets/style.css), 
[app.js](/Users/tyemeclifford/Documents/GH/blog/themes/neon/assets/app.js), 
and 
[lone-embed.php](/Users/tyemeclifford/Documents/GH/blog/lone-embed.php): 
skip link, named search/forms, current page state, live notices/empty 
states, labelled media/embed controls, visible focus states, theme 
control pressed states, and a focus-trapped/lightbox dialog with proper 
ARIA.
Mac app changes landed in 
[main.m](/Users/tyemeclifford/Documents/GH/blog/macclient/AppKitClient/main.m), 
and I rebuilt 
[TCMS](/Users/tyemeclifford/Documents/GH/blog/macclient/TCMS.app/Contents/MacOS/TCMS): 
AppKit controls/tables/fields/previews now get accessibility 
labels/help/value text, workspace sections are named, media/comment 
tables expose useful context, and status messages are announced via 
macOS accessibility notifications.
This commit is contained in:
Ty Clifford
2026-07-05 23:36:27 -04:00
parent 0b28e719ee
commit 75d38d4eb8
6 changed files with 298 additions and 64 deletions
+66 -10
View File
@@ -3,19 +3,33 @@
var storedMode = localStorage.getItem('neon-mode') || 'dark';
var storedAccent = localStorage.getItem('neon-accent') || 'green';
var modeButton = document.querySelector('[data-mode-toggle]');
var accentButtons = document.querySelectorAll('[data-accent]');
function labelAccent(accent) {
return accent.charAt(0).toUpperCase() + accent.slice(1);
}
function applyMode(mode) {
root.dataset.mode = mode;
localStorage.setItem('neon-mode', mode);
if (modeButton) {
modeButton.textContent = mode === 'light' ? 'Dark' : 'Light';
modeButton.setAttribute('aria-label', mode === 'light' ? 'Use dark mode' : 'Use light mode');
modeButton.textContent = 'Light';
modeButton.setAttribute('aria-label', 'Light mode');
modeButton.setAttribute('aria-pressed', mode === 'light' ? 'true' : 'false');
modeButton.title = mode === 'light' ? 'Disable light mode' : 'Enable light mode';
}
}
function applyAccent(accent) {
root.dataset.accent = accent;
localStorage.setItem('neon-accent', accent);
accentButtons.forEach(function (button) {
var selected = button.dataset.accent === accent;
var name = labelAccent(button.dataset.accent || 'accent');
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
button.setAttribute('aria-label', selected ? name + ' accent selected' : 'Use ' + name.toLowerCase() + ' accent');
button.title = selected ? name + ' accent selected' : 'Use ' + name.toLowerCase() + ' accent';
});
}
applyMode(storedMode);
@@ -27,7 +41,7 @@
});
}
document.querySelectorAll('[data-accent]').forEach(function (button) {
accentButtons.forEach(function (button) {
button.addEventListener('click', function () {
applyAccent(button.dataset.accent || 'green');
});
@@ -145,14 +159,15 @@
lightbox.hidden = true;
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-label', 'Post media slideshow');
lightbox.setAttribute('aria-labelledby', 'tcms-lightbox-title');
lightbox.setAttribute('aria-describedby', 'tcms-lightbox-caption');
lightbox.innerHTML = [
'<button class="tcms-lightbox__backdrop" type="button" data-lightbox-close aria-label="Close media viewer"></button>',
'<section class="tcms-lightbox__dialog" role="document">',
'<header class="tcms-lightbox__bar">',
'<strong class="tcms-lightbox__title"></strong>',
'<span class="tcms-lightbox__count"></span>',
'<strong class="tcms-lightbox__title" id="tcms-lightbox-title"></strong>',
'<span class="tcms-lightbox__count" aria-live="polite"></span>',
'<button class="tcms-lightbox__close" type="button" data-lightbox-close aria-label="Close">Close</button>',
'</header>',
'<div class="tcms-lightbox__stage">',
@@ -160,7 +175,7 @@
'<div class="tcms-lightbox__media"></div>',
'<button class="tcms-lightbox__nav tcms-lightbox__nav--next" type="button" data-lightbox-next aria-label="Next media">Next</button>',
'</div>',
'<p class="tcms-lightbox__caption"></p>',
'<p class="tcms-lightbox__caption" id="tcms-lightbox-caption"></p>',
'</section>'
].join('');
@@ -198,6 +213,7 @@
mediaFrame.textContent = '';
titleNode.textContent = entry.title;
countNode.textContent = (currentIndex + 1) + ' of ' + entries.length;
countNode.setAttribute('aria-label', 'Media ' + (currentIndex + 1) + ' of ' + entries.length);
captionNode.textContent = entry.title;
if (entry.type === 'image') {
@@ -219,6 +235,7 @@
video.controls = true;
video.playsInline = true;
video.preload = 'metadata';
video.setAttribute('aria-label', entry.title);
if (entry.poster) {
video.poster = entry.poster;
}
@@ -253,11 +270,47 @@
lightbox.hidden = true;
mediaFrame.textContent = '';
document.body.classList.remove('tcms-lightbox-open');
if (lastFocus && typeof lastFocus.focus === 'function') {
if (lastFocus && document.contains(lastFocus) && typeof lastFocus.focus === 'function') {
lastFocus.focus();
}
}
function trapLightboxFocus(event) {
var focusable;
var first;
var last;
if (!lightbox || lightbox.hidden || event.key !== 'Tab') {
return;
}
focusable = Array.prototype.slice.call(lightbox.querySelectorAll([
'button:not([disabled])',
'a[href]',
'iframe',
'video[controls]',
'[tabindex]:not([tabindex="-1"])'
].join(','))).filter(function (element) {
return !element.hidden && element.offsetParent !== null;
});
if (!focusable.length) {
event.preventDefault();
closeButton.focus();
return;
}
first = focusable[0];
last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function openFromEvent(event, index) {
event.preventDefault();
event.stopPropagation();
@@ -277,8 +330,9 @@
}
if (tag === 'img') {
trigger.setAttribute('role', 'button');
trigger.setAttribute('aria-label', 'Open image viewer');
trigger.setAttribute('aria-label', 'Open image viewer for ' + entry.title);
}
trigger.setAttribute('aria-haspopup', 'dialog');
trigger.addEventListener('click', function (event) {
openFromEvent(event, index);
});
@@ -294,7 +348,8 @@
button.className = 'tcms-lightbox-trigger';
button.type = 'button';
button.textContent = 'View';
button.setAttribute('aria-label', 'Open media viewer');
button.setAttribute('aria-haspopup', 'dialog');
button.setAttribute('aria-label', 'Open media viewer for ' + entry.title);
button.addEventListener('click', function (event) {
openFromEvent(event, index);
});
@@ -319,6 +374,7 @@
} else if (event.key === 'ArrowLeft') {
showLightboxItem(currentIndex - 1);
}
trapLightboxFocus(event);
});
}
+46
View File
@@ -39,6 +39,15 @@
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
html {
min-height: 100%;
}
@@ -67,6 +76,15 @@ a:hover {
color: var(--accent);
}
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
[tabindex="-1"]:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 72%, white);
outline-offset: 3px;
}
button,
input,
textarea {
@@ -94,6 +112,26 @@ button:hover {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.skip-link {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 2000;
transform: translateY(-160%);
border: 1px solid color-mix(in srgb, var(--accent) 72%, white);
border-radius: var(--radius);
padding: 0.55rem 0.75rem;
color: #06100b;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
font-weight: 800;
text-decoration: none;
box-shadow: var(--shadow);
}
.skip-link:focus {
transform: translateY(0);
}
input,
textarea {
width: 100%;
@@ -221,6 +259,14 @@ textarea {
.swatch--purple { background: #a084ff; }
.swatch--orange { background: #ff9f43; }
.mode-toggle[aria-pressed="true"],
.swatch[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--accent) 80%, white);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent),
0 0 18px color-mix(in srgb, var(--accent) 34%, transparent);
}
.site-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
+34 -31
View File
@@ -11,13 +11,13 @@ $description = $item['summary'] ?? $site['description'] ?? '';
$postCard = static function (array $entry): void { ?>
<article class="post-card">
<div class="post-card__meta">
<a href="<?= View::url('category/' . ContentRepository::slugify((string) $entry['category'])) ?>"><?= View::e($entry['category']) ?></a>
<span><?= View::date((string) $entry['date']) ?></span>
<a href="<?= View::url('category/' . ContentRepository::slugify((string) $entry['category'])) ?>" aria-label="Category: <?= View::e($entry['category']) ?>"><?= View::e($entry['category']) ?></a>
<time datetime="<?= View::e($entry['date']) ?>"><?= View::date((string) $entry['date']) ?></time>
</div>
<h2><a href="<?= View::itemUrl($entry) ?>"><?= View::e($entry['title']) ?></a></h2>
<p><?= View::e($entry['summary']) ?></p>
<?php if (!empty($entry['tags'])): ?>
<div class="tag-row">
<div class="tag-row" aria-label="Tags">
<?php foreach ($entry['tags'] as $tag): ?>
<a href="<?= View::url('tag/' . ContentRepository::slugify((string) $tag)) ?>"><?= View::e($tag) ?></a>
<?php endforeach; ?>
@@ -35,9 +35,9 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<?php endif; ?>
<?php foreach (($pagination['numbers'] ?? []) as $number): ?>
<?php if ((int) $number === (int) $pagination['page']): ?>
<span class="pager pager--current" aria-current="page"><?= (int) $number ?></span>
<span class="pager pager--current" aria-current="page" aria-label="Page <?= (int) $number ?>"><?= (int) $number ?></span>
<?php else: ?>
<a class="pager pager--number" href="<?= $url((int) $number) ?>"><?= (int) $number ?></a>
<a class="pager pager--number" href="<?= $url((int) $number) ?>" aria-label="Go to page <?= (int) $number ?>"><?= (int) $number ?></a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next']): ?>
@@ -61,9 +61,10 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<script defer src="<?= View::asset('app.js') ?>"></script>
</head>
<body>
<a class="skip-link" href="#content">Skip to content</a>
<header class="site-header">
<a class="brand" href="<?= View::url('') ?>">
<span class="brand__mark"></span>
<span class="brand__mark" aria-hidden="true"></span>
<span>
<strong><?= View::e($site['title'] ?? "Ty Clifford's Content Management System") ?></strong>
<small><?= View::e($site['tagline'] ?? '') ?></small>
@@ -71,40 +72,42 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
</a>
<nav class="main-nav" aria-label="Primary">
<a href="<?= View::url('') ?>">Home</a>
<a href="<?= View::url('') ?>"<?php if (($routeType ?? '') === 'home'): ?> aria-current="page"<?php endif; ?>>Home</a>
<?php foreach ($navPages as $pageItem): ?>
<a href="<?= View::itemUrl($pageItem) ?>"><?= View::e($pageItem['title']) ?></a>
<a href="<?= View::itemUrl($pageItem) ?>"<?php if (($slug ?? '') === ($pageItem['slug'] ?? null)): ?> aria-current="page"<?php endif; ?>><?= View::e($pageItem['title']) ?></a>
<?php endforeach; ?>
<a href="<?= View::url('feed.xml') ?>">RSS</a>
</nav>
<form class="search-form" action="<?= View::url('search') ?>" method="get">
<input type="search" name="q" value="<?= View::e($search_query ?? '') ?>" placeholder="Search" aria-label="Search">
<form class="search-form" action="<?= View::url('search') ?>" method="get" role="search" aria-label="Site search">
<label class="sr-only" for="site-search">Search posts and pages</label>
<input id="site-search" type="search" name="q" value="<?= View::e($search_query ?? '') ?>" placeholder="Search" autocomplete="off">
<button type="submit" aria-label="Search">Go</button>
</form>
<div class="theme-tools" aria-label="Theme controls">
<button class="mode-toggle" type="button" data-mode-toggle aria-label="Toggle light mode">Light</button>
<div class="theme-tools" role="group" aria-label="Theme controls">
<button class="mode-toggle" type="button" data-mode-toggle aria-pressed="false">Light</button>
<?php foreach (['green', 'blue', 'red', 'pink', 'purple', 'orange'] as $accent): ?>
<button class="swatch swatch--<?= $accent ?>" type="button" data-accent="<?= $accent ?>" aria-label="<?= ucfirst($accent) ?> accent"></button>
<button class="swatch swatch--<?= $accent ?>" type="button" data-accent="<?= $accent ?>" aria-pressed="false" aria-label="Use <?= $accent ?> accent"></button>
<?php endforeach; ?>
</div>
</header>
<main class="site-shell">
<main class="site-shell" id="content" tabindex="-1">
<section class="content-lane">
<?php if ($flash): ?>
<div class="notice notice--<?= View::e($flash['type']) ?>"><?= View::e($flash['message']) ?></div>
<div class="notice notice--<?= View::e($flash['type']) ?>" role="<?= ($flash['type'] ?? '') === 'error' ? 'alert' : 'status' ?>" aria-live="<?= ($flash['type'] ?? '') === 'error' ? 'assertive' : 'polite' ?>"><?= View::e($flash['message']) ?></div>
<?php endif; ?>
<?php if ($template === 'listing'): ?>
<header class="page-heading">
<p><?= View::e($site['description'] ?? '') ?></p>
<h1><?= View::e($heading ?? $title ?? '') ?></h1>
<h1 id="page-title"><?= View::e($heading ?? $title ?? '') ?></h1>
</header>
<?php if (($search_query ?? null) !== null): ?>
<form class="wide-search" action="<?= View::url('search') ?>" method="get">
<input type="search" name="q" value="<?= View::e($search_query) ?>" placeholder="Search posts and pages" aria-label="Search posts and pages">
<form class="wide-search" action="<?= View::url('search') ?>" method="get" role="search" aria-label="Search archive">
<label class="sr-only" for="archive-search">Search posts and pages</label>
<input id="archive-search" type="search" name="q" value="<?= View::e($search_query) ?>" placeholder="Search posts and pages" autocomplete="off">
<button type="submit">Search</button>
</form>
<?php endif; ?>
@@ -114,7 +117,7 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<?php endforeach; ?>
</div>
<?php if (empty($items)): ?>
<p class="empty-state"><?= View::e($empty_message ?? 'No posts found.') ?></p>
<p class="empty-state" role="status"><?= View::e($empty_message ?? 'No posts found.') ?></p>
<?php endif; ?>
<?php $renderPagination($pagination, $pagination_url); ?>
<?php elseif ($template === 'single'): ?>
@@ -122,12 +125,12 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<header class="article__header">
<?php if ($item['type'] === 'post'): ?>
<div class="post-card__meta">
<a href="<?= View::url('category/' . ContentRepository::slugify((string) $item['category'])) ?>"><?= View::e($item['category']) ?></a>
<span><?= View::date((string) $item['date']) ?></span>
<a href="<?= View::url('category/' . ContentRepository::slugify((string) $item['category'])) ?>" aria-label="Category: <?= View::e($item['category']) ?>"><?= View::e($item['category']) ?></a>
<time datetime="<?= View::e($item['date']) ?>"><?= View::date((string) $item['date']) ?></time>
<span><?= View::e($item['author']) ?></span>
</div>
<?php endif; ?>
<h1><?= View::e($item['title']) ?></h1>
<h1 id="page-title"><?= View::e($item['title']) ?></h1>
<?php if (!empty($item['summary'])): ?>
<p><?= View::e($item['summary']) ?></p>
<?php endif; ?>
@@ -136,7 +139,7 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<?= $item['html'] ?>
</div>
<?php if (!empty($item['tags'])): ?>
<footer class="tag-row article-tags">
<footer class="tag-row article-tags" aria-label="Tags">
<?php foreach ($item['tags'] as $tag): ?>
<a href="<?= View::url('tag/' . ContentRepository::slugify((string) $tag)) ?>"><?= View::e($tag) ?></a>
<?php endforeach; ?>
@@ -148,7 +151,7 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<section class="comments" id="comments">
<h2>Comments</h2>
<?php if (empty($approved_comments)): ?>
<p class="empty-state">No comments yet.</p>
<p class="empty-state" role="status">No comments yet.</p>
<?php endif; ?>
<?php foreach ($approved_comments as $comment): ?>
<article class="comment">
@@ -162,15 +165,15 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<form class="comment-form" action="<?= View::url('comment/' . $item['slug']) ?>" method="post">
<div class="form-grid">
<label>Name <input name="author" required maxlength="120"></label>
<label>Email <input type="email" name="email" required maxlength="180"></label>
<label>Name <input name="author" required maxlength="120" autocomplete="name"></label>
<label>Email <input type="email" name="email" required maxlength="180" autocomplete="email"></label>
</div>
<label>Website <input type="url" name="site_url" maxlength="220"></label>
<label class="honey-field">Leave this empty <input name="<?= View::e($config->get('comments.honeypot_field', 'website_url')) ?>" tabindex="-1" autocomplete="off"></label>
<label>Website <input type="url" name="site_url" maxlength="220" autocomplete="url"></label>
<label class="honey-field" aria-hidden="true">Leave this empty <input name="<?= View::e($config->get('comments.honeypot_field', 'website_url')) ?>" tabindex="-1" autocomplete="off"></label>
<label>Comment <textarea name="body" required rows="6" maxlength="5000"></textarea></label>
<?php if ($config->get('comments.captcha', true)): ?>
<input type="hidden" name="captcha_token" value="<?= View::e($captcha['token']) ?>">
<label><?= View::e($captcha['question']) ?> <input name="captcha_answer" required inputmode="numeric"></label>
<label>Captcha: <?= View::e($captcha['question']) ?> <input name="captcha_answer" required inputmode="numeric" autocomplete="off"></label>
<?php endif; ?>
<button type="submit">Post Comment</button>
</form>
@@ -178,7 +181,7 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<?php endif; ?>
<?php else: ?>
<section class="error-view">
<h1><?= View::e($title ?? 'Error') ?></h1>
<h1 id="page-title"><?= View::e($title ?? 'Error') ?></h1>
<p><?= View::e($message ?? 'Something went wrong.') ?></p>
</section>
<?php endif; ?>
@@ -196,7 +199,7 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
$socialUrl = View::url(ltrim($socialUrl, '/'));
}
?>
<a href="<?= View::e($socialUrl) ?>" rel="me noopener" target="_blank"><?= View::e($entry['label'] ?? $network) ?></a>
<a href="<?= View::e($socialUrl) ?>" rel="me noopener" target="_blank"><?= View::e($entry['label'] ?? $network) ?><span class="sr-only"> opens in a new tab</span></a>
<?php endforeach; ?>
</div>
</section>