Skip to content

Commit 6465601

Browse files
fix(web): comprehensive design review — 9 issues fixed
Critical: - BackToTop: was listening on window, not scrollable main element (button never appeared). Now attaches to #main-content with $effect cleanup. Tailwind v4 consistency: - Prose line-height: fixed from ignored CSS variable to direct property - Nested list borders: use @theme tokens instead of hardcoded hex - Code/pre backgrounds: replaced oklch() with standard rgb() for browser compat - Removed unused .badge-metadata and redundant footer element selector - Added --color-teal-bright to @theme Accessibility: - PrecedentDrawer: added aria-expanded, aria-controls, proper IDs - SearchBar: results listbox always in DOM (valid aria-controls ref), Escape key dismissal, truncated titles, line-clamped excerpts - DiffViewer: commit buttons get aria-pressed, fixed dark mode contrast - Footer links: aria-label announces "opens in new tab" - Skip-to-content: added focus ring styles Bug fixes: - github.ts: removed silent error suppression in getFileHistory - content.config.ts: generated_at now optional (some files omit it) - Homepage stats: corrected 53 → 54 titles Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c77aec commit 6465601

10 files changed

Lines changed: 109 additions & 91 deletions

File tree

apps/web/src/components/BackToTop.svelte

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
<script lang="ts">
22
let visible = $state(false);
33
4-
if (typeof window !== 'undefined') {
5-
window.addEventListener('scroll', () => {
6-
visible = window.scrollY > 400;
7-
}, { passive: true });
8-
}
4+
$effect(() => {
5+
// The page uses overflow-y-auto on <main>, not on window.
6+
// We must attach to the main element to detect scroll position.
7+
const main = document.getElementById('main-content');
8+
if (!main) return;
9+
10+
function onScroll(): void {
11+
visible = main!.scrollTop > 400;
12+
}
13+
14+
main.addEventListener('scroll', onScroll, { passive: true });
15+
return () => {
16+
main.removeEventListener('scroll', onScroll);
17+
};
18+
});
919
1020
function scrollToTop(): void {
11-
window.scrollTo({ top: 0, behavior: 'smooth' });
21+
const main = document.getElementById('main-content');
22+
if (main) {
23+
main.scrollTo({ top: 0, behavior: 'smooth' });
24+
}
1225
}
1326
</script>
1427

apps/web/src/components/DiffViewer.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,10 @@
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-teal/10 dark:bg-navy'
97-
: 'hover:bg-gray-100 dark:hover:bg-gray-800'}"
96+
? 'bg-teal/10 text-navy ring-1 ring-teal/30 dark:bg-teal/20 dark:text-gray-100'
97+
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
9898
onclick={() => toggleCommit(commit.sha)}
99+
aria-pressed={selected.includes(commit.sha)}
99100
>
100101
<code class="font-mono text-teal dark:text-teal">{commit.sha.slice(0, 7)}</code>
101102
<span class="ml-2 text-gray-700 dark:text-gray-300">{commit.message}</span>

apps/web/src/components/PrecedentDrawer.svelte

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
}
88
99
const COURT_ORDER: CaseAnnotation['court'][] = ['SCOTUS', 'Appellate', 'District'];
10+
// Use custom theme colors (--color-amber, --color-teal, --color-slate) for consistency
1011
const COURT_COLORS: Record<CaseAnnotation['court'], string> = {
11-
SCOTUS: 'text-amber-600 dark:text-amber-400',
12-
Appellate: 'text-teal-600 dark:text-teal-400',
13-
District: 'text-slate-600 dark:text-slate-400',
12+
SCOTUS: 'text-amber dark:text-amber',
13+
Appellate: 'text-teal dark:text-teal-bright',
14+
District: 'text-slate dark:text-gray-400',
1415
};
1516
1617
let { sectionId, annotations }: Props = $props();
@@ -38,16 +39,20 @@
3839
<!-- Toggle button -->
3940
<button
4041
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+
class="fixed right-0 top-1/2 z-40 -translate-y-1/2 rounded-l-md bg-teal px-2 py-3 text-xs font-semibold text-white shadow-lg hover:opacity-90 transition-opacity"
4243
aria-label={open ? 'Close precedent drawer' : 'Open precedent drawer'}
44+
aria-expanded={open}
45+
aria-controls="precedent-drawer-panel"
4346
>
4447
{open ? '' : ''} Cases ({annotations.length})
4548
</button>
4649

