/**
* fossil.craftdesign.group — Accessibility enhancements
*
* Loaded via <script defer src="/a11y.js"> in the Fossil skin header.
* Served as a static asset by Caddy; satisfies `script-src 'self'` CSP.
*
* Execution timing (defer):
* 1. HTML fully parsed
* 2. Deferred scripts run (this file) — window.fossil already set up by
* Fossil's earlier inline scripts, so we can patch config immediately
* 3. DOMContentLoaded fires
* 4. window.load fires — Fossil's timeline renderer creates graph elements
*
* We re-run timeline fixes on window.load to cover the graph elements
* that Fossil's JS inserts after the initial parse.
*/
(function () {
'use strict';
/* ─────────────────────────────────────────────────────────────────────
1. isDark patch
Fossil's C code hard-codes isDark:false for all custom skins,
ignoring the skin-detail config key. Patch it here after Fossil's
inline scripts have already run (guaranteed by defer).
───────────────────────────────────────────────────────────────────── */
function patchIsDark() {
/* Only for the dark variant: the light build of this skin embeds the
same script, and its stylesheet declares color-scheme: light. */
if (getComputedStyle(document.documentElement).colorScheme.indexOf('dark') < 0) return;
if (window.fossil && window.fossil.config && window.fossil.config.skin) {
window.fossil.config.skin.isDark = true;
}
document.body.classList.add('fossil-dark-style');
}
/* ─────────────────────────────────────────────────────────────────────
2. Main landmark
div.content is Fossil's primary content wrapper but has no landmark
role. Adding role="main" lets screen reader users jump to content.
───────────────────────────────────────────────────────────────────── */
function addMainLandmark() {
var content = document.querySelector('div.content');
if (content && !content.getAttribute('role')) {
content.setAttribute('role', 'main');
}
}
/* ─────────────────────────────────────────────────────────────────────
3. Hamburger menu — the worst accessibility failure in the skin
----------------------------------------------------------------
What Fossil gives us:
<a id="hbbtn" href="/repo/sitemap" aria-label="Site Map">☰</a>
<nav id="hbdrop" class="hbdrop" title="sitemap"></nav>
Problems:
- No aria-expanded: screen readers can't tell if the menu is open
- No aria-controls: no programmatic link between button and panel
- hbdrop has no role or accessible name beyond an unhelpful title
- When the menu opens, focus stays on hbbtn — user cannot reach
the menu links by keyboard without realising the menu opened
- Escape key does not close the menu
- hbmenu.js controls visibility via style.display; we use
MutationObserver to stay in sync rather than patching the JS
Fixes applied:
- aria-expanded / aria-controls on hbbtn
- role="navigation" + aria-label on hbdrop
- MutationObserver: sync aria-expanded on every show/hide
- On open: move focus to the first link in the dropdown
- On Escape inside dropdown: close panel, return focus to hbbtn
───────────────────────────────────────────────────────────────────── */
function fixHamburgerMenu() {
var btn = document.getElementById('hbbtn');
var drop = document.getElementById('hbdrop');
if (!btn || !drop) return;
var nav = btn.closest('nav.mainmenu') || btn.parentNode;
if (!nav) return;
/* Position the dropdown using position:fixed so it overlays content
regardless of DOM structure. We set --nav-bottom as a CSS variable
so the dropdown's top edge tracks the nav bar's bottom edge. */
function setNavBottom() {
var bottom = Math.max(0, nav.getBoundingClientRect().bottom);
document.documentElement.style.setProperty('--nav-bottom', bottom + 'px');
}
setNavBottom();
window.addEventListener('resize', setNavBottom);
/* Measure again on every open (capture: before hbmenu.js shows the
panel), so the menu opens under the nav bar wherever the page is. */
btn.addEventListener('click', setNavBottom, true);
/* Open by touch or mouse vs keyboard: only the keyboard gets focus moved */
var openedByPointer = false;
btn.addEventListener('pointerdown', function () { openedByPointer = true; });
btn.addEventListener('keydown', function () { openedByPointer = false; });
/* Static button semantics (JS will keep aria-expanded in sync) */
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', 'hbdrop');
/* Panel semantics */
drop.setAttribute('role', 'navigation');
drop.setAttribute('aria-label', 'Site map');
/* Sync aria-expanded whenever hbmenu.js toggles panel visibility.
hbmenu.js uses style.display to show/hide — observe that attribute. */
var menuObserver = new MutationObserver(function () {
var open = getComputedStyle(drop).display !== 'none';
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
/* Move focus into the panel when it opens so keyboard users
can immediately navigate the links without guessing. */
if (open) {
/* The nav bar may now be stuck to the top (see the skin CSS) */
requestAnimationFrame(setNavBottom);
if (!openedByPointer) {
var firstLink = drop.querySelector('a');
if (firstLink) firstLink.focus({ preventScroll: true });
}
}
});
menuObserver.observe(drop, { attributes: true, attributeFilter: ['style'] });
/* Escape inside the dropdown: close the panel and return focus.
We set display directly — this also triggers the MutationObserver
which will update aria-expanded to 'false'. */
drop.addEventListener('keydown', function (e) {
if (e.key === 'Escape' || e.keyCode === 27) {
drop.style.display = 'none';
btn.focus();
}
});
}
/* ─────────────────────────────────────────────────────────────────────
4. Timeline table
----------------------------------------------------------------
Structure Fossil generates:
<table class="timelineTable">
<tr class="timelineDateRow">
<td><div class="timelineDate">2026-03-14</div></td>
<td></td> <- empty filler for graph column
<td></td> <- empty filler for comment column
</tr>
<tr>
<td class="timelineTime">20:16</td>
<td class="timelineGraph">
<div class="tl-nodemark"></div> <- JS draws SVG here later
</td>
<td class="timelineModernCell" id="mc1">
<span class="timelineModernComment">commit message</span>
<span class="timelineModernDetail">...</span>
</td>
</tr>
</table>
Fixes applied:
- aria-label="Commit timeline" on each .timelineTable
- aria-hidden="true" on all td.timelineGraph cells
- aria-hidden="true" on filler cells in timelineDateRow rows
- aria-label="Time: HH:MM" on timelineTime cells
Run at defer-time AND on window.load because Fossil's timeline JS
inserts SVG canvas elements into the graph cells on window.load.
───────────────────────────────────────────────────────────────────── */
function fixTimeline() {
/* Label each timeline table */
document.querySelectorAll('table.timelineTable').forEach(function (tbl) {
if (!tbl.getAttribute('aria-label') && !tbl.querySelector('caption')) {
tbl.setAttribute('aria-label', 'Commit timeline');
}
});
/* Hide the graph column — purely visual SVG/canvas drawn by Fossil JS */
document.querySelectorAll('td.timelineGraph').forEach(function (td) {
td.setAttribute('aria-hidden', 'true');
});
/* Hide filler cells in date-separator rows */
document.querySelectorAll('tr.timelineDateRow').forEach(function (tr) {
tr.querySelectorAll('td + td').forEach(function (td) {
td.setAttribute('aria-hidden', 'true');
});
});
/* Give bare time cells a descriptive label.
Strip stray </a> text artefact Fossil emits in anonymous mode. */
document.querySelectorAll('td.timelineTime').forEach(function (td) {
var text = td.textContent.trim();
if (text && !td.getAttribute('aria-label')) {
td.setAttribute('aria-label', 'Time: ' + text);
}
});
}
/* ─────────────────────────────────────────────────────────────────────
5. Sortable table headers — add scope="col"
Fossil generates <th> elements in branch, tag, file, and ticket
tables but never includes scope. Screen readers cannot correctly
associate header text with the cells in each column.
───────────────────────────────────────────────────────────────────── */
function fixTableHeaders() {
var selector = [
'table.sortable th',
'table.filelistTable th',
'table.branchlistTable th',
'table.taglistTable th',
'table.userlistTable th',
'table.report th',
].join(', ');
document.querySelectorAll(selector).forEach(function (th) {
if (!th.getAttribute('scope')) {
th.setAttribute('scope', 'col');
}
});
}
/* ─────────────────────────────────────────────────────────────────────
6. Submenu select labels
Fossil renders <select> controls in the page submenu with no
associated <label>. We add aria-label from the preceding sibling
label text, falling back to a class-name lookup table.
───────────────────────────────────────────────────────────────────── */
var SELECT_LABELS = {
'smc-ss': 'View style',
'smc-ci': 'Check-in',
'smc-v': 'Version',
'smc-r': 'Repository',
'smc-b': 'Branch',
'smc-t': 'Tag',
'smc-u': 'User',
};
function fixSubmenuSelects() {
document.querySelectorAll('div.submenu select, div.sectionmenu select').forEach(function (sel) {
if (sel.getAttribute('aria-label') || sel.getAttribute('aria-labelledby')) return;
/* Walk backwards through siblings to find a .label text node */
var prev = sel.previousElementSibling;
while (prev) {
if (prev.classList.contains('label') && prev.textContent.trim()) {
sel.setAttribute('aria-label', prev.textContent.trim());
return;
}
prev = prev.previousElementSibling;
}
/* Class-name lookup */
var classes = Array.from(sel.classList);
for (var i = 0; i < classes.length; i++) {
if (SELECT_LABELS[classes[i]]) {
sel.setAttribute('aria-label', SELECT_LABELS[classes[i]]);
return;
}
}
sel.setAttribute('aria-label', 'Filter');
});
}
/* ─────────────────────────────────────────────────────────────────────
7. Day of week on each timeline date
Fossil's date rows carry only the date ("2026-11-11"). This puts the
full weekday on a small line above it. The weekday comes from Ben
Joffe's branchless method (benjoffe.com/fast-day-of-week), as zit
uses it: one multiply and two shifts, exact for every 32-bit day
number. It is read from the date Fossil prints, so it always matches
that date whether the timeline shows UTC or local time. The element
carries its own layout, so a stale cached stylesheet cannot misplace it.
───────────────────────────────────────────────────────────────────── */
var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function weekdayOf(dayNumber) { /* days since 1970-01-01 -> 0 = Sunday */
return ((Math.imul(dayNumber, 613566756) + 0x95000000 +
(dayNumber >> 1) + (dayNumber >> 4)) >>> 0) >>> 29;
}
function addWeekdays() {
var rows = document.querySelectorAll('div.timelineDate');
for (var i = 0; i < rows.length; i++) {
if (rows[i].querySelector('.tl-dow')) continue;
var m = /^(\d{4})-(\d\d)-(\d\d)$/.exec(rows[i].textContent.trim());
if (!m) continue;
var s = document.createElement('span');
s.className = 'tl-dow';
s.textContent = WEEKDAYS[weekdayOf(Date.UTC(+m[1], m[2] - 1, +m[3]) / 864e5)];
s.style.cssText = 'display:block;font-size:0.8em;font-weight:400;opacity:0.85;' +
'line-height:1.2;margin:0 0 0.1em 0;padding:0';
rows[i].insertBefore(s, rows[i].firstChild);
}
}
/* ─────────────────────────────────────────────────────────────────────
Execute all fixes
The script is deferred so the DOM is fully parsed when this runs.
Fossil's inline config scripts have already executed, so
window.fossil exists and we can patch isDark immediately.
───────────────────────────────────────────────────────────────────── */
patchIsDark();
addMainLandmark();
fixHamburgerMenu();
fixTimeline();
fixTableHeaders();
fixSubmenuSelects();
addWeekdays();
/* Re-run after window.load: Fossil's timeline renderer inserts
.tl-canvas SVG elements into td.timelineGraph on load, and
Fossil's own load handlers may reset isDark. */
window.addEventListener('load', function () {
patchIsDark();
fixTimeline();
fixTableHeaders();
fixSubmenuSelects();
});
}());