The testing bookmarklets have been superseded by a console snippet which works with any browser.
This opens a small testing panel on the page so you can turn checks on and off.
!function(){const t="__THREE_STEP_SELF_AUDIT__",e="tsa-root",n="tsa-report",i="tsa-style",a="tsa-tabstops-svg";window[t]&&window[t].destroy&&window[t].destroy();const o={options:{headings:!1,links:!1,tabStops:!1},cleanupFns:[],focusHandlers:[],observer:null};function s(t,e=document){return Array.from(e.querySelectorAll(t))}function r(t){return!(!t||!(t.closest("#"+e)||t.closest("#"+n)||t.closest("#"+a)))}function l(t){if(!t||!t.isConnected||r(t))return!1;if("function"==typeof t.checkVisibility)try{if(!t.checkVisibility())return!1}catch(t){}const e=getComputedStyle(t);if("none"===e.display||"hidden"===e.visibility||"0"===e.opacity)return!1;const n=t.getBoundingClientRect();return n.width>0&&n.height>0}function d(t){return String(t).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function c(){document.documentElement.removeAttribute("data-tsa-headings-active"),document.documentElement.removeAttribute("data-tsa-links-active"),document.documentElement.removeAttribute("data-tsa-tabstops-active"),s("[data-tsa-heading-note],[data-tsa-link-note],[data-tsa-tabstop]").forEach(t=>{t.removeAttribute("data-tsa-heading-note"),t.removeAttribute("data-tsa-link-note"),t.removeAttribute("data-tsa-tabstop"),t.classList.remove("tsa-tabable")}),o.focusHandlers.forEach(({el:t,onFocus:e,onBlur:n})=>{t.removeEventListener("focus",e),t.removeEventListener("blur",n)}),o.focusHandlers=[],o.cleanupFns.forEach(t=>{try{t()}catch(t){}}),o.cleanupFns=[];const t=document.getElementById(a);t&&t.remove();const i=document.getElementById(e),r=i?i.querySelector("#"+n):null;r&&(r.innerHTML=""),i&&i.classList.remove("tsa-has-report")}function p(){let t=document.getElementById(e);if(t)return t;!function(){const t=document.getElementById(i);if(t)return t;const o=document.createElement("style");o.id=i,o.textContent=`\n#${e}{position:fixed;top:16px;left:16px;z-index:2147483646;background:#f7f7f7;border:2px solid #ccc;padding:12px 14px;font:400 14px/1.4 Arial,sans-serif;color:#222;width:320px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);display:flex;flex-direction:column;gap:0;box-shadow:0 4px 14px rgba(0,0,0,.18);box-sizing:border-box}\n#${e} h2{margin:0 0 8px;font:700 16px/1.2 Arial,sans-serif;cursor:move}\n#${e} fieldset{border:0;padding:0;margin:8px 0 10px}\n#${e} label{display:flex;gap:8px;align-items:center;margin:6px 0}\n#${e} .tsa-controls{flex:0 0 auto}\n#${e} .tsa-help{margin:0 0 8px;color:#444;font-size:12px}\n#${n}{display:none;flex:1 1 auto;min-height:0;overflow:auto;background:#f7f7f7;border:0;border-top:1px solid #ccc;padding:4px 0 0;font:400 13px/1.4 Arial,sans-serif;color:#333;box-shadow:none;box-sizing:border-box;margin-top:8px}\n#${e}.tsa-has-report #${n}{display:block}\n#${n} h2{margin:0 0 8px;font:700 20px/1.2 Arial,sans-serif}\n#${n} h3{margin:28px 0 8px;font:700 17px/1.2 Arial,sans-serif}\n#${n} .tsa-report-help{margin:10px 0 10px;color:#333;font-size:12px}\n#${n} > :first-child h3,#${n} > h3:first-child{margin-top:0}\n#${n} ol, #${n} ul{margin:.7rem 0;padding-left:1rem}\n#${n} .tsa-indent{list-style:none;padding-left:0}\n#${n} .tsa-indent li{margin-top:6px}\n#${n} .tsa-tag{display:inline-block;background:green;color:#fff;padding:2px 4px 0;text-transform:uppercase;margin-right:6px}\n#${n} .tsa-error{color:#c00;font-weight:700}\n#${n} .tsa-img{color:#070}\n#${n} a,#${n} a:visited{color:#1b5e20!important;text-decoration:underline!important;text-underline-offset:2px;font-weight:500}\n#${n} .H1{padding-left:0} #${n} .H2{padding-left:24px} #${n} .H3{padding-left:48px} #${n} .H4{padding-left:72px} #${n} .H5{padding-left:96px} #${n} .H6{padding-left:120px}\nhtml[data-tsa-headings-active="true"] :is(h1,h2,h3,h4,h5,h6,[role="heading"]):not(#${e} *, #${n} *){--tsa-h-color:#000;--tsa-h-bg:#0c0;outline:2px solid var(--tsa-h-bg)!important;outline-offset:-2px;position:relative}\nhtml[data-tsa-headings-active="true"] :is(h1,h2,h3,h4,h5,h6,[role="heading"]):not(#${e} *, #${n} *)::after{content:attr(data-tsa-heading-note);color:var(--tsa-h-color)!important;background:var(--tsa-h-bg)!important;text-shadow:0 0 1px #000;box-sizing:border-box;z-index:2147483640;display:flex;align-items:center;position:absolute;inset:0 0 auto auto;padding:2px 6px;font:500 14px/1.3 Arial,sans-serif;max-width:55%;min-height:100%}\nhtml[data-tsa-headings-active="true"] [data-tsa-heading-note][data-tsa-heading-error="true"]{--tsa-h-color:#fff;--tsa-h-bg:#e50000;outline:4px dotted var(--tsa-h-bg)!important;min-height:1.5rem}\nhtml[data-tsa-links-active="true"] a[href]:not(#${e} *, #${n} *){outline:4px solid green!important;outline-offset:2px!important}\nhtml[data-tsa-links-active="true"] a[href][data-tsa-link-note]:not(#${e} *, #${n} *){outline:4px dotted #c00!important;position:relative;display:inline-block}\nhtml[data-tsa-links-active="true"] a[href][data-tsa-link-note]:not(#${e} *, #${n} *)::after{content:attr(data-tsa-link-note);position:absolute;left:100%;top:0;margin-left:8px;background:#c00;color:#fff;padding:2px 6px;font:500 14px/1.3 Arial,sans-serif;text-shadow:0 0 1px #000;z-index:2147483640;box-sizing:border-box;display:flex;align-items:center;min-height:100%;width:max-content}\nhtml[data-tsa-links-active="true"] a[href]:focus,html[data-tsa-links-active="true"] a[href]:focus-visible{outline-style:dashed!important}\n#${a}{position:absolute;inset:0;overflow:visible;pointer-events:none;z-index:2147483644;margin:0}\n#${a} line{stroke:yellow;stroke-width:4px}\n#${a} rect{fill:yellow;stroke:black;stroke-width:1px}\n#${a} text{fill:black;font-size:10px;font-weight:700}\n#${a} rect.-js-focussed{fill:red!important}\n`,document.head.appendChild(o)}(),t=document.createElement("div"),t.id=e,t.innerHTML=`\n <div class="tsa-controls">\n <h2>Accessibility self-audit</h2>\n <p class="tsa-help">Tick a check to turn it on. Untick all checks to clear the overlay.</p>\n <fieldset>\n <label><input type="checkbox" data-opt="headings"> Headings</label>\n <label><input type="checkbox" data-opt="links"> Links</label>\n <label><input type="checkbox" data-opt="tabStops"> Tab-stops</label>\n </fieldset>\n </div>\n <output id="${n}" class="reportWindow"></output>\n `,t.addEventListener("change",t=>{const e=t.target;e&&e.matches("[data-opt]")&&(o.options[e.getAttribute("data-opt")]=e.checked,b())}),document.body.appendChild(t);const s=t.querySelector("h2");let r=null;const l=e=>{if(!r)return;const n=Math.max(0,window.innerWidth-t.offsetWidth),i=Math.max(0,window.innerHeight-t.offsetHeight),a=Math.min(n,Math.max(0,e.clientX-r.dx)),o=Math.min(i,Math.max(0,e.clientY-r.dy));t.style.left=a+"px",t.style.top=o+"px",t.style.right="auto"},d=()=>{r=null,document.removeEventListener("pointermove",l),document.removeEventListener("pointerup",d)};return s.addEventListener("pointerdown",e=>{if(e.target.closest("button,input,label"))return;const n=t.getBoundingClientRect();r={dx:e.clientX-n.left,dy:e.clientY-n.top},document.addEventListener("pointermove",l),document.addEventListener("pointerup",d)}),t}function u(t){const e=t.getAttribute("aria-level");if(e)return parseInt(e,10)||6;const n=t.tagName.toUpperCase().match(/^H([1-6])$/);return n?parseInt(n[1],10):6}function h(t){let e=(t.textContent||"").trim();const n=t.querySelector("img");return n&&(e+=` "${n.getAttribute("alt")||""}"`),[e.trim(),!!n]}function m(t){document.documentElement.setAttribute("data-tsa-links-active","true");const e=document.createElement("div");let n='<h3>Links</h3><ol style="margin:1rem 0"><li>Can the link text be understood on its own, without relying on the content around it?</li><li>Does the link text avoid general terms like "Find out more" or "More details"?</li><li>Does the link text accurately describe the destination it goes to?</li><li>Do links with the same text go to the same destination?</li><li>Do links use additional methods, apart from colour, to be distinguishable?</li><li>Are links that open in a new window or tab clearly indicated?</li><li>Are document links clearly marked with format and file size?</li></ol><ol class="tsa-indent">';const i=s("a[href]").filter(l);i.forEach(t=>t.removeAttribute("data-tsa-link-note")),i.forEach(t=>{let e="";const[a,o]=h(t);let s=t.getAttribute("href")||"";if(s&&s.startsWith("javascript:")&&(s="javascript:"),s){(function(t,e,n,i){return t.some(t=>t!==e&&l(t)&&h(t)[0]===n&&t.getAttribute("href")!==i)})(i,t,a,s)&&(e+="Duplicated link copy, with a different address. ");const n=t.querySelector("img");if(n){const i=n.getAttribute("alt")||"";((t.textContent||"")+i).trim().length||(e+="Link image missing alt text. ")}if(function(t){return["see all","see more","view all","view more","read more","more","click here","here","find out more"].includes((t.textContent||"").trim().toLowerCase())}(t)&&(e+="Does this link text describe the destination? "),function(t){return"_blank"===(t.getAttribute("target")||"").toLowerCase()&&"none"===getComputedStyle(t,"::after").content}(t)&&(e+="Is this link marked as opening in a new window or tab? "),function(t){return!t.href.startsWith("javascript:")&&[".pdf",".xlsx",".docx",".pptx"].some(e=>t.href.includes(e))}(t)){const n=new URL(t.href);n.search="",n.hash="";const i=n.toString(),a=i.indexOf(".")>0?i.split(".").pop().toLowerCase():"undefined";(t.textContent||"").toLowerCase().includes(a)||(e+="Is this document link marked with format and file-size? ")}}else""===s&&(e+="Link missing address. ");e=e.trim(),e&&t.setAttribute("data-tsa-link-note",e),n+=`<li>${o?'<span class="tsa-img">alt-text: </span>':""}<a ${s?`href="${d(s)}"`:""} class="copy">${d(a)}</a> ${e?`<strong class="tsa-error">${d(e)}</strong>`:""}</li>`}),n+="</ol><p>Links in the content are highlighted: valid links are marked with a 4px solid green outline; invalid links are marked with a 4px dotted red outline; the outline becomes dashed on focus.</p>",e.innerHTML=n,t.appendChild(e)}function g(t){const e=s(`input[type="radio"][name="${CSS.escape(t)}"]`).filter(l).filter(t=>!r(t));return e.length?e.find(t=>t.checked)||e[0]:null}function f(t){document.documentElement.setAttribute("data-tsa-tabstops-active","true");const e=document.createElement("div");e.innerHTML="<h3>Keyboard tab-stops</h3><p>Using only the keyboard, navigate through the document using <kbd>tab</kbd> and <kbd>shift</kbd> + <kbd>tab</kbd>, or the arrow keys on radio buttons. Activate links by pressing <kbd>Return</kbd>, and buttons with <kbd>Return</kbd> or <kbd>Spacebar</kbd>.</p><p>Reporting is state dependant, remember to <b>expand accordions</b> before testing.</p><ol><li>When an element is focused by keyboard, is your current position clearly indicated?</li><li>Are all interactive elements reachable and usable with just the keyboard?</li><li>Does keyboard focus stop on non-interactive elements?</li><li>Is any tab stop completely obscured by the overlaid yellow square marker?</li><li>Does the keyboard tab order proceed in a logical sequence—specifically from left to right and top to bottom?</li><li>Are there any points where the keyboard becomes stuck?</li><li>Are there any elements which remain invisible upon keyboard focus?</li></ol>",t.appendChild(e);let n=document.getElementById(a);n&&n.remove(),n=document.createElementNS("http://www.w3.org/2000/svg","svg"),n.id=a,document.body.appendChild(n);const i=s(':is(a[href],area[href],audio[controls],button,embed[src],iframe,input,select,summary,textarea,video,[contenteditable],[tabindex]):not([hidden],:disabled,[tabindex^="-"])').filter(l);let r=null,d=0,c="";for(const t of i){if(t.name&&t.name===c)continue;let e=t;if("radio"===t.type&&(e=g(t.name),c=t.name,!e))continue;e.classList.add("tsa-tabable"),e.setAttribute("data-tsa-tabstop","true");const i=e.getBoundingClientRect(),a=Math.round(i.left+i.width/2),s=Math.round(window.scrollY+i.top+i.height/2);if(r){const t=document.createElementNS("http://www.w3.org/2000/svg","line");t.setAttribute("x1",String(r.x)),t.setAttribute("y1",String(r.y)),t.setAttribute("x2",String(a)),t.setAttribute("y2",String(s)),n.appendChild(t)}d+=1;const l=document.createElementNS("http://www.w3.org/2000/svg","rect");l.setAttribute("x",String(a-11)),l.setAttribute("y",String(s-11)),l.setAttribute("width","22"),l.setAttribute("height","22"),n.appendChild(l);const p=document.createElementNS("http://www.w3.org/2000/svg","text");p.setAttribute("x",String(a)),p.setAttribute("y",String(s)),p.setAttribute("text-anchor","middle"),p.setAttribute("dominant-baseline","central"),p.textContent=String(d),n.appendChild(p);const u=()=>l.classList.add("-js-focussed"),h=()=>l.classList.remove("-js-focussed");e.addEventListener("focus",u),e.addEventListener("blur",h),o.focusHandlers.push({el:e,onFocus:u,onBlur:h}),r={x:a,y:s}}}function b(){if(c(),!o.options.headings&&!o.options.links&&!o.options.tabStops)return;const t=function(){const t=p(),e=t.querySelector("#"+n);return e.innerHTML='<p class="tsa-report-help">For each check, read and answer the questions shown.<br>The report is state dependant, ensure sections are expanded.</p>',t.classList.add("tsa-has-report"),e}();o.options.headings&&function(t){document.documentElement.setAttribute("data-tsa-headings-active","true");const e=document.createElement("div");let n='<h3>Headings</h3><ol><li>Is there a single main <code>H1</code> heading at the top of the page?</li><li>Looking at the list of headings, are they arranged in a logical order from highest (H1) to lowest (H2 to H6)?</li><li>Do all headings clearly describe the content that follows?</li><li>Have all empty headings been removed?</li></ol><ol class="tsa-indent">';const i=s(':is(h1,h2,h3,h4,h5,h6,[role="heading"])').filter(l);i.forEach(t=>{t.removeAttribute("data-tsa-heading-error"),t.setAttribute("data-tsa-heading-note","H"+u(t))});let a=6;const o=i.filter(t=>1===u(t));o.length||(n+='<li><span class="tsa-tag">H1</span> <span class="tsa-error">Missing H1</span></li>'),i.forEach((t,e)=>{const i=u(t),s="H"+i,[r,l]=function(t){let e=(t.textContent||"").trim();const n=t.querySelector("img"),i=!!n;return n&&(e?e+=` "${n.alt||""}"`:(n.getAttribute("alt")||"").trim()?e=`"${n.getAttribute("alt").trim()}"`:t.setAttribute("data-tsa-heading-error","true")),[e.trim(),i]}(t);let c="";r||(c=`Empty ${s} heading`),e>0&&i>a+1&&(c=`Should be a H${a+1}, or lower, but it's a ${s}`),1===i&&o.indexOf(t)>0&&(c="A single H1 heading is advised"),l&&!r&&(c=`${s} heading image is missing an alt text`),c&&(t.setAttribute("data-tsa-heading-note",c),t.setAttribute("data-tsa-heading-error","true")),n+=`<li class="${s}"><span class="tsa-tag">${s}</span> ${l?'<span class="tsa-img">IMG ALT:</span> ':""}<span>${d(r)}</span> ${c?`<strong class="tsa-error">${d(c)}</strong>`:""}</li>`,a=i}),n+="</ol>",e.innerHTML=n,t.appendChild(e)}(t),o.options.links&&m(t),o.options.tabStops&&f(t)}function x(){c()}p(),window[t]={run:b,clear:x,destroy:function(){x();const n=document.getElementById(e);n&&n.remove();const a=document.getElementById(i);a&&a.remove(),delete window[t]},ui:p},console.log("3 Step Self-Audit ready. Use the floating panel or window."+t+".run()")}();
Copy this snippet, then paste it into Chrome’s Console while viewing the webpage.
Uncompressed snippet code (for the devs)
Full console snippet
(function () {
'use strict';
const NS = '__THREE_STEP_SELF_AUDIT__';
const ROOT_ID = 'tsa-root';
const PANEL_ID = 'tsa-panel';
const REPORT_ID = 'tsa-report';
const STYLE_ID = 'tsa-style';
const SVG_ID = 'tsa-tabstops-svg';
if (window[NS] && window[NS].destroy) {
window[NS].destroy();
}
const state = {
options: {
headings: false,
links: false,
tabStops: false
},
cleanupFns: [],
focusHandlers: [],
observer: null
};
function qsa(sel, root = document) {
return Array.from(root.querySelectorAll(sel));
}
function isInternal(el) {
return !!(el && (el.closest('#' + ROOT_ID) || el.closest('#' + REPORT_ID) || el.closest('#' + SVG_ID)));
}
function isVisible(el) {
if (!el || !el.isConnected || isInternal(el)) return false;
if (typeof el.checkVisibility === 'function') {
try {
if (!el.checkVisibility()) return false;
} catch (_) {}
}
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function removeArtifacts() {
document.documentElement.removeAttribute('data-tsa-headings-active');
document.documentElement.removeAttribute('data-tsa-links-active');
document.documentElement.removeAttribute('data-tsa-tabstops-active');
qsa('[data-tsa-heading-note],[data-tsa-link-note],[data-tsa-tabstop]').forEach((el) => {
el.removeAttribute('data-tsa-heading-note');
el.removeAttribute('data-tsa-link-note');
el.removeAttribute('data-tsa-tabstop');
el.classList.remove('tsa-tabable');
});
state.focusHandlers.forEach(({ el, onFocus, onBlur }) => {
el.removeEventListener('focus', onFocus);
el.removeEventListener('blur', onBlur);
});
state.focusHandlers = [];
state.cleanupFns.forEach((fn) => {
try { fn(); } catch (_) {}
});
state.cleanupFns = [];
const svg = document.getElementById(SVG_ID);
if (svg) svg.remove();
const root = document.getElementById(ROOT_ID);
const report = root ? root.querySelector('#' + REPORT_ID) : null;
if (report) report.innerHTML = '';
if (root) root.classList.remove('tsa-has-report');
}
function ensureStyle() {
const existing = document.getElementById(STYLE_ID);
if (existing) return existing;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${ROOT_ID}{position:fixed;top:16px;left:16px;z-index:2147483646;background:#f7f7f7;border:2px solid #ccc;padding:12px 14px;font:400 14px/1.4 Arial,sans-serif;color:#222;width:320px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);display:flex;flex-direction:column;gap:0;box-shadow:0 4px 14px rgba(0,0,0,.18);box-sizing:border-box}
#${ROOT_ID} h2{margin:0 0 8px;font:700 16px/1.2 Arial,sans-serif;cursor:move}
#${ROOT_ID} fieldset{border:0;padding:0;margin:8px 0 10px}
#${ROOT_ID} label{display:flex;gap:8px;align-items:center;margin:6px 0}
#${ROOT_ID} .tsa-controls{flex:0 0 auto}
#${ROOT_ID} .tsa-help{margin:0 0 8px;color:#444;font-size:12px}
#${REPORT_ID}{display:none;flex:1 1 auto;min-height:0;overflow:auto;background:#f7f7f7;border:0;border-top:1px solid #ccc;padding:4px 0 0;font:400 13px/1.4 Arial,sans-serif;color:#333;box-shadow:none;box-sizing:border-box;margin-top:8px}
#${ROOT_ID}.tsa-has-report #${REPORT_ID}{display:block}
#${REPORT_ID} h2{margin:0 0 8px;font:700 20px/1.2 Arial,sans-serif}
#${REPORT_ID} h3{margin:28px 0 8px;font:700 17px/1.2 Arial,sans-serif}
#${REPORT_ID} .tsa-report-help{margin:10px 0 10px;color:#333;font-size:12px}
#${REPORT_ID} > :first-child h3,#${REPORT_ID} > h3:first-child{margin-top:0}
#${REPORT_ID} ol, #${REPORT_ID} ul{margin:.7rem 0;padding-left:1rem}
#${REPORT_ID} .tsa-indent{list-style:none;padding-left:0}
#${REPORT_ID} .tsa-indent li{margin-top:6px}
#${REPORT_ID} .tsa-tag{display:inline-block;background:green;color:#fff;padding:2px 4px 0;text-transform:uppercase;margin-right:6px}
#${REPORT_ID} .tsa-error{color:#c00;font-weight:700}
#${REPORT_ID} .tsa-img{color:#070}
#${REPORT_ID} a,#${REPORT_ID} a:visited{color:#1b5e20!important;text-decoration:underline!important;text-underline-offset:2px;font-weight:500}
#${REPORT_ID} .H1{padding-left:0} #${REPORT_ID} .H2{padding-left:24px} #${REPORT_ID} .H3{padding-left:48px} #${REPORT_ID} .H4{padding-left:72px} #${REPORT_ID} .H5{padding-left:96px} #${REPORT_ID} .H6{padding-left:120px}
html[data-tsa-headings-active="true"] :is(h1,h2,h3,h4,h5,h6,[role="heading"]):not(#${ROOT_ID} *, #${REPORT_ID} *){--tsa-h-color:#000;--tsa-h-bg:#0c0;outline:2px solid var(--tsa-h-bg)!important;outline-offset:-2px;position:relative}
html[data-tsa-headings-active="true"] :is(h1,h2,h3,h4,h5,h6,[role="heading"]):not(#${ROOT_ID} *, #${REPORT_ID} *)::after{content:attr(data-tsa-heading-note);color:var(--tsa-h-color)!important;background:var(--tsa-h-bg)!important;text-shadow:0 0 1px #000;box-sizing:border-box;z-index:2147483640;display:flex;align-items:center;position:absolute;inset:0 0 auto auto;padding:2px 6px;font:500 14px/1.3 Arial,sans-serif;max-width:55%;min-height:100%}
html[data-tsa-headings-active="true"] [data-tsa-heading-note][data-tsa-heading-error="true"]{--tsa-h-color:#fff;--tsa-h-bg:#e50000;outline:4px dotted var(--tsa-h-bg)!important;min-height:1.5rem}
html[data-tsa-links-active="true"] a[href]:not(#${ROOT_ID} *, #${REPORT_ID} *){outline:4px solid green!important;outline-offset:2px!important}
html[data-tsa-links-active="true"] a[href][data-tsa-link-note]:not(#${ROOT_ID} *, #${REPORT_ID} *){outline:4px dotted #c00!important;position:relative;display:inline-block}
html[data-tsa-links-active="true"] a[href][data-tsa-link-note]:not(#${ROOT_ID} *, #${REPORT_ID} *)::after{content:attr(data-tsa-link-note);position:absolute;left:100%;top:0;margin-left:8px;background:#c00;color:#fff;padding:2px 6px;font:500 14px/1.3 Arial,sans-serif;text-shadow:0 0 1px #000;z-index:2147483640;box-sizing:border-box;display:flex;align-items:center;min-height:100%;width:max-content}
html[data-tsa-links-active="true"] a[href]:focus,html[data-tsa-links-active="true"] a[href]:focus-visible{outline-style:dashed!important}
#${SVG_ID}{position:absolute;inset:0;overflow:visible;pointer-events:none;z-index:2147483644;margin:0}
#${SVG_ID} line{stroke:yellow;stroke-width:4px}
#${SVG_ID} rect{fill:yellow;stroke:black;stroke-width:1px}
#${SVG_ID} text{fill:black;font-size:10px;font-weight:700}
#${SVG_ID} rect.-js-focussed{fill:red!important}
`;
document.head.appendChild(style);
return style;
}
function ensureUI() {
let root = document.getElementById(ROOT_ID);
if (root) return root;
ensureStyle();
root = document.createElement('div');
root.id = ROOT_ID;
root.innerHTML = `
<div class="tsa-controls">
<h2>Accessibility self-audit</h2>
<p class="tsa-help">Tick a check to turn it on. Untick all checks to clear the overlay.</p>
<fieldset>
<label><input type="checkbox" data-opt="headings"> Headings</label>
<label><input type="checkbox" data-opt="links"> Links</label>
<label><input type="checkbox" data-opt="tabStops"> Tab-stops</label>
</fieldset>
</div>
<output id="${REPORT_ID}" class="reportWindow"></output>
`;
root.addEventListener('change', (e) => {
const input = e.target;
if (input && input.matches('[data-opt]')) {
state.options[input.getAttribute('data-opt')] = input.checked;
run();
}
});
document.body.appendChild(root);
const handle = root.querySelector('h2');
let drag = null;
const onMove = (event) => {
if (!drag) return;
const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - root.offsetHeight);
const left = Math.min(maxLeft, Math.max(0, event.clientX - drag.dx));
const top = Math.min(maxTop, Math.max(0, event.clientY - drag.dy));
root.style.left = left + 'px';
root.style.top = top + 'px';
root.style.right = 'auto';
};
const onUp = () => {
drag = null;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('button,input,label')) return;
const rect = root.getBoundingClientRect();
drag = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
return root;
}
function createReport() {
const root = ensureUI();
const report = root.querySelector('#' + REPORT_ID);
report.innerHTML = '<p class="tsa-report-help">For each check, read and answer the questions shown.<br>The report is state dependant, ensure sections are expanded.</p>';
root.classList.add('tsa-has-report');
return report;
}
function headingText(el) {
let text = (el.textContent || '').trim();
const img = el.querySelector('img');
const hasImage = !!img;
if (img) {
if (text) text += ` "${img.alt || ''}"`;
else if ((img.getAttribute('alt') || '').trim()) text = `"${img.getAttribute('alt').trim()}"`;
else el.setAttribute('data-tsa-heading-error', 'true');
}
return [text.trim(), hasImage];
}
function headingLevel(el) {
const aria = el.getAttribute('aria-level');
if (aria) return parseInt(aria, 10) || 6;
const m = el.tagName.toUpperCase().match(/^H([1-6])$/);
return m ? parseInt(m[1], 10) : 6;
}
function runHeadings(report) {
document.documentElement.setAttribute('data-tsa-headings-active', 'true');
const section = document.createElement('div');
let html = '<h3>Headings</h3><ol><li>Is there a single main <code>H1</code> heading at the top of the page?</li><li>Looking at the list of headings, are they arranged in a logical order from highest (H1) to lowest (H2 to H6)?</li><li>Do all headings clearly describe the content that follows?</li><li>Have all empty headings been removed?</li></ol><ol class="tsa-indent">';
const headings = qsa(':is(h1,h2,h3,h4,h5,h6,[role="heading"])').filter(isVisible);
headings.forEach((el) => {
el.removeAttribute('data-tsa-heading-error');
el.setAttribute('data-tsa-heading-note', 'H' + headingLevel(el));
});
let lastLevel = 6;
const h1s = headings.filter((el) => headingLevel(el) === 1);
if (!h1s.length) {
html += `<li><span class="tsa-tag">H1</span> <span class="tsa-error">Missing H1</span></li>`;
}
headings.forEach((el, idx) => {
const level = headingLevel(el);
const tag = 'H' + level;
const [text, hasImage] = headingText(el);
let issue = '';
if (!text) issue = `Empty ${tag} heading`;
if (idx > 0 && level > lastLevel + 1) issue = `Should be a H${lastLevel + 1}, or lower, but it's a ${tag}`;
if (level === 1 && h1s.indexOf(el) > 0) issue = 'A single H1 heading is advised';
if (hasImage && !text) issue = `${tag} heading image is missing an alt text`;
if (issue) {
el.setAttribute('data-tsa-heading-note', issue);
el.setAttribute('data-tsa-heading-error', 'true');
}
html += `<li class="${tag}"><span class="tsa-tag">${tag}</span> ${hasImage ? '<span class="tsa-img">IMG ALT:</span> ' : ''}<span>${escapeHtml(text)}</span> ${issue ? `<strong class="tsa-error">${escapeHtml(issue)}</strong>` : ''}</li>`;
lastLevel = level;
});
html += '</ol>';
section.innerHTML = html;
report.appendChild(section);
}
function linkText(link) {
let text = (link.textContent || '').trim();
const img = link.querySelector('img');
if (img) text += ` "${img.getAttribute('alt') || ''}"`;
return [text.trim(), !!img];
}
function duplicateTextDifferentHref(links, current, txt, href) {
return links.some((other) => other !== current && isVisible(other) && linkText(other)[0] === txt && other.getAttribute('href') !== href);
}
function genericLink(link) {
return ['see all','see more','view all','view more','read more','more','click here','here','find out more'].includes((link.textContent || '').trim().toLowerCase());
}
function opensNewWindowWithoutIndicator(link) {
return (link.getAttribute('target') || '').toLowerCase() === '_blank' && getComputedStyle(link, '::after').content === 'none';
}
function documentLink(link) {
if (link.href.startsWith('javascript:')) return false;
return ['.pdf','.xlsx','.docx','.pptx'].some((ext) => link.href.includes(ext));
}
function runLinks(report) {
document.documentElement.setAttribute('data-tsa-links-active', 'true');
const section = document.createElement('div');
let html = '<h3>Links</h3><ol style="margin:1rem 0"><li>Can the link text be understood on its own, without relying on the content around it?</li><li>Does the link text avoid general terms like "Find out more" or "More details"?</li><li>Does the link text accurately describe the destination it goes to?</li><li>Do links with the same text go to the same destination?</li><li>Do links use additional methods, apart from colour, to be distinguishable?</li><li>Are links that open in a new window or tab clearly indicated?</li><li>Are document links clearly marked with format and file size?</li></ol><ol class="tsa-indent">';
const links = qsa('a[href]').filter(isVisible);
links.forEach((link) => link.removeAttribute('data-tsa-link-note'));
links.forEach((link) => {
let issue = '';
const [txt, hasImg] = linkText(link);
let href = link.getAttribute('href') || '';
if (href && href.startsWith('javascript:')) href = 'javascript:';
if (href) {
if (duplicateTextDifferentHref(links, link, txt, href)) issue += 'Duplicated link copy, with a different address. ';
const img = link.querySelector('img');
if (img) {
const alt = img.getAttribute('alt') || '';
if (!((link.textContent || '') + alt).trim().length) issue += 'Link image missing alt text. ';
}
if (genericLink(link)) issue += 'Does this link text describe the destination? ';
if (opensNewWindowWithoutIndicator(link)) issue += 'Is this link marked as opening in a new window or tab? ';
if (documentLink(link)) {
const u = new URL(link.href);
u.search = '';
u.hash = '';
const str = u.toString();
const ext = str.indexOf('.') > 0 ? str.split('.').pop().toLowerCase() : 'undefined';
if (!(link.textContent || '').toLowerCase().includes(ext)) issue += 'Is this document link marked with format and file-size? ';
}
} else if (href === '') {
issue += 'Link missing address. ';
}
issue = issue.trim();
if (issue) link.setAttribute('data-tsa-link-note', issue);
html += `<li>${hasImg ? '<span class="tsa-img">alt-text: </span>' : ''}<a ${href ? `href="${escapeHtml(href)}"` : ''} class="copy">${escapeHtml(txt)}</a> ${issue ? `<strong class="tsa-error">${escapeHtml(issue)}</strong>` : ''}</li>`;
});
html += '</ol><p>Links in the content are highlighted: valid links are marked with a 4px solid green outline; invalid links are marked with a 4px dotted red outline; the outline becomes dashed on focus.</p>';
section.innerHTML = html;
report.appendChild(section);
}
function radioRepresentative(name) {
const radios = qsa(`input[type="radio"][name="${CSS.escape(name)}"]`).filter(isVisible).filter((el) => !isInternal(el));
if (!radios.length) return null;
return radios.find((el) => el.checked) || radios[0];
}
function tabbables() {
return qsa(':is(a[href],area[href],audio[controls],button,embed[src],iframe,input,select,summary,textarea,video,[contenteditable],[tabindex]):not([hidden],:disabled,[tabindex^="-"])').filter(isVisible);
}
function runTabStops(report) {
document.documentElement.setAttribute('data-tsa-tabstops-active', 'true');
const section = document.createElement('div');
section.innerHTML = '<h3>Keyboard tab-stops</h3><p>Using only the keyboard, navigate through the document using <kbd>tab</kbd> and <kbd>shift</kbd> + <kbd>tab</kbd>, or the arrow keys on radio buttons. Activate links by pressing <kbd>Return</kbd>, and buttons with <kbd>Return</kbd> or <kbd>Spacebar</kbd>.</p><p>Reporting is state dependant, remember to <b>expand accordions</b> before testing.</p><ol><li>When an element is focused by keyboard, is your current position clearly indicated?</li><li>Are all interactive elements reachable and usable with just the keyboard?</li><li>Does keyboard focus stop on non-interactive elements?</li><li>Is any tab stop completely obscured by the overlaid yellow square marker?</li><li>Does the keyboard tab order proceed in a logical sequence—specifically from left to right and top to bottom?</li><li>Are there any points where the keyboard becomes stuck?</li><li>Are there any elements which remain invisible upon keyboard focus?</li></ol>';
report.appendChild(section);
let svg = document.getElementById(SVG_ID);
if (svg) svg.remove();
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = SVG_ID;
document.body.appendChild(svg);
const items = tabbables();
let prev = null;
let num = 0;
let seenRadio = '';
for (const item of items) {
if (item.name && item.name === seenRadio) continue;
let focusEl = item;
if (item.type === 'radio') {
focusEl = radioRepresentative(item.name);
seenRadio = item.name;
if (!focusEl) continue;
}
focusEl.classList.add('tsa-tabable');
focusEl.setAttribute('data-tsa-tabstop', 'true');
const rect = focusEl.getBoundingClientRect();
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(window.scrollY + rect.top + rect.height / 2);
if (prev) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', String(prev.x));
line.setAttribute('y1', String(prev.y));
line.setAttribute('x2', String(x));
line.setAttribute('y2', String(y));
svg.appendChild(line);
}
num += 1;
const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
r.setAttribute('x', String(x - 11));
r.setAttribute('y', String(y - 11));
r.setAttribute('width', '22');
r.setAttribute('height', '22');
svg.appendChild(r);
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('x', String(x));
t.setAttribute('y', String(y));
t.setAttribute('text-anchor', 'middle');
t.setAttribute('dominant-baseline', 'central');
t.textContent = String(num);
svg.appendChild(t);
const onFocus = () => r.classList.add('-js-focussed');
const onBlur = () => r.classList.remove('-js-focussed');
focusEl.addEventListener('focus', onFocus);
focusEl.addEventListener('blur', onBlur);
state.focusHandlers.push({ el: focusEl, onFocus, onBlur });
prev = { x, y };
}
}
function run() {
removeArtifacts();
if (!state.options.headings && !state.options.links && !state.options.tabStops) {
return;
}
const report = createReport();
if (state.options.headings) runHeadings(report);
if (state.options.links) runLinks(report);
if (state.options.tabStops) runTabStops(report);
}
function clear() {
removeArtifacts();
}
function destroy() {
clear();
const root = document.getElementById(ROOT_ID);
if (root) root.remove();
const style = document.getElementById(STYLE_ID);
if (style) style.remove();
delete window[NS];
}
ensureUI();
window[NS] = { run, clear, destroy, ui: ensureUI };
console.log('3 Step Self-Audit ready. Use the floating panel or window.' + NS + '.run()');
})();