This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
npm test # run all 67 Jest tests
npx jest -t "label truncation" # run a single describe block by name
npx jest --testNamePattern "F6" # run tests matching a string
npm run build # minify src/ → dist/ (terser + clean-css)Always run npm test before committing. The CI workflow (.github/workflows/ci.yml) runs tests on every push. The build workflow (.github/workflows/build.yml) rebuilds dist/ automatically when src/ changes on main and commits back via the GitHub Actions bot.
src/ — human-readable source (edit these)
dist/ — minified production files (committed; auto-rebuilt by CI)
tests/ — Jest + jsdom test suite
build.js — Node script: reads src/, writes dist/
src/dynamic-skip-links.js is a single self-executing IIFE with no runtime dependencies. It runs once on page load.
Execution flow:
- Merge
window.dynamicSkipLinksConfigover defaults withObject.assign. Then rebuildconfig.typeLabelsin a separate step — this is intentional: a plain shallow merge would discard unset defaults, so typeLabels always needs a two-step merge. ready(fn)defers the rest untilDOMContentLoaded(or runs immediately if the DOM is already ready).- Inject
<nav id="skiplinks" role="navigation" aria-label="…">as the very first child of<body>. Therole="navigation"duplicate is required for iOS 15 VoiceOver, which ignoresaria-labelon<nav>without it. querySelectorAll(config.selector)collects targets. The injected nav is excluded by identity check (element === nav).- For each target: strip
title, addtabindex="-1"(makes it programmatically focusable without adding a tab stop), assign a generatedsl-<n>id if none exists, resolve a label, build a<li><a>and append it to the container. - If
config.f6is enabled, attach a singlekeydownlistener todocumentthat cycles focus through the collectedtargets[]array.
Label resolution priority (highest → lowest):
aria-labelattributetextContentof element referenced byaria-labelledby- Element's own
textContent— headings only (H1–H6) config.typeLabels[element.tagName]— keyed by uppercase tag nameconfig.noLabel
Labels are passed through truncate(str, 60) which word-wraps at the last space before position 60, appending …. No-space strings are hard-cut.
Debug mode (config.debug: true): sets data-dsl-debug="ok"|"no-label" on each target; fires console.warn for missing labels; appends a <span aria-hidden="true"> (nav)</span> type tag to each link. The aria-hidden is required — putting the tag in the link's text node would violate WCAG 2.5.3 (Label in Name) and break voice-control software.
Tests live in tests/dynamic-skip-links.test.js. The module is an IIFE, so each test uses jest.resetModules() + require() to re-execute it fresh:
function loadModule(bodyHtml = "", config = {}) {
jest.resetModules();
document.body.innerHTML = bodyHtml;
global.window.dynamicSkipLinksConfig = config;
require("../src/dynamic-skip-links.js");
}Key helpers: getLinks() queries #js-nav-skip-links a; getLinkTexts() and getLinkHrefs() map over them; pressKey(key, options) dispatches a KeyboardEvent on document.
Pitfall: document.querySelector("nav") returns the injected <nav id="skiplinks"> (first in DOM), not content navs. Use document.getElementById("resources") or add explicit ids in test HTML when you need a specific content element.
src/dynamic-skip-links.css contains only mechanical rules — no colours, spacing, or typography. Integrators add visual design in their own stylesheet.
- Links are visually hidden with
clip-path: inset(50%)(better thanclipfor modern browsers). - Revealed on
:focus-visibleand:activeby undoing every hiding rule. - Debug indicators use
box-shadow: inset(notoutline) so nested elements such as<nav>inside<main>remain visually distinct. --skiplinks-z-indexCSS custom property controls stacking without editing the file.position: absoluteon#skiplinks— integrators can override toposition: fixedfor sticky-header layouts..dsl-f6-hintis hidden with the sameclip-pathtechnique as the links, but revealed via#skiplinks:focus-within(not:focus-visible) so it appears alongside whichever skip link is active. Only rendered whenf6: trueandf6Hintis non-empty.
All user-facing strings live in window.dynamicSkipLinksConfig. No translation requires editing the module. typeLabels is deep-merged (two-step) so partial overrides keep unspecified defaults. Config is read once at IIFE execution time.
main is protected and does not accept direct pushes. Use mcp__github__push_files to update main, then sync locally: git fetch origin main && git reset --hard origin/main.