Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/code-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- **History startup**: CLI commands use `openHistoryStore`, which opens the schema and runs the versioned, transactional saved-reply migration via `inbox.MigrateNoDataResponses`. Keep new entry points on this helper so upgrades do not require visiting the web UI. The migration is local-only and retryable after a rollback.
- **Pipeline UI**: counts and pending rows share `history.GetActionItems`; use it for Pipeline and Action Needed together. The Pipeline URL carries view, status, response type, search, review, and page filters. Response search filters the active profile in SQLite before pagination, including stored email bodies.
- **Multi-profile scoping**: any new `history.Store` method that reads/writes per-user data should take a `profileID string` first parameter and filter by it (see [multi-profile.md](multi-profile.md)) - the two deliberate exceptions are `ClearBrokerResponses`/`GetAllBrokerResponses`, which operate on the one shared inbox across all profiles.
- **Accessibility**: CI runs axe-core (WCAG 2.2 A/AA, light and dark) over the website and the web UI against fictional seed data: `scripts/a11y/run.sh` runs the same thing locally (needs Go, Hugo, Node and Chrome). Add a new web UI page to the URL list in that script. Rules of thumb it enforces: every `<select>`/icon-only control needs an `aria-label`, links inside body text need an underline, text must hit 4.5:1 in both themes. In the web UI use `--accent` for text and borders and `--accent-solid` for fills behind white text, since the dark theme needs a lighter accent for text than for buttons. Headings step down one level at a time (`h1` then `h2`), never by tag size.
- **Accessibility**: CI runs axe-core (WCAG 2.2 A/AA, light and dark) over the website and the web UI against fictional seed data: `scripts/a11y/run.sh` runs the same thing locally (needs Go, Hugo, Node and Chrome). `scripts/a11y/probe.mjs <chrome> <url>...` covers what axe can't (skip link, tab stops and focus rings, mouse-only controls, 320px reflow); it is informational, not in CI. Add a new web UI page to the URL list in that script. Rules of thumb it enforces: every `<select>`/icon-only control needs an `aria-label`, links inside body text need an underline, text must hit 4.5:1 in both themes. In the web UI use `--accent` for text and borders and `--accent-solid` for fills behind white text, since the dark theme needs a lighter accent for text than for buttons. Headings step down one level at a time (`h1` then `h2`), never by tag size.

## Known quirks

Expand Down
43 changes: 43 additions & 0 deletions scripts/a11y/probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Keyboard and structure probe that axe can't do: tab stops, focus rings,
// skip link, mouse-only controls, 320px reflow. Informational, not run in CI.
// usage: node probe.mjs <chrome> <url>...
import puppeteer from "puppeteer-core";

const [chrome, ...urls] = process.argv.slice(2);
const b = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--no-sandbox"] });
for (const url of urls) {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 900 });
await p.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
await p.goto(url, { waitUntil: "networkidle0" });
const st = await p.evaluate(() => {
const q = (s) => [...document.querySelectorAll(s)];
const interactive = "a[href],button,input,select,textarea,summary,[tabindex]";
return {
h1: q("h1").length, main: q("main").length,
skipLink: q("a[href^='#']").some((a) => /skip/i.test(a.textContent)),
mouseOnly: q("[onclick],[hx-get],[hx-post]").filter((e) => !e.matches(interactive + ",form") && !e.closest("a,button")).length,
thNoScope: q("th:not([scope])").length,
smallTargets: q(interactive).filter((e) => { const r = e.getBoundingClientRect(); return r.width && (r.width < 24 || r.height < 24) && getComputedStyle(e).display !== "inline"; }).map((e) => (e.textContent || e.name || e.id).trim().slice(0, 25)),
};
});
const noRing = [];
let stops = 0;
for (let i = 0; i < 100; i++) {
await p.keyboard.press("Tab");
const f = await p.evaluate(() => {
const e = document.activeElement;
if (!e || e === document.body) return null;
const c = getComputedStyle(e);
return { id: e.tagName + ":" + (e.textContent || e.name || "").trim().slice(0, 25), ring: (c.outlineStyle !== "none" && parseFloat(c.outlineWidth) > 0) || c.boxShadow !== "none" };
});
if (!f) break;
stops++;
if (!f.ring) noRing.push(f.id);
}
await p.setViewport({ width: 320, height: 800 });
const overflow = await p.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
console.log(url, JSON.stringify({ ...st, stops, noRing, overflowAt320: overflow }));
await p.close();
}
await b.close();
5 changes: 3 additions & 2 deletions site/layouts/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@
{{ block "head" . }}{{ end }}
</head>
<body>
<a href="#main" class="skip-link">Skip to main content</a>
<header class="topbar">
<a class="brand" href="{{ "/" | relURL }}">eraser</a>
<nav>
<nav aria-label="Main">
{{ range .Site.Menus.main }}<a href="{{ .URL | relLangURL }}"{{ if $.IsMenuCurrent "main" . }} class="active"{{ end }}>{{ .Name }}</a>{{ end }}
</nav>
</header>
<main class="wrap">
<main id="main" tabindex="-1" class="wrap">
{{ block "main" . }}{{ end }}
</main>
<footer class="foot">
Expand Down
4 changes: 3 additions & 1 deletion site/layouts/brokers/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ <h2>Full broker list</h2>
</div>
<p class="meta"><span id="count">Loading…</span></p>

<div class="table-scroll" role="region" aria-label="Broker list" tabindex="0">
<table class="dir">
<thead><tr><th>Broker</th><th>Region</th><th>Category</th><th>Opt out</th></tr></thead>
<thead><tr><th scope="col">Broker</th><th scope="col">Region</th><th scope="col">Category</th><th scope="col">Opt out</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</article>

<script src="{{ "js/directory.js" | relURL }}" data-src="{{ "brokers.json" | relURL }}"></script>
Expand Down
21 changes: 21 additions & 0 deletions site/static/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,24 @@ table.dir a { word-break: break-word; }
.docs-bar { display: flex; align-items: center; gap: .6rem; margin: .5rem 0 1rem; color: var(--ink-3); font-size: .9rem; }
.docs-bar select { background: var(--surface); color: var(--ink); border: 1px solid var(--border); border-radius: 6px; padding: .3rem .5rem; font: inherit; }
.docs-banner { background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--warn); border-radius: 6px; padding: .6rem .8rem; font-size: .92rem; color: var(--ink-2); }

/* WCAG 2.4.1 bypass block, visible on keyboard focus only */
.skip-link { position: absolute; left: .5rem; top: -4rem; z-index: 10; padding: .5rem 1rem; border-radius: 6px; background: var(--accent); color: var(--on-accent); font-weight: 600; }
.skip-link:focus { top: .5rem; }
main:focus { outline: none; }

/* wide table scrolls inside its box instead of the page (reflow at 320px) */
.table-scroll { overflow-x: auto; }
.table-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* WCAG 2.5.8: 24px minimum targets */
.filters label { min-height: 24px; }
.filters input[type=checkbox] { width: 1.5rem; height: 1.5rem; }
.topbar nav a, .foot a { display: inline-block; min-height: 24px; }

/* long code lines and wide docs tables scroll inside their box, not the page */
pre { overflow-x: auto; max-width: 100%; }
.prose table { display: block; overflow-x: auto; max-width: 100%; }
img { max-width: 100%; height: auto; }
.highlight { overflow-x: auto; max-width: 100%; }
:not(pre) > code { overflow-wrap: anywhere; }
Loading