From 53f2e66b09de4647e89ca852142d7506a0644777 Mon Sep 17 00:00:00 2001
From: "Aaron K. Clark (CryptoJones)"
Date: Thu, 9 Jul 2026 18:39:08 -0500
Subject: [PATCH 1/2] feat(web): rebuild UI for accessibility
---
.github/ISSUE_TEMPLATE/accessibility.yml | 56 +
.github/workflows/ci.yml | 18 +
.gitignore | 3 +
CLAUDE.md | 8 +-
README.md | 22 +-
api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +-
.../wwwroot/accessibility-preferences.js | 85 ++
api/ApplyTrack.Api/wwwroot/api-client.js | 23 +
api/ApplyTrack.Api/wwwroot/app.css | 1357 ++++-------------
api/ApplyTrack.Api/wwwroot/app.js | 444 +++---
api/ApplyTrack.Api/wwwroot/index.html | 155 +-
api/ApplyTrack.Api/wwwroot/theme-init.js | 25 +-
docs/accessibility.md | 37 +
docs/mobile.png | Bin 124883 -> 134144 bytes
docs/screenshot.png | Bin 354264 -> 80623 bytes
package-lock.json | 730 +++++++++
package.json | 13 +
plan.md | 10 +-
playwright.config.js | 24 +
pyproject.toml | 2 +-
tests/web/accessibility.spec.js | 146 ++
uv.lock | 2 +-
22 files changed, 1859 insertions(+), 1303 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/accessibility.yml
create mode 100644 api/ApplyTrack.Api/wwwroot/accessibility-preferences.js
create mode 100644 api/ApplyTrack.Api/wwwroot/api-client.js
create mode 100644 docs/accessibility.md
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 playwright.config.js
create mode 100644 tests/web/accessibility.spec.js
diff --git a/.github/ISSUE_TEMPLATE/accessibility.yml b/.github/ISSUE_TEMPLATE/accessibility.yml
new file mode 100644
index 0000000..3c42779
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/accessibility.yml
@@ -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.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index acac681..b4a1086 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index d2a386e..f6c261b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,9 @@ obj/
[Dd]ebug/
[Rr]elease/
artifacts/
+node_modules/
+playwright-report/
+test-results/
# Local secrets / env
.env
diff --git a/CLAUDE.md b/CLAUDE.md
index 05a90be..d658c3e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index c1e493f..72b7bb9 100644
--- a/README.md
+++ b/README.md
@@ -37,9 +37,9 @@ telemetry, no SaaS.
-
+
- Open the magic link on your phone and it's a native-feeling app — tap a card for the full detail view. (cyberdeck theme)
+ The responsive list and detail view support touch, keyboard, and screen readers.
---
@@ -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)
@@ -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
@@ -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
diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
index 65c099e..9fae489 100644
--- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj
+++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
@@ -5,7 +5,7 @@
enable
enable
ApplyTrack.Api
- 1.7.9
+ 1.8.0
Aaron K. Clark
Copyright 2026 Aaron K. Clark
Apache-2.0
diff --git a/api/ApplyTrack.Api/wwwroot/accessibility-preferences.js b/api/ApplyTrack.Api/wwwroot/accessibility-preferences.js
new file mode 100644
index 0000000..e0bcae3
--- /dev/null
+++ b/api/ApplyTrack.Api/wwwroot/accessibility-preferences.js
@@ -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}
+ ${options.map(([key, text]) =>
+ `${text} `).join("")}
+ ${help} `;
+}
+
+export function renderAccessibilityPreferences(body, announce) {
+ const preferences = readPreferences();
+ body.innerHTML = `
+ Display and sensory preferences
+ These settings are stored only in this browser and apply before sign-in.
+
+ ${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.")}
+
+
+ Reset to device settings
+
+ `;
+ 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();
+ });
+}
diff --git a/api/ApplyTrack.Api/wwwroot/api-client.js b/api/ApplyTrack.Api/wwwroot/api-client.js
new file mode 100644
index 0000000..59a87a6
--- /dev/null
+++ b/api/ApplyTrack.Api/wwwroot/api-client.js
@@ -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();
+ };
+}
diff --git a/api/ApplyTrack.Api/wwwroot/app.css b/api/ApplyTrack.Api/wwwroot/app.css
index c0981e3..54d82b1 100644
--- a/api/ApplyTrack.Api/wwwroot/app.css
+++ b/api/ApplyTrack.Api/wwwroot/app.css
@@ -1,1086 +1,401 @@
/* SPDX-License-Identifier: Apache-2.0 */
/* Copyright 2026 Aaron K. Clark */
-/* ============================================================ THEMES ===== */
-/* Each theme is a flat set of variables. Restrained: one accent, neutral
- surfaces, a quiet secondary for links. Swap [data-theme] to reskin. */
-
:root {
- --radius: 10px;
- --radius-sm: 7px;
- --font-ui: "Bricolage Grotesque", system-ui, sans-serif;
-}
-
-[data-theme="midnight"] {
- --bg: #0c0e13;
- --bg-elev: #11141b;
- --surface: #161a22;
- --surface-2: #0e1117;
- --border: #232834;
- --border-strong: #313847;
- --text: #e7eaf0;
- --text-soft: #9aa3b2;
- --text-faint: #626c7d;
- --accent: #7d9bff;
- --accent-hover: #97aeff;
- --accent-contrast: #0a0c10;
- --accent-soft: rgba(125, 155, 255, 0.13);
- --link: #5fd0bf;
- --shadow: rgba(0, 0, 0, 0.5);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(125, 155, 255, 0.09), transparent 70%);
-}
-
-[data-theme="carbon"] {
- --bg: #100f0d;
- --bg-elev: #16140f; /* (dark variants verified for AA contrast) */
- --surface: #1c1915;
- --surface-2: #131110;
- --border: #2a261f;
- --border-strong: #3a352b;
- --text: #ece6da;
- --text-soft: #a89e8c;
- --text-faint: #6f6757;
- --accent: #d3a24e;
- --accent-hover: #e6b667;
- --accent-contrast: #14110a;
- --accent-soft: rgba(211, 162, 78, 0.13);
- --link: #c98a6a;
- --shadow: rgba(0, 0, 0, 0.55);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(211, 162, 78, 0.08), transparent 70%);
-}
-
-[data-theme="dusk"] {
- --bg: #110f17;
- --bg-elev: #16131f;
- --surface: #1c1827;
- --surface-2: #120f19;
- --border: #2a2438;
- --border-strong: #3a3350;
- --text: #e9e4f1;
- --text-soft: #a59cba;
- --text-faint: #6c6483;
- --accent: #b48cf5;
- --accent-hover: #c6a6f9;
- --accent-contrast: #0f0c15;
- --accent-soft: rgba(180, 140, 245, 0.14);
- --link: #6fb6e8;
- --shadow: rgba(0, 0, 0, 0.5);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(180, 140, 245, 0.1), transparent 70%);
-}
-
-[data-theme="paper"] {
- --bg: #f6f5f1;
- --bg-elev: #fbfaf7;
+ color-scheme: light dark;
+ --font-ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
+ --font-scale: 1;
+ --space-control: 0.72rem;
+ --radius: 0.375rem;
+ --focus: #0b63ce;
+ --danger: #b42318;
+ --danger-surface: #fff1f0;
+ --success: #176b3a;
+ --warning: #8a4b08;
+}
+
+html[data-text-size="125"] { --font-scale: 1.25; }
+html[data-text-size="150"] { --font-scale: 1.5; }
+html[data-density="compact"] { --space-control: 0.48rem; }
+
+html,
+html[data-color-mode="light"] {
+ --bg: #f4f6f8;
--surface: #ffffff;
- --surface-2: #fbfaf7;
- --border: #e6e2d9;
- --border-strong: #d4cec1;
- --text: #1f2228;
- --text-soft: #5a626e;
- --text-faint: #97a0ad;
- --accent: #c2403a;
- --accent-hover: #d4504a;
- --accent-contrast: #ffffff;
- --accent-soft: rgba(194, 64, 58, 0.09);
- --link: #1f7a6d;
- --shadow: rgba(40, 38, 33, 0.12);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(194, 64, 58, 0.05), transparent 70%);
-}
-
-[data-theme="mint"] {
- --bg: #f0f4f2;
- --bg-elev: #f7faf8;
- --surface: #ffffff;
- --surface-2: #f7faf8;
- --border: #dde7e1;
- --border-strong: #c6d5cc;
- --text: #182420;
- --text-soft: #4e5d56;
- --text-faint: #859990;
- --accent: #0f9d6b;
- --accent-hover: #14b87e;
- --accent-contrast: #ffffff;
- --accent-soft: rgba(15, 157, 107, 0.1);
- --link: #2563eb;
- --shadow: rgba(20, 40, 32, 0.11);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(15, 157, 107, 0.06), transparent 70%);
+ --surface-subtle: #eef2f5;
+ --surface-selected: #e7f1fb;
+ --border: #aab4bf;
+ --border-strong: #66717d;
+ --text: #17212b;
+ --text-muted: #4d5a67;
+ --accent: #075ea8;
+ --accent-hover: #004b87;
+ --accent-text: #ffffff;
+ --status-bg: #e9edf1;
+}
+
+html[data-color-mode="dark"] {
+ --bg: #171a1d;
+ --surface: #22272b;
+ --surface-subtle: #2b3136;
+ --surface-selected: #173b59;
+ --border: #7d8993;
+ --border-strong: #c1c8ce;
+ --text: #f4f6f8;
+ --text-muted: #c3cbd2;
+ --accent: #72b7f2;
+ --accent-hover: #a5d2f7;
+ --accent-text: #08131d;
+ --status-bg: #343b41;
+ --danger: #ffb4ab;
+ --danger-surface: #5c1b17;
+ --success: #8ed7aa;
+ --warning: #ffc47b;
+}
+
+@media (prefers-color-scheme: dark) {
+ html[data-color-mode="system"] {
+ --bg: #171a1d;
+ --surface: #22272b;
+ --surface-subtle: #2b3136;
+ --surface-selected: #173b59;
+ --border: #7d8993;
+ --border-strong: #c1c8ce;
+ --text: #f4f6f8;
+ --text-muted: #c3cbd2;
+ --accent: #72b7f2;
+ --accent-hover: #a5d2f7;
+ --accent-text: #08131d;
+ --status-bg: #343b41;
+ --danger: #ffb4ab;
+ --danger-surface: #5c1b17;
+ --success: #8ed7aa;
+ --warning: #ffc47b;
+ }
}
-/* Cyberdeck — CryptoJones/cyberdeck-theme. Cyan-on-near-black terminal look,
- monospace. The default on phones; a selectable swatch on desktop. The deck's
- own tokens (--panel/--fg/--stamp) are mapped onto this app's variable contract. */
-[data-theme="cyberdeck"] {
- --bg: #07090f;
- --bg-elev: #0c1018;
- --surface: #0c121c;
- --surface-2: #0a0f17;
- --border: #141a26;
- --border-strong: #233247;
- --text: #cfd8e3;
- --text-soft: #88aaaa;
- --text-faint: #5a6678;
- --accent: #27d4ff;
- --accent-hover: #6fe3ff;
- --accent-contrast: #07090f;
- --accent-soft: rgba(39, 212, 255, 0.13);
- --link: #55ff99;
- --shadow: rgba(0, 0, 0, 0.6);
- --glow: radial-gradient(900px 500px at 78% -8%, rgba(39, 212, 255, 0.1), transparent 70%);
- /* a deck is a terminal — monospace everywhere (JetBrains Mono is vendored, so
- this still looks right fully offline) */
- --font-ui: Menlo, Consolas, "JetBrains Mono", "DejaVu Sans Mono", monospace;
-}
-/* Force the mono over the app's serif body (.font-body) + display wordmark. */
-[data-theme="cyberdeck"] body,
-[data-theme="cyberdeck"] .font-body,
-[data-theme="cyberdeck"] .font-display {
- font-family: Menlo, Consolas, "JetBrains Mono", "DejaVu Sans Mono", monospace;
-}
-/* Signature neon: cyan glow on the wordmark + a cyan top rule on the masthead. */
-[data-theme="cyberdeck"] .text-stamp,
-[data-theme="cyberdeck"] .login-mark {
- text-shadow: 0 0 12px rgba(39, 212, 255, 0.4);
-}
-[data-theme="cyberdeck"] .masthead {
- border-top: 2px solid var(--accent);
+html[data-contrast="high"] {
+ --bg: Canvas;
+ --surface: Canvas;
+ --surface-subtle: Canvas;
+ --surface-selected: Highlight;
+ --border: CanvasText;
+ --border-strong: CanvasText;
+ --text: CanvasText;
+ --text-muted: CanvasText;
+ --accent: LinkText;
+ --accent-hover: LinkText;
+ --accent-text: Canvas;
+ --status-bg: Canvas;
+ --focus: Highlight;
+}
+
+@media (prefers-contrast: more) {
+ html[data-contrast="system"] {
+ --border: CanvasText;
+ --border-strong: CanvasText;
+ --text-muted: CanvasText;
+ }
}
-/* ============================================================ BASE ======= */
-
+* { box-sizing: border-box; }
+html { font-size: calc(100% * var(--font-scale)); }
body {
+ margin: 0;
+ min-width: 0;
background: var(--bg);
color: var(--text);
-}
-
-.app-glow {
- pointer-events: none;
- position: fixed;
- inset: 0;
- z-index: 0;
- background: var(--glow);
-}
-
-body,
-.masthead,
-.sidebar,
-.index-card,
-.sheet,
-.field-input,
-.field-textarea,
-.title-input,
-.tag-chip,
-.btn,
-.seg button {
- transition: background-color 0.25s ease, border-color 0.25s ease,
- color 0.25s ease, box-shadow 0.18s ease, transform 0.12s ease;
-}
-
-/* ============================================================ CHROME ===== */
-
-.masthead {
- border-bottom: 1px solid var(--border);
- background: color-mix(in srgb, var(--bg-elev) 75%, transparent);
- backdrop-filter: blur(8px);
-}
-
-.sidebar {
- border-inline-end: 1px solid var(--border);
- background: var(--bg-elev);
-}
-
-/* ---- Language select --------------------------------------------------- */
-
-.lang-select {
font-family: var(--font-ui);
- font-size: 12px;
- font-weight: 600;
- color: var(--text-soft);
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.3rem 0.5rem;
- cursor: pointer;
- max-width: 7.5rem;
-}
-.lang-select:hover {
- color: var(--text);
- border-color: var(--border-strong);
-}
-.lang-select:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-soft);
+ font-size: 1rem;
+ line-height: 1.5;
}
-.lang-select option {
+.font-body, .font-display { font-family: var(--font-ui) !important; }
+button, input, select, textarea { font: inherit; }
+button, a, input, select, textarea { touch-action: manipulation; }
+a { color: var(--accent); text-decoration-thickness: 0.1em; text-underline-offset: 0.15em; }
+a:hover { color: var(--accent-hover); }
+h1, h2, h3 { line-height: 1.25; margin-block: 0 0.75rem; letter-spacing: 0; }
+h1 { font-size: 1.35rem; }
+h2 { font-size: 1.65rem; }
+h3 { font-size: 1.15rem; }
+p { max-width: 70ch; }
+:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
+[hidden] { display: none !important; }
+.sr-only {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+.skip-link {
+ position: fixed;
+ z-index: 1000;
+ top: 0.5rem;
+ left: 0.5rem;
+ padding: 0.65rem 0.9rem;
background: var(--surface);
- color: var(--text);
+ border: 2px solid var(--border-strong);
+ transform: translateY(-180%);
}
+.skip-link:focus { transform: translateY(0); }
-/* ---- Theme swatches ---------------------------------------------------- */
-
-.swatch {
- width: 20px;
- height: 20px;
- border-radius: 6px;
- border: 1px solid var(--border-strong);
- display: grid;
- place-items: center;
- cursor: pointer;
- padding: 0;
-}
-.swatch::after {
- content: "";
- width: 8px;
- height: 8px;
- border-radius: 50%;
- display: block;
-}
-.swatch:hover {
- transform: translateY(-1px);
-}
-.swatch.active {
- box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 3px var(--accent);
-}
-.swatch[data-theme="midnight"] { background: #0c0e13; }
-.swatch[data-theme="midnight"]::after { background: #7d9bff; }
-.swatch[data-theme="carbon"] { background: #100f0d; }
-.swatch[data-theme="carbon"]::after { background: #d3a24e; }
-.swatch[data-theme="dusk"] { background: #110f17; }
-.swatch[data-theme="dusk"]::after { background: #b48cf5; }
-.swatch[data-theme="paper"] { background: #f6f5f1; }
-.swatch[data-theme="paper"]::after { background: #c2403a; }
-.swatch[data-theme="mint"] { background: #f0f4f2; }
-.swatch[data-theme="mint"]::after { background: #0f9d6b; }
-.swatch[data-theme="cyberdeck"] { background: #07090f; }
-.swatch[data-theme="cyberdeck"]::after { background: #27d4ff; box-shadow: 0 0 6px #27d4ff; }
-
-/* ---- Search ------------------------------------------------------------ */
-
-.catalog-search {
+.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
+.app-header {
+ min-height: 4rem;
display: flex;
align-items: center;
- gap: 0.5rem;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.4rem 0.6rem;
-}
-.catalog-search:focus-within {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-soft);
-}
-.search-icon {
- color: var(--text-faint);
- font-size: 0.8rem;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.6rem clamp(1rem, 3vw, 2rem);
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
}
+.wordmark { color: var(--text); font-size: 1.35rem; text-decoration: none; }
+.wordmark strong { color: var(--accent); }
+.header-actions { display: flex; align-items: center; gap: 0.5rem; }
-/* ---- Tag chips --------------------------------------------------------- */
-
+.button,
+.icon-button,
+.seg button,
.tag-chip {
- font-family: var(--font-ui);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.01em;
- padding: 0.14rem 0.55rem;
- border: 1px solid var(--border);
- border-radius: 999px;
- color: var(--text-soft);
- background: var(--surface-2);
+ min-height: 2.75rem;
+ border: 2px solid transparent;
+ border-radius: var(--radius);
+ padding: var(--space-control) 0.9rem;
cursor: pointer;
- white-space: nowrap;
-}
-.tag-chip:hover {
- border-color: var(--accent);
- color: var(--accent);
+ font-weight: 650;
+ line-height: 1.1;
}
-.tag-chip.active {
+.button-primary, .btn-primary {
background: var(--accent);
+ color: var(--accent-text);
border-color: var(--accent);
- color: var(--accent-contrast);
-}
-
-/* ============================================================ LIST ======= */
-
-.index-card {
- position: relative;
- cursor: pointer;
- padding: 0.65rem 0.8rem;
- background: transparent;
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- animation: card-in 0.32s ease backwards;
-}
-.index-card:hover {
- background: var(--surface);
- border-color: var(--border);
-}
-.index-card.active {
- background: var(--surface);
- border-color: var(--border-strong);
}
-.index-card.active::before {
- content: "";
- position: absolute;
- inset-inline-start: 0;
- top: 0.55rem;
- bottom: 0.55rem;
- width: 3px;
- border-radius: 3px;
- background: var(--accent);
-}
-
-.index-card .ic-title {
- font-family: var(--font-ui);
- font-weight: 600;
- font-size: 0.92rem;
- line-height: 1.2;
- color: var(--text);
-}
-.index-card .ic-meta {
- font-family: "JetBrains Mono", monospace;
- font-size: 9.5px;
- letter-spacing: 0.04em;
- color: var(--text-faint);
- margin-top: 0.25rem;
-}
-.index-card .ic-snippet {
- font-size: 0.84rem;
- line-height: 1.4;
- color: var(--text-soft);
- margin-top: 0.3rem;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-@keyframes card-in {
- from { opacity: 0; transform: translateY(5px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-/* ============================================================ SHEET ====== */
-
-.sheet {
- max-width: 46rem;
- margin: 0 auto;
+.button-primary:hover, .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.button-secondary, .btn-ghost, .btn {
background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- box-shadow: 0 1px 2px var(--shadow), 0 12px 40px -28px var(--shadow);
- padding: 2.25rem 2.5rem 2.5rem;
- animation: sheet-in 0.4s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
-}
-@keyframes sheet-in {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.sheet-eyebrow {
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.16em;
- text-transform: uppercase;
- color: var(--text-faint);
-}
-.sheet-title {
- font-family: var(--font-ui);
- font-weight: 700;
- font-size: 2rem;
- line-height: 1.1;
- letter-spacing: -0.02em;
color: var(--text);
- margin: 0.35rem 0 0.85rem;
+ border-color: var(--border);
}
+.button-secondary:hover, .btn-ghost:hover { border-color: var(--border-strong); background: var(--surface-subtle); }
+.button-danger, .btn-danger { background: var(--danger-surface); color: var(--danger); border-color: var(--danger); }
+.button:disabled, .btn:disabled { cursor: wait; opacity: 0.65; }
+.icon-button { width: 2.75rem; padding: 0; background: var(--surface); color: var(--text); border-color: var(--border); }
+.btn { min-height: 2.75rem; border-width: 2px; border-style: solid; border-radius: var(--radius); padding: var(--space-control) 0.9rem; cursor: pointer; font-weight: 650; }
+.btn.btn-primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
+.btn.btn-primary:hover { background: var(--accent-hover); color: var(--accent-text); border-color: var(--accent-hover); }
+.btn.btn-danger { background: var(--danger-surface); color: var(--danger); border-color: var(--danger); }
+.btn-xs { min-height: 2.75rem; padding: 0.4rem 0.65rem; }
-.meta-row {
+.pipeline {
display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem 1.3rem;
- padding: 0.65rem 0;
- border-top: 1px solid var(--border);
+ gap: 0.4rem;
+ overflow-x: auto;
+ padding: 0.65rem clamp(1rem, 3vw, 2rem);
+ background: var(--surface-subtle);
border-bottom: 1px solid var(--border);
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- color: var(--text-soft);
-}
-.meta-row .label {
- color: var(--text-faint);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- margin-inline-end: 0.35rem;
-}
-
-.date-stamp {
- display: inline-block;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.06em;
- color: var(--accent);
- background: var(--accent-soft);
- border-radius: 999px;
- padding: 0.16rem 0.55rem;
}
-
-/* ============================================================ PROSE ====== */
-
-.prose-omi {
- font-size: 1.05rem;
- line-height: 1.75;
+.pipe-stat {
+ min-height: 2.75rem;
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.45rem 0.7rem;
color: var(--text);
-}
-.prose-omi h1 { display: none; }
-.prose-omi h2 {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.7rem;
- font-weight: 700;
- letter-spacing: 0.18em;
- text-transform: uppercase;
- color: var(--text-faint);
- margin: 1.9rem 0 0.6rem;
-}
-.prose-omi p { margin: 0.7rem 0; color: var(--text-soft); }
-.prose-omi strong { color: var(--text); font-weight: 600; }
-.prose-omi ul { margin: 0.5rem 0; padding-inline-start: 1.2rem; list-style: none; }
-.prose-omi li { margin: 0.3rem 0; position: relative; color: var(--text-soft); }
-.prose-omi li::before {
- content: "";
- position: absolute;
- inset-inline-start: -0.95rem;
- top: 0.68em;
- width: 4px;
- height: 4px;
- border-radius: 50%;
- background: var(--accent);
-}
-.prose-omi a { color: inherit; }
-.prose-omi code {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.85em;
- background: var(--surface-2);
+ background: var(--surface);
border: 1px solid var(--border);
- border-radius: 4px;
- padding: 0.05em 0.35em;
-}
-
-.wikilink {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.84em;
- color: var(--link);
- text-decoration: none;
- border-bottom: 1px solid color-mix(in srgb, var(--link) 40%, transparent);
- cursor: pointer;
-}
-.wikilink:hover { color: var(--accent); border-color: var(--accent); }
-.wikilink.missing {
- color: var(--text-faint);
- border-style: dotted;
- cursor: help;
-}
-
-.hash-tag {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.8em;
- color: var(--accent);
- background: var(--accent-soft);
- border-radius: 5px;
- padding: 0.03em 0.34em;
+ border-radius: var(--radius);
cursor: pointer;
}
-.hash-tag:hover { filter: brightness(1.12); }
-
-/* ============================================================ FORMS ====== */
-
-.field-label {
- display: block;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--text-faint);
- margin-bottom: 0.35rem;
-}
-.field-input,
-.field-textarea {
+.pipe-stat[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--surface-selected); padding: calc(0.45rem - 2px) calc(0.7rem - 2px); }
+.pipe-stat .n { font-weight: 750; }
+.pipe-total { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem; color: var(--text-muted); }
+
+.workspace { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr); }
+.list-pane { min-width: 0; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); }
+.list-tools { padding: 1rem; border-bottom: 1px solid var(--border); }
+.list-heading-row { display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline; }
+.result-count { color: var(--text-muted); white-space: nowrap; }
+.search-field, .filter-row label { display: grid; gap: 0.3rem; color: var(--text); font-weight: 650; }
+.search-field > span, .filter-row label > span, .field-label { font-size: 0.9rem; }
+input, select, textarea, .field-input, .field-textarea, .title-input {
width: 100%;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.55rem 0.7rem;
- font-family: "Newsreader", serif;
- font-size: 1rem;
+ min-height: 2.75rem;
+ padding: 0.6rem 0.7rem;
color: var(--text);
+ background: var(--surface);
+ border: 2px solid var(--border);
+ border-radius: var(--radius);
}
-.field-input::placeholder,
-.field-textarea::placeholder { color: var(--text-faint); }
-.field-input:focus,
-.field-textarea:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-soft);
-}
-.field-input.mono,
-.field-textarea.mono {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.82rem;
-}
-.field-textarea { resize: vertical; line-height: 1.6; }
-.field-hint {
- font-family: "JetBrains Mono", monospace;
- font-size: 9.5px;
- color: var(--text-faint);
- margin-top: 0.3rem;
- letter-spacing: 0.02em;
-}
-
-.title-input {
+textarea, .field-textarea { min-height: 7rem; resize: vertical; line-height: 1.5; }
+.title-input { font-size: 1.4rem; font-weight: 700; }
+input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--focus); }
+[aria-invalid="true"] { border-color: var(--danger) !important; }
+.field-error, .error-summary { color: var(--danger); font-weight: 650; }
+.error-summary { padding: 0.8rem; border: 2px solid var(--danger); border-radius: var(--radius); background: var(--danger-surface); }
+.field-help, .settings-help { color: var(--text-muted); font-size: 0.95rem; }
+.filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-top: 0.8rem; }
+.application-list { min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0.6rem; }
+.application-list li + li { margin-top: 0.35rem; }
+.application-card {
width: 100%;
- background: transparent;
- border: none;
- border-bottom: 1px solid var(--border);
- font-family: var(--font-ui);
- font-weight: 700;
- font-size: 1.8rem;
- letter-spacing: -0.02em;
- color: var(--text);
- padding: 0.15rem 0;
-}
-.title-input::placeholder { color: var(--text-faint); }
-.title-input:focus { outline: none; border-color: var(--accent); }
-
-/* ---- Buttons ----------------------------------------------------------- */
-
-.btn {
- font-family: var(--font-ui);
- font-size: 12.5px;
- font-weight: 600;
- letter-spacing: 0.01em;
- padding: 0.45rem 0.95rem;
- border-radius: var(--radius-sm);
- cursor: pointer;
- border: 1px solid transparent;
-}
-.btn-primary {
- color: var(--accent-contrast);
- background: var(--accent);
-}
-.btn-primary:hover { background: var(--accent-hover); }
-.btn-primary:active { transform: translateY(1px); }
-.btn-ghost {
- color: var(--text-soft);
- background: transparent;
- border-color: var(--border-strong);
-}
-.btn-ghost:hover { color: var(--text); border-color: var(--text-soft); }
-.btn-danger {
- color: #e5484d;
- background: transparent;
- border-color: color-mix(in srgb, #e5484d 35%, transparent);
-}
-.btn-danger:hover { background: color-mix(in srgb, #e5484d 12%, transparent); }
-
-/* ---- Segmented toggle -------------------------------------------------- */
-
-.seg {
- display: inline-flex;
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- overflow: hidden;
- background: var(--surface-2);
-}
-.seg button {
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- padding: 0.34rem 0.75rem;
- color: var(--text-soft);
- background: transparent;
-}
-.seg button:hover { color: var(--text); }
-.seg button.active { background: var(--accent); color: var(--accent-contrast); }
-
-/* ============================================================ EMPTY ====== */
-
-.empty {
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- color: var(--text-faint);
- gap: 0.85rem;
-}
-.empty .card-glyph {
- width: 96px;
- height: 96px;
- border-radius: 18px;
- border: 1px solid var(--border);
- background: var(--surface);
+ min-height: 4.5rem;
display: grid;
- place-items: center;
- box-shadow: 0 12px 40px -28px var(--shadow);
-}
-.empty .card-glyph::after {
- content: "";
- width: 34px;
- height: 34px;
- border-radius: 50%;
- border: 2px solid var(--accent);
- border-right-color: transparent;
- transform: rotate(-20deg);
-}
-.empty .empty-title {
- font-family: var(--font-ui);
- font-weight: 600;
- font-size: 1.2rem;
- color: var(--text-soft);
-}
-
-/* ============================================================ TOAST ====== */
-
-.toast {
- position: fixed;
- bottom: 1.5rem;
- left: 50%;
- transform: translateX(-50%) translateY(180%);
- background: var(--surface);
+ gap: 0.25rem;
+ padding: 0.7rem;
+ text-align: left;
color: var(--text);
- font-family: "JetBrains Mono", monospace;
- font-size: 11.5px;
- letter-spacing: 0.02em;
- padding: 0.6rem 1.1rem;
- border-radius: 999px;
- border: 1px solid var(--border-strong);
- box-shadow: 0 10px 30px -10px var(--shadow);
- z-index: 50;
- transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
- pointer-events: none;
-}
-.toast::before {
- content: "";
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--accent);
- margin-inline-end: 0.55rem;
- vertical-align: middle;
-}
-.toast.show { transform: translateX(-50%) translateY(0); }
-
-/* ============================================================ SCROLLBAR == */
-
-::-webkit-scrollbar { width: 10px; height: 10px; }
-::-webkit-scrollbar-thumb {
- background: var(--border-strong);
- border-radius: 6px;
+ background: var(--surface);
border: 2px solid transparent;
- background-clip: content-box;
-}
-::-webkit-scrollbar-thumb:hover {
- background: var(--text-faint);
- background-clip: content-box;
-}
-
-/* ========================================================== BACKLINKS == */
-
-.backlinks {
- margin-top: 2rem;
- padding-top: 1.1rem;
- border-top: 1px solid var(--border);
-}
-.backlinks-head {
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.16em;
- text-transform: uppercase;
- color: var(--text-faint);
- margin-bottom: 0.6rem;
-}
-.backlinks-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 0.4rem;
-}
-.backlinks-list li {
+ border-radius: var(--radius);
cursor: pointer;
- padding: 0.5rem 0.7rem;
- border: 1px solid var(--border);
- border-radius: calc(var(--radius) - 4px);
- background: var(--bg-elev);
- transition: border-color 0.15s, transform 0.15s;
-}
-.backlinks-list li:hover {
- border-color: var(--accent);
- transform: translateX(2px);
}
-[dir="rtl"] .backlinks-list li:hover {
- transform: translateX(-2px);
-}
-.backlinks-list .bl-title {
- display: block;
- font-family: var(--font-ui);
- font-weight: 600;
- font-size: 0.92rem;
+.application-card:hover { background: var(--surface-subtle); border-color: var(--border); }
+.application-card[aria-current="true"] { background: var(--surface-selected); border-color: var(--accent); }
+.application-card-header { display: flex; justify-content: space-between; align-items: start; gap: 0.5rem; }
+.ic-title { font-weight: 750; font-size: 1rem; }
+.ic-meta, .ic-snippet { color: var(--text-muted); font-size: 0.9rem; }
+.badge, .lane-pill, .score-chip {
+ display: inline-block;
+ padding: 0.15rem 0.45rem;
color: var(--text);
-}
-.backlinks-list .bl-snippet {
- display: block;
- font-size: 0.8rem;
- line-height: 1.35;
- color: var(--text-soft);
- margin-top: 0.15rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* ==================================================== STATUS + LANES ===== */
-/* Pipeline status pill. Hues are fixed (not theme tokens) so a stage reads the
- same across themes; the chip surface still tints with --accent-soft. */
-
-.badge {
- display: inline-flex;
- align-items: center;
- gap: 0.4em;
- font-family: "JetBrains Mono", monospace;
- font-size: 9.5px;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- padding: 0.16rem 0.5rem;
- border-radius: 999px;
+ background: var(--status-bg);
border: 1px solid var(--border);
- background: var(--surface-2);
- color: var(--text-soft);
+ border-radius: 0.25rem;
+ font-size: 0.85rem;
+ font-weight: 700;
white-space: nowrap;
}
-.badge::before {
- content: "";
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- flex: none;
-}
-.badge[data-status="lead"] { color: #8b94a6; }
-.badge[data-status="ready"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
-.badge[data-status="applied"] { color: #5b8def; }
-.badge[data-status="screen"] { color: #d9a441; }
-.badge[data-status="onsite"] { color: #b48cf5; }
-.badge[data-status="offer"] { color: #2fb37a; border-color: color-mix(in srgb, #2fb37a 45%, transparent); }
-.badge[data-status="rejected"] { color: #c75d63; }
-.badge[data-status="passed"] { color: var(--text-faint); }
-
-/* Lane pill — which strength the role leads with. */
-.lane-pill {
- font-family: "JetBrains Mono", monospace;
- font-size: 9px;
- font-weight: 600;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- padding: 0.12rem 0.45rem;
- border-radius: 5px;
- border: 1px solid var(--border);
- color: var(--text-soft);
-}
-.lane-pill[data-lane="dotnet"] { color: #7a86f0; }
-.lane-pill[data-lane="devrel"] { color: #e08a4f; }
-.lane-pill[data-lane="ai"] { color: #2fb39b; }
-/* Fit score chip (0–100) — agent's lane/keyword match estimate. */
-.score-chip {
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.04em;
- color: var(--accent);
- background: var(--accent-soft);
- border-radius: 999px;
- padding: 0.1rem 0.45rem;
-}
-
-/* Filter selects in the sidebar header. */
-.filter-row { display: flex; gap: 0.5rem; }
-.filter-select {
- flex: 1;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.04em;
- color: var(--text-soft);
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.3rem 0.4rem;
- cursor: pointer;
-}
-.filter-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
-.filter-select option { background: var(--surface); color: var(--text); }
+.content-pane { min-width: 0; padding: clamp(1rem, 4vw, 3rem); overflow-y: auto; scroll-padding-top: 1rem; }
+.sheet { width: min(100%, 58rem); margin: 0 auto; }
+.sheet-title { overflow-wrap: anywhere; }
+.sheet-eyebrow { margin-bottom: 0.35rem; color: var(--text-muted); font-size: 0.9rem; font-weight: 650; }
+.meta-row { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; margin-top: 1rem; padding-block: 0.8rem; border-block: 1px solid var(--border); }
+.meta-row .label { font-weight: 700; }
+.view-header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
+.action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 1rem; }
+.action-row.end { justify-content: flex-end; }
+.action-row.split { justify-content: space-between; }
+.section-divider { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
+.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
+.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.field { display: grid; gap: 0.3rem; margin-top: 1rem; }
+.mono { font-family: var(--font-mono); }
+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
+.seg button { min-height: 2.75rem; color: var(--text); background: var(--surface); border: 0; border-radius: 0; }
+.seg button[aria-selected="true"], .seg button.active { color: var(--accent-text); background: var(--accent); }
+.prose-omi { max-width: 72ch; overflow-wrap: anywhere; }
+.prose-omi img { max-width: 100%; height: auto; }
+.prose-omi pre { max-width: 100%; padding: 0.8rem; overflow-x: auto; background: var(--surface-subtle); }
+.prose-omi table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
+.prose-omi th, .prose-omi td { padding: 0.4rem; border: 1px solid var(--border); }
+.empty { width: min(100%, 42rem); margin: 12vh auto 0; text-align: center; }
+.empty-title { font-size: 1.4rem; font-weight: 700; }
+
+.settings-tabs { display: flex; gap: 0.35rem; overflow-x: auto; padding-bottom: 0.6rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
+.settings-tabs button { flex: 0 0 auto; }
+.settings-group { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
+.preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
+.source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.4rem; }
+.source-row { min-height: 2.75rem; display: flex; align-items: center; gap: 0.6rem; }
+.source-row input { width: 1.25rem; min-height: 1.25rem; height: 1.25rem; }
+.board-list { display: grid; gap: 0.5rem; }
+.board-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem; border: 1px solid var(--border); border-radius: var(--radius); }
+.board-slug { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.board-add { display: grid; grid-template-columns: minmax(8rem, 0.6fr) minmax(10rem, 1fr) auto; gap: 0.5rem; }
+.exp-row { margin-top: 0.7rem; padding: 0.8rem; border: 1px solid var(--border); border-radius: var(--radius); }
-/* Pipeline summary strip under the masthead. */
-.pipeline {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
- padding: 0.55rem 1.5rem;
- border-bottom: 1px solid var(--border);
- background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
-}
-.pipe-stat {
- display: inline-flex;
- align-items: baseline;
- gap: 0.35rem;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--text-faint);
- padding: 0.1rem 0.5rem;
- border-radius: 999px;
- border: 1px solid var(--border);
- cursor: pointer;
-}
-.pipe-stat:hover { border-color: var(--border-strong); color: var(--text-soft); }
-.pipe-stat.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
-.pipe-stat .n { font-size: 12px; font-weight: 700; color: var(--text); }
-/* Grand total, pushed to the right of the per-status pills. */
-.pipe-total {
- display: inline-flex;
- align-items: baseline;
- gap: 0.35rem;
- margin-left: auto;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--accent);
- padding: 0.1rem 0.5rem;
- border-radius: 999px;
- border: 1px solid var(--accent);
- background: var(--accent-soft);
-}
-.pipe-total .n { font-size: 12px; font-weight: 700; color: var(--accent); }
-
-/* "Apply" call-to-action on a staged (ready) application. */
-.btn-apply {
- color: var(--accent-contrast);
- background: var(--accent);
- text-decoration: none;
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
-}
-.btn-apply:hover { background: var(--accent-hover); }
-
-.link-status { font-family: var(--font-ui); }
-.link-status.ok { color: #46b17b; }
-.link-status.bad { color: #e5484d; }
-
-/* ---- Criteria panel ---- */
-.source-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 0.35rem 1rem;
-}
-.source-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-family: var(--font-ui);
- font-size: 0.9rem;
- color: var(--text);
- cursor: pointer;
-}
-.source-row input[type="checkbox"] {
- width: 15px;
- height: 15px;
- accent-color: var(--accent);
- cursor: pointer;
-}
-.board-list { display: flex; flex-direction: column; gap: 0.4rem; }
-.board-row {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.35rem 0.55rem;
-}
-.board-prov {
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--accent);
-}
-.board-slug { flex: 1; font-size: 0.85rem; color: var(--text); }
-.board-add {
- display: grid;
- grid-template-columns: auto 1fr auto;
- gap: 0.5rem;
- align-items: center;
-}
-.btn-xs { padding: 0.1rem 0.45rem; font-size: 0.75rem; line-height: 1.2; }
-
-/* ============================================================ LOGIN ====== */
-
-.login-overlay {
+.toast {
position: fixed;
- inset: 0;
- z-index: 50;
- display: grid;
- place-items: center;
- padding: 1.5rem;
- background: color-mix(in srgb, var(--bg) 88%, transparent);
- backdrop-filter: blur(6px);
-}
-.login-card {
- width: 100%;
- max-width: 23rem;
- display: flex;
- flex-direction: column;
- gap: 0.85rem;
- padding: 2rem 1.75rem;
- border: 1px solid var(--border);
+ z-index: 100;
+ left: 50%;
+ bottom: 1.25rem;
+ max-width: calc(100vw - 2rem);
+ padding: 0.7rem 1rem;
+ color: var(--accent-text);
+ background: var(--accent);
+ border: 2px solid var(--border-strong);
border-radius: var(--radius);
- background: var(--surface);
- box-shadow: 0 24px 70px -34px var(--shadow);
- text-align: center;
-}
-.login-mark { font-size: 1.75rem; font-weight: 800; letter-spacing: -0.01em; }
-.login-sub { color: var(--text-soft); font-size: 0.9rem; }
-.login-signup-head {
- font-size: 1.45rem;
- font-weight: 800;
- line-height: 1.2;
- color: #e5484d;
- margin: 0.35rem 0 0.1rem;
-}
-.login-signup {
- font-size: 1.15rem;
- font-weight: 800;
- line-height: 1.3;
- color: var(--text);
- margin: 0 0 0.15rem;
-}
-.login-error {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.72rem;
- color: #e5484d;
-}
-.login-input {
- width: 100%;
- font-family: "JetBrains Mono", monospace;
- font-size: 0.85rem;
- color: var(--text);
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 0.6rem 0.7rem;
-}
-.login-input:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-soft);
-}
-.login-btn { width: 100%; padding: 0.6rem; }
-.login-btn:disabled { opacity: 0.6; cursor: default; }
-.login-note {
- font-family: "JetBrains Mono", monospace;
- font-size: 0.68rem;
- line-height: 1.5;
- color: var(--text-faint);
-}
-
-/* ===================================================== MOBILE (iOS app) === */
-/* The bottom action bar exists only on phones. Hidden by default (desktop). */
-.mobile-bar { display: none; }
+ transform: translate(-50%, 150%);
+ visibility: hidden;
+}
+.toast.show { transform: translate(-50%, 0); visibility: visible; }
+.dialog { width: min(32rem, calc(100% - 2rem)); padding: 1.25rem; color: var(--text); background: var(--surface); border: 2px solid var(--border-strong); border-radius: var(--radius); }
+.dialog::backdrop { background: rgb(0 0 0 / 65%); }
+.dialog-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: 1.25rem; }
+.login-overlay { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 1rem; background: var(--bg); }
+.login-card { width: min(100%, 30rem); padding: 1.5rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
+.login-mark { font-size: 1.7rem; }
+.login-input { margin-top: 0.5rem; }
+.login-btn { width: 100%; margin-top: 1rem; }
+.login-error { color: var(--danger); font-weight: 700; }
+.login-note, .login-signup { color: var(--text-muted); }
+.login-signup-head { font-weight: 750; }
+.mobile-actions { display: none; }
@media (max-width: 767px) {
- /* --- Header: compact, notch-aware. Per-route buttons live in the bottom bar. */
- .masthead {
- position: sticky;
- top: 0;
- z-index: 50;
- padding: max(0.6rem, env(safe-area-inset-top)) 1rem 0.6rem;
- }
- #theme-picker { display: none; } /* mobile is locked to cyberdeck */
- #settings-btn, #poll-btn, #new-btn { display: none; } /* moved to bottom bar */
-
- /* --- Pipeline: a single horizontally-scrolling strip of status pills. */
- .pipeline {
- overflow-x: auto;
- flex-wrap: nowrap;
- -webkit-overflow-scrolling: touch;
- }
-
- /* --- Two-pane workspace -> one screen at a time (list <-> detail). The row
- reserves space for the fixed bottom bar; both panes fill it absolutely. */
- #workspace {
- position: relative;
- overflow: hidden;
- margin-bottom: calc(54px + env(safe-area-inset-bottom));
- }
- #workspace .sidebar {
- position: absolute;
- inset: 0;
- width: 100%;
- border-inline-end: 0;
- }
- #workspace > #content {
- position: absolute;
- inset: 0;
- padding: 1rem;
- transform: translateX(100%); /* detail parked off-screen right */
- transition: transform 0.25s ease;
- }
- body.m-detail #workspace > #content { transform: translateX(0); } /* slide in */
- body.m-detail #workspace .sidebar { visibility: hidden; }
-
- /* --- Sheets + forms go full width and single column. */
- .sheet { max-width: 100%; padding: 1.25rem 0.25rem; }
- .grid-cols-2,
- .grid-cols-3,
- .grid-cols-\[1fr_2fr_auto\] { grid-template-columns: 1fr !important; }
-
- /* --- Sidebar internals: stack the filters, give touchable widths. */
- .filter-row { flex-direction: column; }
- .filter-select { width: 100%; }
-
- /* --- Touch targets >= 44px. */
- .btn { min-height: 44px; }
-
- /* --- Toast clears the bottom bar. */
- .toast { bottom: calc(64px + env(safe-area-inset-bottom)); }
-
- /* --- Bottom action bar: List / New / Poll / Settings. */
- .mobile-bar {
- display: flex;
+ body { padding-bottom: calc(3.75rem + env(safe-area-inset-bottom)); }
+ .app-header { padding-top: max(0.6rem, env(safe-area-inset-top)); }
+ .header-actions { display: none; }
+ .pipeline { padding-inline: 0.75rem; }
+ .workspace { display: block; }
+ .list-pane { min-height: calc(100vh - 8rem); border-right: 0; }
+ .content-pane { display: none; padding: 1rem; }
+ body.m-detail .list-pane { display: none; }
+ body.m-detail .content-pane { display: block; }
+ .view-header { display: block; }
+ .view-header .seg { margin-top: 0.8rem; }
+ .form-grid, .form-grid.three, .preference-grid, .source-grid, .board-add { grid-template-columns: 1fr; }
+ .mobile-actions {
position: fixed;
+ z-index: 90;
left: 0;
right: 0;
bottom: 0;
- z-index: 60;
- background: var(--bg-elev);
- border-top: 1px solid var(--border);
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
padding-bottom: env(safe-area-inset-bottom);
+ background: var(--surface);
+ border-top: 1px solid var(--border);
}
- .mobile-bar button {
- flex: 1;
- min-height: 54px;
- background: transparent;
- border: 0;
- color: var(--text-soft);
- font-size: 1.3rem;
- cursor: pointer;
+ .mobile-actions button { min-height: 3.75rem; padding: 0.35rem; color: var(--text); background: var(--surface); border: 0; border-right: 1px solid var(--border); font-weight: 650; }
+ .mobile-actions #m-list { display: none; }
+ body.m-detail .mobile-actions #m-list { display: block; }
+ body.m-detail .mobile-actions { grid-template-columns: 1.7fr repeat(3, 1fr); }
+ .toast { bottom: calc(4.5rem + env(safe-area-inset-bottom)); }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ html:not([data-motion="reduce"]) .toast { transition: transform 120ms ease-out; }
+}
+
+html[data-motion="reduce"] *,
+html[data-motion="reduce"] *::before,
+html[data-motion="reduce"] *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
}
- .mobile-bar button:active { color: var(--accent); background: var(--accent-soft); }
- /* In detail view, the List button is the "back" affordance — highlight it. */
- body.m-detail .mobile-bar #m-list { color: var(--accent); }
+}
+
+@media (forced-colors: active) {
+ .button, .btn, .icon-button, .application-card, .pipe-stat, input, select, textarea { forced-color-adjust: auto; }
+ .application-card[aria-current="true"], .pipe-stat[aria-pressed="true"] { outline: 3px solid Highlight; outline-offset: -3px; }
}
diff --git a/api/ApplyTrack.Api/wwwroot/app.js b/api/ApplyTrack.Api/wwwroot/app.js
index e560556..ddd45d5 100644
--- a/api/ApplyTrack.Api/wwwroot/app.js
+++ b/api/ApplyTrack.Api/wwwroot/app.js
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
+import { createApiClient } from "./api-client.js";
+import { applyPreferences, readPreferences, renderAccessibilityPreferences } from "./accessibility-preferences.js";
+
"use strict";
const STATUSES = ["lead", "ready", "applied", "screen", "onsite", "offer", "rejected", "passed"];
@@ -25,7 +28,7 @@ const state = {
current: null,
currentVersion: "",
mode: "empty", // empty | view | edit | raw | new | settings
- settingsTab: "criteria",
+ settingsTab: "accessibility",
coverLettersEnabled: true,
};
@@ -36,31 +39,14 @@ const contentEl = $("#content");
const countEl = $("#app-count");
const searchEl = $("#search");
const toastEl = $("#toast");
+const statusMessageEl = $("#status-message");
+const alertMessageEl = $("#alert-message");
+const confirmDialogEl = $("#confirm-dialog");
const laneSel = $("#filter-lane");
const statusSel = $("#filter-status");
// ---- API ------------------------------------------------------------------
-
-async function api(method, path, body) {
- const opts = { method, headers: {} };
- if (body !== undefined) {
- opts.headers["Content-Type"] = "application/json";
- opts.body = JSON.stringify(body);
- }
- const res = await fetch(path, opts);
- if (!res.ok) {
- // No session (or it was revoked): drop straight to the login view. The poll
- // loop swallows the throw, so an expired session bounces here on its own.
- if (res.status === 401) showLogin();
- let detail = res.statusText;
- try { detail = (await res.json()).detail || detail; } catch (_) {}
- const err = new Error(detail);
- err.status = res.status;
- throw err;
- }
- if (res.status === 204) return null;
- return res.json();
-}
+const api = createApiClient(showLogin);
// PUT guarding against external edits (the hourly poller also writes files).
// Send the version we last read; a 409 means it changed underneath us.
@@ -71,7 +57,11 @@ async function saveWithConflict(path, body, label) {
try {
return await api("PUT", versioned, body);
} catch (e) {
- if (e.status === 409 && confirm(`"${label}" changed on disk since you opened it. Overwrite with your version?`)) {
+ if (e.status === 409 && await confirmAction({
+ title: "Application changed",
+ message: `"${label}" changed since you opened it. Overwrite it with your version?`,
+ confirmLabel: "Overwrite",
+ })) {
return api("PUT", path, body);
}
throw e;
@@ -89,19 +79,23 @@ function showLogin() {
const overlay = document.createElement("div");
overlay.id = "login-overlay";
overlay.className = "login-overlay";
+ $("#app-shell").inert = true;
+ $(".mobile-actions").inert = true;
overlay.innerHTML = `
- `;
document.body.appendChild(overlay);
const form = overlay.querySelector("#login-form");
@@ -115,11 +109,12 @@ function showLogin() {
// Always reports success: the API returns 200 either way (no account enumeration).
try { await api("POST", "/api/auth/request", { email }); } catch (_) {}
form.innerHTML = `
- apply track
- Check your inbox.
+ ApplyTrack
+ Check your inbox
If ${escapeHtml(email)} can sign in, a link is on its way —
check your spam folder too. Self-hosting? Look for it in the server logs.
Open source on GitHub ↗
`;
+ form.querySelector("h2").focus();
});
overlay.querySelector("#login-email").focus();
}
@@ -132,9 +127,55 @@ const escapeHtml = (s) =>
function toast(msg) {
toastEl.textContent = msg;
+ toastEl.setAttribute("aria-hidden", "false");
+ announce(msg);
toastEl.classList.add("show");
clearTimeout(toast._t);
- toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600);
+ toast._t = setTimeout(() => {
+ toastEl.classList.remove("show");
+ toastEl.setAttribute("aria-hidden", "true");
+ }, 4000);
+}
+
+function announce(message, assertive = false) {
+ const el = assertive ? alertMessageEl : statusMessageEl;
+ el.textContent = "";
+ requestAnimationFrame(() => { el.textContent = message; });
+}
+
+function confirmAction({ title, message, confirmLabel = "Confirm" }) {
+ $("#confirm-title").textContent = title;
+ $("#confirm-message").textContent = message;
+ $("#confirm-accept").textContent = confirmLabel;
+ confirmDialogEl.returnValue = "cancel";
+ confirmDialogEl.showModal();
+ return new Promise((resolve) => {
+ confirmDialogEl.addEventListener("close", () => resolve(confirmDialogEl.returnValue === "confirm"), { once: true });
+ });
+}
+
+function focusView(selector = "h1, h2") {
+ requestAnimationFrame(() => {
+ const target = contentEl.querySelector(selector) || contentEl;
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
+ target.focus({ preventScroll: true });
+ contentEl.scrollTop = 0;
+ });
+}
+
+function showDetailPane() {
+ document.body.classList.add("m-detail");
+ if (window.matchMedia("(max-width: 767px)").matches)
+ $("#application-list").setAttribute("aria-hidden", "true");
+}
+
+function showListPane({ restoreFocus = true } = {}) {
+ document.body.classList.remove("m-detail");
+ $("#application-list").removeAttribute("aria-hidden");
+ if (restoreFocus) {
+ const active = listEl.querySelector('[aria-current="true"]');
+ (active || searchEl).focus();
+ }
}
const stem = (filename) => filename.replace(/\.md$/i, "");
@@ -154,13 +195,15 @@ function safeUrl(u) {
function renderPipeline() {
const counts = state.stats.status || {};
const parts = STATUSES.filter((s) => counts[s]).map((s) => {
- const active = state.filterStatus === s ? " active" : "";
- return `
- ${counts[s]} ${escapeHtml(STATUS_LABEL[s] || s)} `;
+ const active = state.filterStatus === s;
+ const label = STATUS_LABEL[s] || s;
+ return `
+ ${counts[s]} ${escapeHtml(label)} `;
});
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
if (total) parts.push(`${total} total `);
- pipelineEl.innerHTML = parts.join("") || `no applications yet `;
+ pipelineEl.innerHTML = parts.join("") || `No applications yet `;
pipelineEl.querySelectorAll(".pipe-stat[data-status]").forEach((el) => {
el.addEventListener("click", () => {
state.filterStatus = state.filterStatus === el.dataset.status ? "" : el.dataset.status;
@@ -199,36 +242,38 @@ function renderSidebar() {
const apps = filteredApps();
listEl.innerHTML = "";
if (apps.length === 0) {
- listEl.innerHTML = `no matches `;
+ listEl.innerHTML = `No applications match the current filters. `;
}
apps.forEach((a, i) => {
const li = document.createElement("li");
- li.className = "index-card" + (a.filename === state.current ? " active" : "");
- li.style.animationDelay = `${Math.min(i, 12) * 28}ms`;
+ li.className = "application-list-item";
li.dataset.name = a.filename;
const score = a.score ? `fit ${escapeHtml(a.score)} ` : "";
const contactLine =
a.contact || a.contact_email
- ? `✉ ${escapeHtml(a.contact || a.contact_email)}${
+ ? `Contact: ${escapeHtml(a.contact || a.contact_email)}${
a.contact && a.contact_email ? " · " + escapeHtml(a.contact_email) : ""
- }
`
+ }`
: "";
- li.innerHTML = `
-
+ const selected = a.filename === state.current;
+ li.innerHTML = `
+
- ${lanePill(a.lane)}${score}
+
+ ${lanePill(a.lane)} ${score}
${a.applied ? "· applied " + escapeHtml(a.applied) : ""}
- ${a.followup ? "· ↻ " + escapeHtml(a.followup) : ""}
- ${a.role ? `${escapeHtml(a.role)}
` : ""}
- ${contactLine}`;
- li.addEventListener("click", () => openApp(a.filename));
+ ${a.followup ? "· follow-up " + escapeHtml(a.followup) : ""}
+ ${a.role ? `${escapeHtml(a.role)} ` : ""}
+ ${contactLine}`;
+ li.querySelector("button").addEventListener("click", () => openApp(a.filename));
listEl.appendChild(li);
});
const total = state.apps.length;
const shown = apps.length;
- countEl.textContent = shown === total ? `${total} apps` : `${shown}/${total}`;
+ countEl.textContent = shown === total
+ ? `${total} application${total === 1 ? "" : "s"}`
+ : `${shown} of ${total} applications`;
}
// ---- Main pane ------------------------------------------------------------
@@ -236,15 +281,13 @@ function renderSidebar() {
function renderEmpty() {
state.mode = "empty";
state.current = null;
- document.body.classList.remove("m-detail"); // mobile: back to the list pane
+ showListPane({ restoreFocus: false });
+ document.title = "Applications | ApplyTrack";
renderSidebar();
contentEl.innerHTML = `
-
-
No application selected
-
- Pick one · or press + New
-
+
No application selected
+
Choose an application from the list or create a new one.
`;
}
@@ -256,7 +299,9 @@ async function openApp(name) {
state.mode = "view";
renderSidebar();
renderView(data);
- document.body.classList.add("m-detail"); // mobile: slide the detail pane in
+ showDetailPane();
+ document.title = `${data.fields.company || stem(name)} | ApplyTrack`;
+ focusView("h2");
} catch (e) {
toast(e.message);
}
@@ -290,23 +335,23 @@ function renderView(data) {
: "";
contentEl.innerHTML = `
-
+
${metaRow(f)}
-
${applyBtn}${checkBtn}
-
+
${applyBtn}${checkBtn}
+
${DOMPurify.sanitize(marked.parse(f.notes || "_No notes yet._", { gfm: true, breaks: false }))}
${materialSection(data)}
-
+
Mark applied
Pass
Blacklist company
@@ -375,7 +420,7 @@ function materialSection(data) {
Cover letter
-
Draft a tailored letter from your résumé via the configured AI.
+
Draft a tailored letter from your résumé via the configured AI.
Generate cover letter
@@ -427,7 +472,11 @@ function downloadMaterial(data) {
}
async function discardMaterial(name) {
- if (!confirm("Discard this cover letter? You can regenerate it later.")) return;
+ if (!await confirmAction({
+ title: "Discard cover letter?",
+ message: "You can generate another cover letter later.",
+ confirmLabel: "Discard",
+ })) return;
try {
await api("DELETE", `/api/apps/${encodeURIComponent(name)}/cover-letter`);
openApp(name);
@@ -490,7 +539,11 @@ async function markStatus(data, action) {
// are passed. Then jump to the next actionable role, like Pass.
async function blacklistCompany(data) {
const company = data.fields.company || stem(data.filename);
- if (!confirm(`Blacklist "${company}"? Future polls will skip it and its open leads will be passed.`))
+ if (!await confirmAction({
+ title: `Blacklist ${company}?`,
+ message: "Future polls will skip this company and its open leads will be passed.",
+ confirmLabel: "Blacklist company",
+ }))
return;
try {
const r = await api("POST", `/api/apps/${encodeURIComponent(data.filename)}/blacklist`);
@@ -516,29 +569,34 @@ function formMarkup(f, { isNew }) {
const eyebrow = isNew ? "New application" : `Editing · ${escapeHtml(state.current)}`;
return `
- ${eyebrow}
-
+ ${eyebrow}
+
+
+ Company *
+
+ Required
+
- Role
+ Role
- Lane
+ Lane
${laneOptions(f.lane || "ai")}
- Status
+ Status
${statusOptions(f.status || "lead")}
-
Posting link
+
Posting link
-
+
⤓ Autofill
@@ -546,56 +604,56 @@ function formMarkup(f, { isNew }) {
- Source
+ Source
- Notes
+ Notes
${escapeHtml(f.notes || "")}
${isNew ? "" : `Delete `}
- Cancel
- ${isNew ? "Create" : "Save"}
+ Cancel
+ ${isNew ? "Create" : "Save"}
`;
@@ -624,16 +682,19 @@ function openEdit(data) {
state.mode = "edit";
contentEl.innerHTML = formMarkup(data.fields, { isNew: false });
wireForm({ isNew: false });
+ document.title = `Edit ${data.fields.company || stem(data.filename)} | ApplyTrack`;
+ focusView("h2");
}
function openNew() {
state.mode = "new";
state.current = null;
- document.body.classList.add("m-detail"); // mobile: show the form pane
+ showDetailPane();
renderSidebar();
const today = new Date().toISOString().slice(0, 10);
contentEl.innerHTML = formMarkup({ created: today, lane: "ai", status: "lead" }, { isNew: true });
wireForm({ isNew: true });
+ document.title = "New application | ApplyTrack";
$("#f-company").focus();
}
@@ -642,7 +703,18 @@ function wireForm({ isNew }) {
state.current ? openApp(state.current) : renderEmpty();
contentEl.querySelector('[data-act="save"]').onclick = async () => {
const fields = gatherFields();
- if (!fields.company) return toast("An application needs a company.");
+ const company = $("#f-company");
+ const summary = $("#form-errors");
+ company.removeAttribute("aria-invalid");
+ summary.hidden = true;
+ if (!fields.company) {
+ company.setAttribute("aria-invalid", "true");
+ summary.textContent = "Enter a company before saving this application.";
+ summary.hidden = false;
+ summary.focus();
+ announce(summary.textContent, true);
+ return;
+ }
try {
if (isNew) {
const { filename } = await api("POST", "/api/apps", fields);
@@ -701,19 +773,22 @@ function openRaw(data) {
state.mode = "raw";
contentEl.innerHTML = `
-
-
Source · ${escapeHtml(data.filename)}
-
-
Form
-
Raw
+
-
${escapeHtml(data.raw)}
+
Application Markdown
+ ${escapeHtml(data.raw)}
Cancel
Save source
`;
+ document.title = `Raw source | ApplyTrack`;
+ focusView("h2");
contentEl.querySelector('[data-act="form"]').onclick = () => openEdit(data);
contentEl.querySelector('[data-act="cancel"]').onclick = () => openApp(data.filename);
contentEl.querySelector('[data-act="save"]').onclick = async () => {
@@ -733,7 +808,11 @@ function openRaw(data) {
// ---- Delete ---------------------------------------------------------------
async function deleteApp(name) {
- if (!confirm(`Delete "${stem(name)}"? This removes the file.`)) return;
+ if (!await confirmAction({
+ title: `Delete ${stem(name)}?`,
+ message: "This permanently deletes the application and its saved cover letter.",
+ confirmLabel: "Delete application",
+ })) return;
try {
await api("DELETE", `/api/apps/${encodeURIComponent(name)}`);
await refresh();
@@ -774,13 +853,14 @@ function sourceToggles(sources) {
function boardRows() {
if (!criteriaBoards.length) {
- return `
No ATS boards added.
`;
+ return `
No ATS boards added.
`;
}
return criteriaBoards.map((b, i) =>
`
${escapeHtml(b.provider)}
${escapeHtml(b.slug)}
- ✕
+ Remove
`).join("");
}
@@ -805,30 +885,30 @@ function criteriaMarkup(c) {
What the poller looks for
-
+
Saved to applications/.criteria.json · used by every poll.
- Keywords — one per line · any match stages a lead
+ Keywords — one per line; any match stages a lead
${escapeHtml((c.keywords || []).join("\n"))}
-
Location filters
+
Location filters
Remote-friendly roles only
@@ -839,18 +919,18 @@ function criteriaMarkup(c) {
-
Sources
+
Sources
${sourceToggles(c.sources || {})}
@@ -927,15 +1007,15 @@ let resumeLinks = [];
function expRows() {
if (!resumeExperience.length)
- return `No experience added.
`;
+ return `No experience added.
`;
return resumeExperience.map((e, i) => `
-
-
-
+
+
+
-
${escapeHtml((e.highlights || []).join("\n"))}
✕ Remove
@@ -971,12 +1051,12 @@ function renderExperience() {
function linkRows() {
if (!resumeLinks.length)
- return `
No links added.
`;
+ return `
No links added.
`;
return resumeLinks.map((l, i) => `
-
-
- ✕
+
+
+ Remove
`).join("");
}
@@ -1006,52 +1086,52 @@ function resumeMarkup(r) {
Résumé
The facts behind your cover letters
-
+
The drafting AI may assert only what's here — no invented employers, titles, or metrics.
- Headline
+ Headline
- Summary
+ Summary
${escapeHtml(r.summary || "")}
-
Experience
+
Experience
+ Add role
- Skills — one per line or comma-separated
+ Skills — one per line or comma-separated
${escapeHtml((r.skills || []).join("\n"))}
- Certifications — one per line
+ Certifications — one per line
${escapeHtml((r.certifications || []).join("\n"))}
-
Links
+
Links
+ Add link
@@ -1132,7 +1212,7 @@ function llmMarkup(s) {
AI · cover-letter endpoint
Where cover letters are drafted
-
+
Any OpenAI-compatible endpoint — a local model (Ollama / vLLM) or a hosted provider.
Leave a field blank to inherit the instance default.
@@ -1142,36 +1222,36 @@ function llmMarkup(s) {
Enable cover-letter drafting
-
+
Untick if you don't want to run a model — the app hides every drafting affordance
and never calls an LLM for this account.
- Base URL
+ Base URL
- Model
+ Model
-
API key ${secretsOff ? "— unavailable on this instance" : "— write-only"}
+
API key ${secretsOff ? "— unavailable on this instance" : "— write-only"}
Remove the saved key
-
${escapeHtml(keyNote)}
+
${escapeHtml(keyNote)}
-
+
Instance default — ${inst.base_url ? escapeHtml(inst.base_url) : "no URL"} ·
${inst.model ? escapeHtml(inst.model) : "no model"} ·
${inst.has_api_key ? "key set" : "no key"}
@@ -1325,7 +1405,11 @@ $("#import-file").addEventListener("change", async (e) => {
const prompt = shared
? "Import this shared list as new leads? Opportunities you already track are left untouched."
: "Import overwrites applications that share a name with ones in this file. Continue?";
- if (!confirm(prompt)) return;
+ if (!await confirmAction({
+ title: shared ? "Import shared opportunities?" : "Import account data?",
+ message: prompt,
+ confirmLabel: "Import",
+ })) return;
try {
const r = await api("POST", "/api/account/import", doc);
await refresh();
@@ -1350,6 +1434,7 @@ $("#import-file").addEventListener("change", async (e) => {
// the tenant-scoped /api routes, so one user's settings never touch another's.
const SETTINGS_TABS = [
+ ["accessibility", "Accessibility"],
["criteria", "Criteria"],
["resume", "Résumé"],
["ai", "AI"],
@@ -1363,26 +1448,39 @@ const SETTINGS_TABS = [
// Each loader re-reads this after its fetch and bails if it's been superseded.
let settingsGen = 0;
-async function openSettings(tab) {
+async function openSettings(tab, focusSelectedTab = false) {
if (SETTINGS_TABS.some(([k]) => k === tab)) state.settingsTab = tab;
const gen = ++settingsGen;
state.mode = "settings";
state.current = null;
- document.body.classList.add("m-detail"); // mobile: show the settings pane
+ showDetailPane();
renderSidebar();
contentEl.innerHTML = `
-
+ Settings
+
${SETTINGS_TABS.map(([k, label]) =>
`${label} `).join("")}
+ role="tab" aria-selected="${k === state.settingsTab}" data-tab="${k}" type="button">${label}`).join("")}
-
`;
+
`;
contentEl.querySelectorAll("[data-tab]").forEach((b) => {
b.onclick = () => openSettings(b.dataset.tab);
});
+ const tabs = [...contentEl.querySelectorAll('[role="tab"]')];
+ tabs.forEach((tabButton, index) => tabButton.addEventListener("keydown", (event) => {
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
+ event.preventDefault();
+ let next = index;
+ if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
+ if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
+ if (event.key === "Home") next = 0;
+ if (event.key === "End") next = tabs.length - 1;
+ openSettings(tabs[next].dataset.tab, true);
+ }));
const body = $("#settings-body");
try {
- if (state.settingsTab === "criteria") await loadCriteriaTab(body, gen);
+ if (state.settingsTab === "accessibility") renderAccessibilityPreferences(body, announce);
+ else if (state.settingsTab === "criteria") await loadCriteriaTab(body, gen);
else if (state.settingsTab === "resume") await loadResumeTab(body, gen);
else if (state.settingsTab === "ai") await loadLlmTab(body, gen);
else if (state.settingsTab === "blacklist") await loadBlacklistTab(body);
@@ -1390,6 +1488,12 @@ async function openSettings(tab) {
} catch (e) {
toast(e.message);
}
+ document.title = `${SETTINGS_TABS.find(([k]) => k === state.settingsTab)?.[1] || "Settings"} | ApplyTrack`;
+ if (focusSelectedTab) {
+ requestAnimationFrame(() => contentEl.querySelector('[role="tab"][aria-selected="true"]')?.focus());
+ } else {
+ focusView("#settings-body h2");
+ }
}
// True once a newer openSettings has started — a stale loader must not touch the DOM.
@@ -1403,7 +1507,7 @@ $("#settings-btn").addEventListener("click", () => openSettings());
$("#m-new").addEventListener("click", openNew);
$("#m-poll").addEventListener("click", runPoll);
$("#m-settings").addEventListener("click", () => openSettings());
-$("#m-list").addEventListener("click", () => document.body.classList.remove("m-detail"));
+$("#m-list").addEventListener("click", () => showListPane());
// Settings · Blacklist tab — the first place blacklisted companies can be seen and
// un-blacklisted (adding happens here or via "Blacklist company" on an app).
@@ -1413,7 +1517,7 @@ async function loadBlacklistTab(body) {
Blacklist
Companies the poller skips
-
+
Leads from these companies are never staged. Names are stored normalized
(lowercased, punctuation-insensitive).
@@ -1422,11 +1526,13 @@ async function loadBlacklistTab(body) {
? companies.map((c) =>
`
${escapeHtml(c)}
- ✕
+ Remove
`).join("")
- : `No blacklisted companies.
`}
+ : `No blacklisted companies.
`}
+ Company name
+ Blacklist company
@@ -1465,7 +1571,7 @@ async function loadAccountTab(body) {
Export — private migration snapshot
-
+
Everything: applications, criteria, blacklist. Import it on another instance to move home.
@@ -1476,7 +1582,7 @@ async function loadAccountTab(body) {
Share — anonymized opportunity list
-
+
Company, role, link, location, source only — no status, notes, contacts, dates, or score.
A peer imports it and every entry lands as a fresh lead.
@@ -1494,7 +1600,7 @@ async function loadAccountTab(body) {
Danger zone
-
+
Deletes your account and every application, setting, and session with it. Immediate and unrecoverable.
@@ -1518,8 +1624,11 @@ async function loadAccountTab(body) {
location.reload();
};
act("delete-account").onclick = async () => {
- if (!confirm("Delete your account? Every application, setting, and cover letter goes with it.")) return;
- if (!confirm("Last chance — this is immediate and unrecoverable. Really delete?")) return;
+ if (!await confirmAction({
+ title: "Delete your account?",
+ message: "Every application, setting, cover letter, and session will be deleted immediately. This cannot be undone.",
+ confirmLabel: "Delete my account",
+ })) return;
try {
await api("DELETE", "/api/account");
location.reload();
@@ -1563,7 +1672,7 @@ async function navigateList(delta) {
if (idx === -1) idx = delta > 0 ? -1 : 0;
idx = Math.max(0, Math.min(apps.length - 1, idx + delta));
await openApp(apps[idx].filename);
- const active = listEl.querySelector(".index-card.active");
+ const active = listEl.querySelector('.application-card[aria-current="true"]');
if (active) active.scrollIntoView({ block: "nearest" });
}
document.addEventListener("keydown", (e) => {
@@ -1585,37 +1694,14 @@ document.addEventListener("keydown", (e) => {
else if (e.key === "k") { e.preventDefault(); navigateList(-1); }
});
-// ---- Theme switcher -------------------------------------------------------
-
-const THEMES = ["midnight", "carbon", "dusk", "paper", "mint", "cyberdeck"];
-const MOBILE = window.matchMedia("(max-width: 767px)");
-function applyTheme(name, persist = true) {
- const theme = THEMES.includes(name) ? name : "midnight";
- document.documentElement.dataset.theme = theme;
- // Don't persist the phone-forced cyberdeck: it would otherwise leak onto desktop
- // when a desktop window is resized across the breakpoint. The saved pref stays
- // the user's explicit choice only.
- if (persist) { try { localStorage.setItem("applytrack-theme", theme); } catch (_) {} }
- document.querySelectorAll(".swatch").forEach((s) => {
- s.classList.toggle("active", s.dataset.theme === theme);
- });
-}
-function savedTheme() {
- try { return localStorage.getItem("applytrack-theme") || "midnight"; } catch (_) { return "midnight"; }
-}
-function initTheme() {
- // Phones are locked to cyberdeck (the mobile app look); desktop uses the saved
- // choice. Re-evaluate when the viewport crosses the phone breakpoint (rotation
- // or a resized desktop window).
- applyTheme(MOBILE.matches ? "cyberdeck" : savedTheme(), !MOBILE.matches);
- document.querySelectorAll(".swatch").forEach((s) => {
- s.addEventListener("click", () => applyTheme(s.dataset.theme));
- });
- MOBILE.addEventListener("change", (e) => {
- applyTheme(e.matches ? "cyberdeck" : savedTheme(), !e.matches);
- });
-}
-initTheme();
+applyPreferences(readPreferences(), false);
+
+const mobileQuery = window.matchMedia("(max-width: 767px)");
+mobileQuery.addEventListener("change", (event) => {
+ const list = $("#application-list");
+ if (!event.matches || !document.body.classList.contains("m-detail")) list.removeAttribute("aria-hidden");
+ else list.setAttribute("aria-hidden", "true");
+});
(async function boot() {
try {
diff --git a/api/ApplyTrack.Api/wwwroot/index.html b/api/ApplyTrack.Api/wwwroot/index.html
index 121863c..870b88a 100644
--- a/api/ApplyTrack.Api/wwwroot/index.html
+++ b/api/ApplyTrack.Api/wwwroot/index.html
@@ -1,108 +1,105 @@
-
+
-
applytrack
-
-
+
+
ApplyTrack
-
-
-
+
+
Skip to application details
-
-
-
-