Skip to content

Commit 3a7e353

Browse files
fix(web): resolve 12 UX/UI audit findings — accessibility, navigation, design (#37)
Critical: - Add skip-to-content link (WCAG 2.4.1) - Replace off-palette blue in DiffViewer with teal/navy tokens - Hide PrecedentDrawer when annotations are empty High: - Extract TITLE_NAMES to shared data file (was duplicated 3x) - TOC sidebar: xl→lg breakpoint + details fallback for mobile - Fix ThemeToggle aria-label interpolation - Add keyboard navigation to search (arrow keys, enter, role=listbox) Medium: - Add Home to all breadcrumb trails - Add mobile hamburger menu with aria-expanded - Fix homepage double title - Add "Current through PL 119-73" release badge - Remove unused Inter font declaration 186 tests passing, typecheck clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ce6250 commit 3a7e353

11 files changed

Lines changed: 183 additions & 218 deletions

File tree

apps/web/src/components/DiffViewer.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,11 @@
9393
<li>
9494
<button
9595
class="w-full rounded px-2 py-1 text-left text-xs transition-colors {selected.includes(commit.sha)
96-
? 'bg-blue-100 dark:bg-blue-900'
96+
? 'bg-teal/10 dark:bg-navy'
9797
: 'hover:bg-gray-100 dark:hover:bg-gray-800'}"
9898
onclick={() => toggleCommit(commit.sha)}
9999
>
100-
<code class="font-mono text-blue-600 dark:text-blue-400">{commit.sha.slice(0, 7)}</code>
100+
<code class="font-mono text-teal dark:text-teal">{commit.sha.slice(0, 7)}</code>
101101
<span class="ml-2 text-gray-700 dark:text-gray-300">{commit.message}</span>
102102
<span class="ml-2 text-gray-400">{commit.date ? new Date(commit.date).toLocaleDateString() : ""}</span>
103103
</button>
@@ -107,7 +107,7 @@
107107

108108
{#if selected.length === 2}
109109
<button
110-
class="mb-3 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
110+
class="mb-3 rounded bg-teal px-3 py-1 text-xs text-white hover:bg-teal/80 disabled:opacity-50"
111111
onclick={() => void loadDiff()}
112112
disabled={diffLoading}
113113
>

apps/web/src/components/PrecedentDrawer.svelte

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,40 +34,36 @@
3434
}
3535
</script>
3636