4750
<!-- Drawer panel -->
4851
{#if open}
4952
<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]"
53+
id="precedent-drawer-panel"
54+
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-gray-950"
55+
aria-label="Precedent annotations"
5156
>
5257
<div class="mb-4 flex items-center justify-between">
5358
<h2 class="text-lg font-bold text-slate-900 dark:text-gray-100">
@@ -76,7 +81,7 @@
7681
href={c.url}
7782
target="_blank"
7883
rel="noopener noreferrer"
79-
class="text-sm font-medium text-teal-700 underline-offset-2 hover:underline dark:text-teal-400"
84+
class="text-sm font-medium text-teal underline-offset-2 hover:underline dark:text-teal-bright"
8085
>{c.caseName}</a>
8186
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
8287
{c.citation} · {c.court} · {c.date}
@@ -89,7 +94,7 @@
8994
{#if c.holdingSummary.length > 120}
9095
<button
9196
onclick={() => toggleHolding(globalIdx)}
92-
class="mt-1 text-xs text-teal-600 hover:underline dark:text-teal-400"
97+
class="mt-1 text-xs text-teal hover:underline dark:text-teal-bright"
9398
>{expandedHoldings.has(globalIdx) ? 'show less' : 'show more'}</button>
9499
{/if}
95100
</li>

apps/web/src/components/SearchBar.svelte

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@
9999
}
100100
101101
function handleKeydown(event: KeyboardEvent): void {
102+
if (event.key === 'Escape') {
103+
showResults = false;
104+
activeIndex = -1;
105+
return;
106+
}
107+
102108
if (!showResults || results.length === 0) return;
103109
104110
if (event.key === 'ArrowDown') {
@@ -109,9 +115,9 @@
109115
activeIndex = activeIndex > 0 ? activeIndex - 1 : results.length - 1;
110116
} else if (event.key === 'Enter' && activeIndex >= 0) {
111117
event.preventDefault();
112-
const selected = results[activeIndex];
113-
if (selected) {
114-
window.location.href = selected.url;
118+
const selectedResult = results[activeIndex];
119+
if (selectedResult) {
120+
window.location.href = selectedResult.url;
115121
}
116122
}
117123
}
@@ -142,32 +148,41 @@
142148
/>
143149
</div>
144150

145-
{#if showResults}
146-
<div class="absolute left-0 top-full z-50 mt-1 max-h-80 w-72 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
151+
<!-- Always render the listbox container so aria-controls is always valid when expanded -->
152+
<ul
153+
id="search-results"
154+
role="listbox"
155+
aria-label="Search results"
156+
class="absolute left-0 top-full z-50 mt-1 w-72 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 {showResults ? 'max-h-80' : 'hidden'}"
157+
>
158+
{#if showResults}
147159
{#if loading}
148-
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">Searching...</div>
160+
<li role="option" aria-selected="false" class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">Searching...</li>
149161
{:else if results.length === 0}
150-
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">No results found.</div>
162+
<li role="option" aria-selected="false" class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">No results found.</li>
151163
{:else}
152-
<ul id="search-results" class="divide-y divide-gray-100 dark:divide-gray-800" role="listbox">
153-
{#each results as result, i}
154-
<li role="option" id="result-{i}" aria-selected={i === activeIndex}>
155-
<a
156-
href={result.url}
157-
class="block px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 {i === activeIndex ? 'bg-gray-100 dark:bg-gray-800' : ''}"
158-
>
159-
<div class="text-xs font-medium text-gray-900 dark:text-gray-100">
160-
{result.meta.title ?? 'Untitled'}
161-
</div>
162-
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
163-
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
164-
{@html result.excerpt}
165-
</div>
166-
</a>
167-
</li>
168-
{/each}
169-
</ul>
164+
{#each results as result, i}
165+
<li
166+
role="option"
167+
id="result-{i}"
168+
aria-selected={i === activeIndex}
169+
class="divide-y divide-gray-100 dark:divide-gray-800"
170+
>
171+
<a
172+
href={result.url}
173+
class="block px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 {i === activeIndex ? 'bg-gray-100 dark:bg-gray-800' : ''}"
174+
>
175+
<div class="truncate text-xs font-medium text-gray-900 dark:text-gray-100">
176+
{result.meta.title ?? 'Untitled'}
177+
</div>
178+
<div class="mt-0.5 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
179+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
180+
{@html result.excerpt}
181+
</div>
182+
</a>
183+
</li>
184+
{/each}
170185
{/if}
171-
</div>
172-
{/if}
186+
{/if}
187+
</ul>
173188
</div>

apps/web/src/content.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const statutes = defineCollection({
1010
chapter: z.number(),
1111
current_through: z.string(),
1212
classification: z.string(),
13-
generated_at: z.string(),
13+
generated_at: z.string().optional(),
1414
}),
1515
});
1616

apps/web/src/layouts/BaseLayout.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const titleEntries = Object.entries(TITLE_NAMES)
5050
</script>
5151
</head>
5252
<body class="h-full bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
53-
<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">
53+
<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 focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-navy">
5454
Skip to content
5555
</a>
5656
<div class="flex h-full flex-col lg:flex-row">
@@ -124,17 +124,17 @@ const titleEntries = Object.entries(TITLE_NAMES)
124124
<h2 class="mb-2 text-xs font-bold uppercase tracking-wide text-gray-700 dark:text-gray-300">Resources</h2>
125125
<ul class="space-y-1 text-xs">
126126
<li>
127-
<a href="https://uscode.house.gov/" class="underline hover:text-teal" rel="noopener noreferrer">
127+
<a href="https://uscode.house.gov/" class="underline hover:text-teal" rel="noopener noreferrer" target="_blank" aria-label="Official U.S. Code (OLRC), opens in new tab">
128128
Official U.S. Code (OLRC)
129129
</a>
130130
</li>
131131
<li>
132-
<a href="https://github.com/civic-source/us-code" class="underline hover:text-teal" rel="noopener noreferrer">
132+
<a href="https://github.com/civic-source/us-code" class="underline hover:text-teal" rel="noopener noreferrer" target="_blank" aria-label="Source Data on GitHub, opens in new tab">
133133
Source Data (GitHub)
134134
</a>
135135
</li>
136136
<li>
137-
<a href="https://github.com/civic-source/us-code-tracker" class="underline hover:text-teal" rel="noopener noreferrer">
137+
<a href="https://github.com/civic-source/us-code-tracker" class="underline hover:text-teal" rel="noopener noreferrer" target="_blank" aria-label="Pipeline Code on GitHub, opens in new tab">
138138
Pipeline Code (GitHub)
139139
</a>
140140
</li>

apps/web/src/lib/github.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,15 @@ export async function getFileHistory(
3131
path: string,
3232
token?: string,
3333
): Promise<CommitInfo[]> {
34-
try {
35-
const octokit = createClient(token);
36-
const response = await octokit.repos.listCommits({ owner, repo, path, per_page: 50 });
34+
const octokit = createClient(token);
35+
const response = await octokit.repos.listCommits({ owner, repo, path, per_page: 50 });
3736

38-
return response.data.map((c) => ({
39-
sha: c.sha,
40-
message: c.commit.message.split("\n")[0] ?? "",
41-
date: c.commit.author?.date ?? "",
42-
author: c.commit.author?.name ?? "unknown",
43-
}));
44-
} catch {
45-
return [];
46-
}
37+
return response.data.map((c) => ({
38+
sha: c.sha,
39+
message: c.commit.message.split("\n")[0] ?? "",
40+
date: c.commit.author?.date ?? "",
41+
author: c.commit.author?.name ?? "unknown",
42+
}));
4743
}
4844

4945
export async function getFileDiff(
@@ -78,10 +74,10 @@ export async function getFileDiff(
7874
}
7975

8076
export function isRateLimited(error: unknown): boolean {
81-
return (
82-
typeof error === "object" &&
83-
error !== null &&
84-
"status" in error &&
85-
(error as { status: number }).status === 403
86-
);
77+
if (typeof error !== "object" || error === null || !("status" in error)) {
78+
return false;
79+
}
80+
const status = (error as { status: number }).status;
81+
// GitHub returns 403 for unauthenticated rate limits and 429 for secondary rate limits
82+
return status === 403 || status === 429;
8783
}

apps/web/src/pages/index.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
1717
<!-- Quick stats -->
1818
<div class="not-prose my-6 flex flex-wrap gap-4">
1919
<div class="rounded-lg border border-teal/30 bg-teal/5 px-5 py-3 text-center font-sans">
20-
<p class="text-2xl font-bold text-teal">53</p>
20+
<p class="text-2xl font-bold text-teal">54</p>
2121
<p class="mt-0.5 text-xs text-slate dark:text-gray-400">Titles</p>
2222
</div>
2323
<div class="rounded-lg border border-teal/30 bg-teal/5 px-5 py-3 text-center font-sans">
@@ -46,7 +46,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
4646
</li>
4747
<li class="flex items-start gap-2">
4848
<span class="mt-0.5 text-teal" aria-hidden="true">&#x1F50D;</span>
49-
<span>Provides a browsable interface for exploring all 54 titles of the US Code.</span>
49+
<span>Provides a browsable interface for exploring all titles of the US Code.</span>
5050
</li>
5151
</ul>
5252

apps/web/src/pages/statute/[...slug].astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const isReserved = entry.data.title.includes('Reserved');
102102
target="_blank"
103103
rel="noopener noreferrer"
104104
class="rounded bg-teal/10 px-2 py-1 text-teal hover:bg-teal/20 transition-colors"
105+
aria-label={`View § ${usc_section} on the Official U.S. Code, opens in new tab`}
105106
>
106107
View on OLRC &rarr;
107108
</a>

apps/web/src/styles/global.css

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
99
--color-navy: #1B3A5C;
1010
--color-teal: #0D7377;
11+
--color-teal-bright: #14a3a8;
1112
--color-amber: #D4A843;
1213
--color-slate: #4A5568;
1314
}
1415

1516
/* Legal text readability — 18px base for comfortable reading */
1617
.prose {
17-
--tw-prose-body-line-height: 1.85;
1818
font-size: 1.125rem; /* 18px */
19+
line-height: 1.85; /* comfortable for long legal paragraphs */
1920
max-width: min(72ch, 100%);
2021
}
2122

@@ -64,15 +65,15 @@
6465

6566
.prose ul ul {
6667
padding-left: 1.75em;
67-
border-left: 2px solid #0D7377;
68+
border-left: 2px solid var(--color-teal);
6869
margin-left: 0.5em;
6970
margin-top: 0.5em;
7071
margin-bottom: 0.5em;
7172
}
7273

7374
/* Dark mode: slightly brighter teal border for nested lists */
7475
:where(.dark, .dark *) .prose ul ul {
75-
border-left-color: #14a3a8;
76+
border-left-color: var(--color-teal-bright);
7677
}
7778

7879
/* At deep nesting, reduce padding to prevent horizontal overflow */
@@ -93,41 +94,27 @@
9394

9495
/* Code/reference blocks — subtle background tint */
9596
.prose code {
96-
background-color: #f1f5f9; /* slate-100 */
97+
background-color: rgb(74 85 104 / 10%); /* slate at 10% opacity */
9798
padding: 0.15em 0.35em;
9899
border-radius: 0.25em;
99100
font-size: 0.875em;
100101
}
101102

102103
:where(.dark, .dark *) .prose code {
103-
background-color: #1e293b; /* slate-800 */
104+
background-color: rgb(27 58 92 / 50%); /* navy at 50% opacity */
105+
color: var(--color-gray-200, #e2e8f0);
104106
}
105107

106108
.prose pre {
107-
background-color: #f8fafc; /* slate-50 */
108-
border: 1px solid #e2e8f0; /* slate-200 */
109+
background-color: var(--color-gray-50, #f8fafc);
110+
border: 1px solid var(--color-gray-200, #e2e8f0);
109111
border-radius: 0.375em;
110112
padding: 1em;
111113
}
112114

113115
:where(.dark, .dark *) .prose pre {
114-
background-color: #0f172a; /* slate-900 */
115-
border-color: #334155; /* slate-700 */
116-
}
117-
118-
/* Metadata badge contrast — light mode */
119-
.badge-metadata {
120-
background-color: #f3f4f6; /* gray-100 */
121-
color: #374151; /* gray-700 */
122-
}
123-
124-
/* Footer text — ensure readable in both modes */
125-
footer {
126-
color: #4A5568;
127-
}
128-
129-
:where(.dark, .dark *) footer {
130-
color: #9ca3af; /* gray-400 */
116+
background-color: var(--color-gray-950, #030712);
117+
border-color: var(--color-gray-700, #334155);
131118
}
132119

133120
/* Print styles */

0 commit comments

Comments
 (0)