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
56 changes: 56 additions & 0 deletions .github/ISSUE_TEMPLATE/accessibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Accessibility problem
description: Report a barrier when using ApplyTrack with assistive technology or accessibility settings.
title: "[Accessibility]: "
labels: ["accessibility"]
body:
- type: markdown
attributes:
value: Thank you for reporting an accessibility barrier. Do not include private application, résumé, or account information.
- type: textarea
id: workflow
attributes:
label: Affected workflow
description: What were you trying to accomplish?
validations:
required: true
- type: textarea
id: observed
attributes:
label: Observed behavior
description: Describe the barrier and what happened.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Describe what would make the workflow usable.
validations:
required: true
- type: input
id: browser
attributes:
label: Browser and version
placeholder: Firefox 128
- type: input
id: assistive_technology
attributes:
label: Assistive technology and version
description: Leave blank when none is involved.
placeholder: NVDA 2026.1
- type: dropdown
id: input
attributes:
label: Primary input method
options:
- Keyboard
- Touch
- Mouse or trackpad
- Voice control
- Switch control
- Other
- type: textarea
id: context
attributes:
label: Additional context
description: Include display preferences or reproducible steps, but no sensitive personal data.
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ on:
- cron: "0 6 * * 1" # weekly Mon 06:00 UTC — catch CVEs disclosed after merge

jobs:
web:
name: Web — WCAG checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
- name: Install and audit development dependencies
run: |
npm ci
npm audit --audit-level=high
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Test keyboard, responsive, and automated accessibility behavior
run: npm run test:web

dotnet:
name: .NET — test + audit
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ obj/
[Dd]ebug/
[Rr]elease/
artifacts/
node_modules/
playwright-report/
test-results/