37-
<!-- Toggle button -->
38-
<button
39-
onclick={() => (open = !open)}
40-
class="fixed right-0 top-1/2 z-40 -translate-y-1/2 rounded-l-md bg-teal-600 px-2 py-3 text-xs font-semibold text-white shadow-lg hover:bg-teal-700"
41-
aria-label={open ? 'Close precedent drawer' : 'Open precedent drawer'}
42-
>
43-
{open ? '' : ''} Cases ({annotations.length})
44-
</button>
45-
46-
<!-- Drawer panel -->
47-
{#if open}
48-
<aside
49-
class="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto border-l border-gray-200 bg-white p-5 shadow-xl sm:w-[400px] dark:border-gray-700 dark:bg-[#0a1628]"
37+
{#if annotations.length > 0}
38+
<!-- Toggle button -->
39+
<button
40+
onclick={() => (open = !open)}
41+
class="fixed right-0 top-1/2 z-40 -translate-y-1/2 rounded-l-md bg-teal-600 px-2 py-3 text-xs font-semibold text-white shadow-lg hover:bg-teal-700"
42+
aria-label={open ? 'Close precedent drawer' : 'Open precedent drawer'}
5043
>
51-
<div class="mb-4 flex items-center justify-between">
52-
<h2 class="text-lg font-bold text-slate-900 dark:text-gray-100">
53-
Precedent <code class="text-sm font-mono">§{sectionId}</code>
54-
</h2>
55-
<button
56-
onclick={() => (open = false)}
57-
class="rounded p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
58-
aria-label="Close drawer"
59-
>✕</button>
60-
</div>
44+
{open ? '' : ''} Cases ({annotations.length})
45+
</button>
6146

62-
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
63-
{annotations.length} annotation{annotations.length !== 1 ? 's' : ''}
64-
</p>
47+
<!-- Drawer panel -->
48+
{#if open}
49+
<aside
50+
class="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto border-l border-gray-200 bg-white p-5 shadow-xl sm:w-[400px] dark:border-gray-700 dark:bg-[#0a1628]"
51+
>
52+
<div class="mb-4 flex items-center justify-between">
53+
<h2 class="text-lg font-bold text-slate-900 dark:text-gray-100">
54+
Precedent <code class="text-sm font-mono">§{sectionId}</code>
55+
</h2>
56+
<button
57+
onclick={() => (open = false)}
58+
class="rounded p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
59+
aria-label="Close drawer"
60+
>✕</button>
61+
</div>
6562

66-
{#if annotations.length === 0}
67-
<p class="text-sm italic text-gray-400 dark:text-gray-500">
68-
No precedent annotations available for this section.
63+
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
64+
{annotations.length} annotation{annotations.length !== 1 ? 's' : ''}
6965
</p>
70-
{:else}
66+
7167
{#each [...grouped()] as [court, cases]}
7268
<h3 class="mt-4 mb-2 text-xs font-bold uppercase tracking-wide {COURT_COLORS[court]}">
7369
{court} ({cases.length})
@@ -100,6 +96,6 @@
10096
{/each}
10197
</ul>
10298
{/each}
103-
{/if}
104-
</aside>
99+
</aside>
100+
{/if}
105101
{/if}

apps/web/src/components/SearchBar.svelte

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
let results = $state<PagefindResultData[]>([]);
2525
let loading = $state(false);
2626
let showResults = $state(false);
27+
let activeIndex = $state(-1);
2728
let pagefind = $state<PagefindModule | null>(null);
2829
let devMode = $state(false);
2930
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@@ -92,8 +93,27 @@
9293
// Delay to allow click on result links
9394
setTimeout(() => {
9495
showResults = false;
96+
activeIndex = -1;
9597
}, 200);
9698
}
99+
100+
function handleKeydown(event: KeyboardEvent): void {
101+
if (!showResults || results.length === 0) return;
102+
103+
if (event.key === 'ArrowDown') {
104+
event.preventDefault();
105+
activeIndex = activeIndex < results.length - 1 ? activeIndex + 1 : 0;
106+
} else if (event.key === 'ArrowUp') {
107+
event.preventDefault();
108+
activeIndex = activeIndex > 0 ? activeIndex - 1 : results.length - 1;
109+
} else if (event.key === 'Enter' && activeIndex >= 0) {
110+
event.preventDefault();
111+
const selected = results[activeIndex];
112+
if (selected) {
113+
window.location.href = selected.url;
114+
}
115+
}
116+
}
97117
</script>
98118

99119
<div class="relative font-sans" role="search">
@@ -112,6 +132,11 @@
112132
bind:value={query}
113133
onfocusin={() => { if (query.length > 0) showResults = true; }}
114134
onfocusout={closeResults}
135+
onkeydown={handleKeydown}
136+
role="combobox"
137+
aria-expanded={showResults}
138+
aria-controls="search-results"
139+
aria-activedescendant={activeIndex >= 0 ? `result-${activeIndex}` : undefined}
115140
class="w-full rounded border border-gray-300 bg-white py-1 pl-8 pr-2 text-xs text-gray-900 placeholder-gray-400 focus:border-teal focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-600 lg:w-48"
116141
/>
117142
</div>
@@ -123,12 +148,12 @@
123148
{:else if results.length === 0}
124149
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">No results found.</div>
125150
{:else}
126-
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
127-
{#each results as result}
128-
<li>
151+
<ul id="search-results" class="divide-y divide-gray-100 dark:divide-gray-800" role="listbox">
152+
{#each results as result, i}
153+
<li role="option" id="result-{i}" aria-selected={i === activeIndex}>
129154
<a
130155
href={result.url}
131-
class="block px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800"
156+
class="block px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 {i === activeIndex ? 'bg-gray-100 dark:bg-gray-800' : ''}"
132157
>
133158
<div class="text-xs font-medium text-gray-900 dark:text-gray-100">
134159
{result.meta.title ?? 'Untitled'}

apps/web/src/components/ThemeToggle.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
<button
4444
onclick={cycle}
45-
aria-label="Toggle theme: {labels[theme]}"
45+
aria-label={`Toggle theme: ${labels[theme]}`}
4646
title="Theme: {labels[theme]}"
4747
class="inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-xs font-sans transition-colors hover:bg-gray-200 dark:border-gray-700 dark:hover:bg-gray-800"
4848
>

apps/web/src/data/title-names.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export const TITLE_NAMES: Record<number, string> = {
2+
1: 'General Provisions',
3+
2: 'The Congress',
4+
3: 'The President',
5+
4: 'Flag and Seal, Seat of Government, and the States',
6+
5: 'Government Organization and Employees',
7+
6: 'Domestic Security',
8+
7: 'Agriculture',
9+
8: 'Aliens and Nationality',
10+
9: 'Arbitration',
11+
10: 'Armed Forces',
12+
11: 'Bankruptcy',
13+
12: 'Banks and Banking',
14+
13: 'Census',
15+
14: 'Coast Guard',
16+
15: 'Commerce and Trade',
17+
16: 'Conservation',
18+
17: 'Copyrights',
19+
18: 'Crimes and Criminal Procedure',
20+
19: 'Customs Duties',
21+
20: 'Education',
22+
21: 'Food and Drugs',
23+
22: 'Foreign Relations and Intercourse',
24+
23: 'Highways',
25+
24: 'Hospitals and Asylums',
26+
25: 'Indians',
27+
26: 'Internal Revenue Code',
28+
27: 'Intoxicating Liquors',
29+
28: 'Judiciary and Judicial Procedure',
30+
29: 'Labor',
31+
30: 'Mineral Lands and Mining',
32+
31: 'Money and Finance',
33+
32: 'National Guard',
34+
33: 'Navigation and Navigable Waters',
35+
34: 'Crime Control and Law Enforcement',
36+
35: 'Patents',
37+
36: 'Patriotic and National Observances, Ceremonies, and Organizations',
38+
37: 'Pay and Allowances of the Uniformed Services',
39+
38: "Veterans' Benefits",
40+
39: 'Postal Service',
41+
40: 'Public Buildings, Property, and Works',
42+
41: 'Public Contracts',
43+
42: 'The Public Health and Welfare',
44+
43: 'Public Lands',
45+
44: 'Public Printing and Documents',
46+
45: 'Railroads',
47+
46: 'Shipping',
48+
47: 'Telecommunications',
49+
48: 'Territories and Insular Possessions',
50+
49: 'Transportation',
51+
50: 'War and National Defense',
52+
51: 'National and Commercial Space Programs',
53+
52: 'Voting and Elections',
54+
53: 'Reserved',
55+
54: 'National Park Service and Related Programs',
56+
};

apps/web/src/layouts/BaseLayout.astro

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const base = import.meta.env.BASE_URL;
2424
<meta name="description" content={description} />
2525
<meta name="generator" content={Astro.generator} />
2626
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
27-
<title>{title} | US Code Tracker</title>
27+
<title>{title === 'US Code Tracker' ? title : `${title} | US Code Tracker`}</title>
2828
<script is:inline>
2929
// Apply theme before render to prevent flash
3030
(function () {
@@ -36,19 +36,32 @@ const base = import.meta.env.BASE_URL;
3636
</script>
3737
</head>
3838
<body class="h-full bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
39+
<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded focus:bg-navy focus:px-4 focus:py-2 focus:text-white">
40+
Skip to content
41+
</a>
3942
<div class="flex h-full flex-col lg:flex-row">
4043
<!-- Sidebar -->
4144
<aside class="w-full border-b border-gray-200 bg-gray-50 font-sans lg:w-64 lg:border-b-0 lg:border-r dark:border-gray-800 dark:bg-gray-900">
4245
<div class="flex items-center justify-between p-4 lg:flex-col lg:items-start lg:gap-4">
4346
<a href={base} class="text-lg font-bold text-navy dark:text-amber">
4447
US Code Tracker
4548
</a>
49+
<span class="hidden text-xs text-slate dark:text-gray-500 lg:inline">Current through PL 119-73</span>
4650
<div class="flex items-center gap-2 lg:w-full lg:flex-col lg:items-stretch">
4751
<ThemeToggle client:load />
4852
<SearchBar client:load />
4953
</div>
5054
</div>
51-
<nav class="hidden px-4 pb-4 font-sans lg:block" aria-label="Main navigation">
55+
<button
56+
id="menu-toggle"
57+
class="rounded border border-gray-300 px-2 py-1 text-xs font-sans lg:hidden dark:border-gray-700"
58+
aria-expanded="false"
59+
aria-controls="main-nav"
60+
aria-label="Open menu"
61+
>
62+
Menu
63+
</button>
64+
<nav id="main-nav" class="hidden px-4 pb-4 font-sans lg:block" aria-label="Main navigation">
5265
<ul class="space-y-1 text-sm">
5366
<li>
5467
<a
@@ -71,7 +84,7 @@ const base = import.meta.env.BASE_URL;
7184
</aside>
7285

7386
<!-- Main content -->
74-
<main class="min-h-0 flex-1 overflow-y-auto">
87+
<main id="main-content" class="min-h-0 flex-1 overflow-y-auto">
7588
<article class="prose prose-gray mx-auto max-w-4xl p-6 font-serif dark:prose-invert lg:p-10">
7689
<slot />
7790
</article>
@@ -90,5 +103,18 @@ const base = import.meta.env.BASE_URL;
90103
</footer>
91104
</main>
92105
</div>
106+
<script is:inline>
107+
(function () {
108+
var btn = document.getElementById('menu-toggle');
109+
var nav = document.getElementById('main-nav');
110+
if (!btn || !nav) return;
111+
btn.addEventListener('click', function () {
112+
var expanded = btn.getAttribute('aria-expanded') === 'true';
113+
btn.setAttribute('aria-expanded', String(!expanded));
114+
btn.setAttribute('aria-label', expanded ? 'Open menu' : 'Close menu');
115+
nav.classList.toggle('hidden', expanded);
116+
});
117+
})();
118+
</script>
93119
</body>
94120
</html>

apps/web/src/pages/browse/[title].astro

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,7 @@
22
import { getCollection } from 'astro:content';
33
import BaseLayout from '../../layouts/BaseLayout.astro';
44
import Breadcrumbs from '../../components/Breadcrumbs.astro';
5-
6-
const TITLE_NAMES: Record<number, string> = {
7-
1: 'General Provisions',
8-
2: 'The Congress',
9-
3: 'The President',
10-
4: 'Flag and Seal, Seat of Government, and the States',
11-
5: 'Government Organization and Employees',
12-
6: 'Domestic Security',
13-
7: 'Agriculture',
14-
8: 'Aliens and Nationality',
15-
9: 'Arbitration',
16-
10: 'Armed Forces',
17-
11: 'Bankruptcy',
18-
12: 'Banks and Banking',
19-
13: 'Census',
20-
14: 'Coast Guard',
21-
15: 'Commerce and Trade',
22-
16: 'Conservation',
23-
17: 'Copyrights',
24-
18: 'Crimes and Criminal Procedure',
25-
19: 'Customs Duties',
26-
20: 'Education',
27-
21: 'Food and Drugs',
28-
22: 'Foreign Relations and Intercourse',
29-
23: 'Highways',
30-
24: 'Hospitals and Asylums',
31-
25: 'Indians',
32-
26: 'Internal Revenue Code',
33-
27: 'Intoxicating Liquors',
34-
28: 'Judiciary and Judicial Procedure',
35-
29: 'Labor',
36-
30: 'Mineral Lands and Mining',
37-
31: 'Money and Finance',
38-
32: 'National Guard',
39-
33: 'Navigation and Navigable Waters',
40-
34: 'Crime Control and Law Enforcement',
41-
35: 'Patents',
42-
36: 'Patriotic and National Observances, Ceremonies, and Organizations',
43-
37: 'Pay and Allowances of the Uniformed Services',
44-
38: "Veterans' Benefits",
45-
39: 'Postal Service',
46-
40: 'Public Buildings, Property, and Works',
47-
41: 'Public Contracts',
48-
42: 'The Public Health and Welfare',
49-
43: 'Public Lands',
50-
44: 'Public Printing and Documents',
51-
45: 'Railroads',
52-
46: 'Shipping',
53-
47: 'Telecommunications',
54-
48: 'Territories and Insular Possessions',
55-
49: 'Transportation',
56-
50: 'War and National Defense',
57-
51: 'National and Commercial Space Programs',
58-
52: 'Voting and Elections',
59-
53: 'Reserved',
60-
54: 'National Park Service and Related Programs',
61-
};
5+
import { TITLE_NAMES } from '../../data/title-names';
626
637
export async function getStaticPaths() {
648
const entries = await getCollection('statutes');
@@ -103,6 +47,7 @@ const base = import.meta.env.BASE_URL;
10347
description={`Chapters in Title ${titleNum} of the United States Code: ${titleName}`}
10448
>
10549
<Breadcrumbs items={[
50+
{ label: 'Home', href: base },
10651
{ label: 'Browse', href: `${base}browse/` },
10752
{ label: `Title ${titleNum} — ${titleName}` },
10853
]} />

0 commit comments

Comments
 (0)