# Local secrets / env
.env
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ rewrite of `applytrack` (the original single-user app lives at
- **Data: Dapper + Npgsql** over Postgres. Migrations via **DbUp** (idempotent
`.sql` scripts). Optimistic locking via a manual `version` column. **Not** EF
Core, **not** SQLAlchemy.
- **Keep the existing vanilla-JS SPA VERBATIM** (`web/static/*` → the .NET app's
`wwwroot/`). The API is written *against* the SPA's existing endpoint contract:
same URLs + JSON shapes, including `?expected_version=` and the 409 conflict
flow. The SPA is not to be rewritten.
- **Accessible build-free SPA.** The vanilla-JS interface in `wwwroot/` targets
WCAG 2.2 AA and may evolve, but it must remain unbundled and preserve the API's
URLs and JSON shapes, including `?expected_version=` and the 409 conflict flow.
Run `npm run test:web` after UI changes.
- **Materials / LLM cover-letter engine was out of v1; it landed post-v1, in
v1.1.0.**
- **⚠ ANY-LLM, AND OPTIONAL:** the cover-letter engine **must run against any
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ telemetry, no SaaS.
</p>

<p align="center">
<img src="docs/mobile.png" alt="OSApplyTrack on a phone — the cyberdeck-themed mobile app view" width="280">
<img src="docs/mobile.png" alt="OSApplyTrack application detail view on a phone" width="280">
<br>
<em>Open the magic link on your phone and it's a native-feeling app — tap a card for the full detail view. (cyberdeck theme)</em>
<em>The responsive list and detail view support touch, keyboard, and screen readers.</em>
</p>

---
Expand All @@ -57,6 +57,7 @@ telemetry, no SaaS.
- [Cover letters](#cover-letters)
- [Security & hardening](#security--hardening)
- [Your data](#your-data)
- [Accessibility](#accessibility)
- [First-run import](#first-run-import-optional)
- [Local development](#local-development)
- [Tests](#tests)
Expand Down Expand Up @@ -404,6 +405,18 @@ OSApplyTrack is built to face the public internet behind a reverse proxy:
`ON DELETE CASCADE`, every row that belongs to it (applications, search profile,
blacklist, seen ledger, queued polls, sessions, tokens) in one statement.

## Accessibility

The web interface targets **WCAG 2.2 Level AA**. It provides semantic screen-reader
navigation, complete keyboard operation, visible focus, reduced-motion and
high-contrast modes, light/dark/system colors, adjustable text size, and responsive
reflow. Preferences are stored only in the current browser and apply before sign-in.

Pull requests run Playwright and axe-core checks against login, application, editor,
settings, validation, and responsive workflows. See the
[accessibility statement and manual test matrix](docs/accessibility.md), or use the
Accessibility problem issue template to report a barrier without sharing private data.

## First-run import (optional)

If you're coming from the original single-user `applytrack`, import your existing
Expand Down Expand Up @@ -461,6 +474,11 @@ cd api && dotnet test
pytest
ruff check .
mypy src

# Web — Playwright keyboard/responsive tests + axe-core WCAG checks
npm ci
npx playwright install chromium
npm run test:web
```

The .NET suite drives the live HTTP stack with `WebApplicationFactory` against a
Expand Down
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.7.9</Version>
<Version>1.8.0</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
85 changes: 85 additions & 0 deletions api/ApplyTrack.Api/wwwroot/accessibility-preferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

const STORAGE_KEY = "applytrack-accessibility-v1";
export const PREFERENCE_DEFAULTS = {
colorMode: "system",
contrast: "system",
motion: "system",
textSize: "100",
density: "comfortable",
};

export function readPreferences() {
try {
return { ...PREFERENCE_DEFAULTS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") };
} catch (_) {
return { ...PREFERENCE_DEFAULTS };
}
}

export function applyPreferences(value, persist = true) {
const prefs = { ...PREFERENCE_DEFAULTS, ...value };
const root = document.documentElement;
root.dataset.colorMode = ["system", "light", "dark"].includes(prefs.colorMode) ? prefs.colorMode : "system";
root.dataset.contrast = ["system", "default", "high"].includes(prefs.contrast) ? prefs.contrast : "system";
root.dataset.motion = ["system", "reduce"].includes(prefs.motion) ? prefs.motion : "system";
root.dataset.textSize = ["100", "125", "150"].includes(String(prefs.textSize)) ? String(prefs.textSize) : "100";
root.dataset.density = ["comfortable", "compact"].includes(prefs.density) ? prefs.density : "comfortable";
if (persist) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch (_) {}
}
}

function preferenceSelect(id, label, value, options, help) {
return `<label class="field" for="${id}"><span class="field-label">${label}</span>
<select id="${id}">${options.map(([key, text]) =>
`<option value="${key}"${String(value) === key ? " selected" : ""}>${text}</option>`).join("")}</select>
<span class="field-help">${help}</span></label>`;
}

export function renderAccessibilityPreferences(body, announce) {
const preferences = readPreferences();
body.innerHTML = `<section class="sheet" aria-labelledby="accessibility-heading">
<h2 id="accessibility-heading">Display and sensory preferences</h2>
<p class="settings-help">These settings are stored only in this browser and apply before sign-in.</p>
<div class="preference-grid">
${preferenceSelect("pref-color", "Color mode", preferences.colorMode,
[["system", "Use device setting"], ["light", "Light"], ["dark", "Dark"]],
"Use device setting follows your operating system light or dark preference.")}
${preferenceSelect("pref-contrast", "Contrast", preferences.contrast,
[["system", "Use device setting"], ["default", "Default"], ["high", "High contrast"]],
"High contrast uses stronger boundaries and system colors where supported.")}
${preferenceSelect("pref-motion", "Motion", preferences.motion,
[["system", "Use device setting"], ["reduce", "Reduce motion"]],
"Reduced motion removes nonessential transitions.")}
${preferenceSelect("pref-text", "Text size", preferences.textSize,
[["100", "100%"], ["125", "125%"], ["150", "150%"]],
"Browser zoom remains available in addition to this setting.")}
${preferenceSelect("pref-density", "Spacing", preferences.density,
[["comfortable", "Comfortable"], ["compact", "Compact"]],
"Comfortable spacing provides more separation between controls.")}
</div>
<div class="action-row section-divider">
<button type="button" class="btn btn-ghost" data-act="pref-reset">Reset to device settings</button>
</div>
</section>`;
const byId = (id) => body.querySelector(`#${id}`);
const update = () => {
applyPreferences({
colorMode: byId("pref-color").value,
contrast: byId("pref-contrast").value,
motion: byId("pref-motion").value,
textSize: byId("pref-text").value,
density: byId("pref-density").value,
});
announce("Display preferences updated.");
};
body.querySelectorAll("select").forEach((select) => select.addEventListener("change", update));
body.querySelector('[data-act="pref-reset"]').addEventListener("click", () => {
applyPreferences(PREFERENCE_DEFAULTS);
renderAccessibilityPreferences(body, announce);
announce("Display preferences reset to device settings.");
byId("pref-color").focus();
});
}
23 changes: 23 additions & 0 deletions api/ApplyTrack.Api/wwwroot/api-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

export function createApiClient(onUnauthorized) {
return async function api(method, path, body) {
const options = { method, headers: {} };
if (body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(path, options);
if (!response.ok) {
if (response.status === 401) onUnauthorized();
let detail = response.statusText;
try { detail = (await response.json()).detail || detail; } catch (_) {}
const error = new Error(detail);
error.status = response.status;
throw error;
}
if (response.status === 204) return null;
return response.json();
};
}
Loading
Loading