Skip to content

Commit 74e04dd

Browse files
InfantLabclaude
andcommitted
feat: accessibility improvements — Phase 1 + 2 (WCAG 2.2 AA)
Phase 1 (quick wins): - Skip-to-main-content link in default layout - aria-hidden on decorative nav SVGs - role="dialog" + aria-modal + aria-labelledby on all 5 modals - Form labels wired to inputs via for/id in 4 components - aria-pressed on CategoryFilter and ZoomToggle buttons - Chart nav button touch targets p-1 → p-2 (~28px → ~36px) - aria-label on TimelineStrip toggle button Phase 2 (moderate effort): - text-tada-600 → text-tada-700 for body text on light backgrounds - text-stone-400 → text-stone-500 for light-mode informational text - Hardcoded #9ca3af → #6b7280 in chart scoped styles - Focus management: move focus into modals on open, restore on close - role="radiogroup" + aria-checked on RhythmCreateModal button groups - Rhythm expand/collapse: div→button with aria-expanded Also adds design/accessibility.md handover doc for Phase 3 work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93beaf9 commit 74e04dd

22 files changed

Lines changed: 349 additions & 37 deletions

app/components/ActivityAutocomplete.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ onUnmounted(() => {
172172
<!-- Label -->
173173
<label
174174
v-if="label"
175+
for="activity-input"
175176
class="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1"
176177
>
177178
{{ label }}
@@ -180,6 +181,7 @@ onUnmounted(() => {
180181
<!-- Input -->
181182
<div class="relative">
182183
<input
184+
id="activity-input"
183185
ref="inputRef"
184186
:value="modelValue"
185187
type="text"

app/components/CategoryFilter.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const categories = computed(() => [
4040
:key="cat.value"
4141
type="button"
4242
class="flex items-center gap-1.5 px-3 py-2 rounded-full text-sm whitespace-nowrap transition-colors"
43+
:aria-pressed="selectedCategory === cat.value"
4344
:class="
4445
selectedCategory === cat.value
4546
? 'bg-tada-500 text-white shadow-md'

app/components/CategorySubcategoryPicker.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ const selectClass = computed(() =>
6464
<template>
6565
<div :class="gridClass">
6666
<div :class="wrapperClass">
67-
<label :class="labelClass">Category</label>
67+
<label for="category-select" :class="labelClass">Category</label>
6868
<select
69+
id="category-select"
6970
:value="category ?? ''"
7071
:class="selectClass"
7172
@change="handleCategoryChange"
@@ -83,8 +84,9 @@ const selectClass = computed(() =>
8384
</select>
8485
</div>
8586
<div :class="wrapperClass">
86-
<label :class="labelClass">Subcategory</label>
87+
<label for="subcategory-select" :class="labelClass">Subcategory</label>
8788
<select
89+
id="subcategory-select"
8890
:value="subcategory ?? ''"
8991
:disabled="!category"
9092
:class="selectClass"

app/components/ContextualHelpPanel.vue

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,12 +287,15 @@ onUnmounted(() => {
287287
>
288288
<div
289289
v-if="open"
290+
role="dialog"
291+
aria-modal="true"
292+
aria-labelledby="help-panel-title"
290293
class="fixed right-0 top-0 bottom-0 w-full max-w-md bg-pearl-cream dark:bg-cosmic-void border-l border-stone-200 dark:border-stone-700 z-50 overflow-y-auto"
291294
>
292295
<!-- Header -->
293296
<div class="sticky top-0 bg-pearl-cream/95 dark:bg-cosmic-void/95 backdrop-blur-sm border-b border-stone-200 dark:border-stone-700 px-6 py-4 flex items-center justify-between">
294297
<div>
295-
<h2 class="text-lg font-semibold text-stone-800 dark:text-stone-100">
298+
<h2 id="help-panel-title" class="text-lg font-semibold text-stone-800 dark:text-stone-100">
296299
{{ currentHelp.title }}
297300
</h2>
298301
<p class="text-sm text-stone-500 dark:text-stone-400">
@@ -336,7 +339,7 @@ onUnmounted(() => {
336339
</p>
337340
<p
338341
v-if="section.tip"
339-
class="mt-2 text-sm text-tada-600 dark:text-tada-400 flex items-start gap-1"
342+
class="mt-2 text-sm text-tada-700 dark:text-tada-400 flex items-start gap-1"
340343
>
341344
<span class="flex-shrink-0">💡</span>
342345
<span>{{ section.tip }}</span>
@@ -350,7 +353,7 @@ onUnmounted(() => {
350353
<div class="pt-4 border-t border-stone-200 dark:border-stone-700 space-y-3">
351354
<NuxtLink
352355
to="/help"
353-
class="flex items-center gap-2 text-tada-600 dark:text-tada-400 hover:underline"
356+
class="flex items-center gap-2 text-tada-700 dark:text-tada-400 hover:underline"
354357
@click="close"
355358
>
356359
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

app/components/DurationPicker.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ function clearDuration() {
154154
:value="preciseMinutes"
155155
type="number"
156156
min="0"
157+
aria-label="Minutes"
157158
:disabled="disabled"
158159
class="w-16 px-2 py-2 rounded-lg border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-900 text-stone-900 dark:text-white text-center focus:ring-2 focus:ring-tada-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
159160
@input="
@@ -171,6 +172,7 @@ function clearDuration() {
171172
type="number"
172173
min="0"
173174
max="59"
175+
aria-label="Seconds"
174176
:disabled="disabled"
175177
class="w-16 px-2 py-2 rounded-lg border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-900 text-stone-900 dark:text-white text-center focus:ring-2 focus:ring-tada-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
176178
@input="

app/components/EmojiPicker.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ const emit = defineEmits<Emits>();
2222
2323
const pickerContainer = ref<HTMLElement | null>(null);
2424
const pickerInstance = ref<EmojiPickerElement | null>(null);
25+
const closeButtonRef = ref<HTMLElement | null>(null);
26+
const triggerElement = ref<Element | null>(null);
2527
2628
// Close modal
2729
function close() {
2830
emit("update:modelValue", false);
2931
32+
// Restore focus to the element that opened the picker
33+
(triggerElement.value as HTMLElement | null)?.focus();
34+
triggerElement.value = null;
35+
3036
// Clean up picker instance so it recreates fresh next time
3137
if (pickerInstance.value && pickerContainer.value) {
3238
try {
@@ -56,6 +62,9 @@ watch(
5662
() => props.modelValue,
5763
async (isOpen) => {
5864
if (isOpen && !pickerInstance.value) {
65+
// Save trigger element for focus restoration on close
66+
triggerElement.value = document.activeElement;
67+
5968
// Wait for next tick to ensure DOM is ready
6069
await nextTick();
6170
@@ -81,6 +90,11 @@ watch(
8190
// Store instance and append to container
8291
pickerInstance.value = picker;
8392
pickerContainer.value.appendChild(picker);
93+
94+
// Focus the close button so keyboard users land inside the modal
95+
nextTick(() => {
96+
closeButtonRef.value?.focus();
97+
});
8498
} catch (error) {
8599
console.error("Failed to load emoji picker:", error);
86100
}
@@ -119,6 +133,9 @@ onUnmounted(() => {
119133
@click="close"
120134
>
121135
<div
136+
role="dialog"
137+
aria-modal="true"
138+
aria-labelledby="emoji-picker-title"
122139
class="bg-white dark:bg-stone-800 rounded-xl shadow-xl overflow-hidden max-h-[90vh]"
123140
@click.stop
124141
>
@@ -127,6 +144,7 @@ onUnmounted(() => {
127144
class="flex items-center justify-between px-4 py-3 border-b border-stone-200 dark:border-stone-700"
128145
>
129146
<h3
147+
id="emoji-picker-title"
130148
class="text-lg font-semibold text-stone-800 dark:text-stone-100"
131149
>
132150
Choose Emoji
@@ -135,6 +153,7 @@ onUnmounted(() => {
135153
</span>
136154
</h3>
137155
<button
156+
ref="closeButtonRef"
138157
class="p-2 hover:bg-stone-100 dark:hover:bg-stone-700 rounded-lg transition-colors"
139158
@click="close"
140159
>

app/components/LandingPage.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const philosophyPoints = [
109109
:key="point"
110110
class="flex items-start gap-3 p-4 bg-white/60 dark:bg-stone-800/60 rounded-xl"
111111
>
112-
<span class="text-tada-600 dark:text-tada-400 text-xl">✓</span>
112+
<span class="text-tada-700 dark:text-tada-400 text-xl">✓</span>
113113
<span class="text-stone-700 dark:text-stone-300">{{ point }}</span>
114114
</div>
115115
</div>
@@ -209,7 +209,7 @@ const philosophyPoints = [
209209
href="https://github.com/infantlab/tada"
210210
target="_blank"
211211
rel="noopener noreferrer"
212-
class="inline-flex items-center gap-2 text-tada-600 hover:text-tada-700 dark:text-tada-400 dark:hover:text-tada-300 font-medium"
212+
class="inline-flex items-center gap-2 text-tada-700 hover:text-tada-800 dark:text-tada-400 dark:hover:text-tada-300 font-medium"
213213
>
214214
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
215215
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />

app/components/QuickEntryModal.vue

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ const emit = defineEmits<{
5656
const { createEntry, checkConflicts, isLoading } = useEntryEngine();
5757
const toast = useToast();
5858
59+
// Focus management
60+
const firstFocusRef = ref<HTMLElement | null>(null);
61+
const triggerElement = ref<Element | null>(null);
62+
5963
// Form state
6064
const mode = ref<EntryMode>(props.initialMode);
6165
const name = ref(props.initialName);
@@ -107,6 +111,9 @@ watch(
107111
() => props.open,
108112
(isOpen) => {
109113
if (isOpen) {
114+
// Save trigger element for focus restoration on close
115+
triggerElement.value = document.activeElement;
116+
110117
// Check if resuming from draft
111118
if (props.resumeDraft) {
112119
const draft = props.resumeDraft;
@@ -144,6 +151,15 @@ watch(
144151
}
145152
conflicts.value = null;
146153
conflictResolution.value = null;
154+
155+
// Move focus into modal after DOM update
156+
nextTick(() => {
157+
firstFocusRef.value?.focus();
158+
});
159+
} else {
160+
// Restore focus to the element that opened the modal
161+
(triggerElement.value as HTMLElement | null)?.focus();
162+
triggerElement.value = null;
147163
}
148164
},
149165
);
@@ -355,18 +371,22 @@ const modeLabels: Record<EntryMode, string> = {
355371
>
356372
<div
357373
v-if="open"
374+
role="dialog"
375+
aria-modal="true"
376+
aria-labelledby="quick-entry-title"
358377
class="relative w-full sm:max-w-md mx-auto bg-white dark:bg-stone-900 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto"
359378
>
360379
<!-- Header -->
361380
<div
362381
class="flex items-center justify-between px-4 py-3 border-b border-stone-200 dark:border-stone-700"
363382
>
364-
<h2 class="text-lg font-semibold text-stone-900 dark:text-white">
383+
<h2 id="quick-entry-title" class="text-lg font-semibold text-stone-900 dark:text-white">
365384
{{ modeLabels[mode] }}
366385
</h2>
367386
<button
387+
ref="firstFocusRef"
368388
type="button"
369-
class="p-1 rounded-full text-stone-400 hover:text-stone-600 dark:hover:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors"
389+
class="p-2 rounded-full text-stone-400 hover:text-stone-600 dark:hover:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors"
370390
@click="closeModal"
371391
>
372392
<svg
@@ -467,12 +487,14 @@ const modeLabels: Record<EntryMode, string> = {
467487
<!-- Notes (moved up, right after title) -->
468488
<div class="space-y-1">
469489
<label
490+
for="entry-notes"
470491
class="block text-sm font-medium text-stone-700 dark:text-stone-300"
471492
>
472493
Notes
473494
<span class="text-stone-400 font-normal">(optional)</span>
474495
</label>
475496
<textarea
497+
id="entry-notes"
476498
v-model="notes"
477499
:rows="2"
478500
:placeholder="

app/components/RhythmBarChart.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<!-- Navigation header -->
44
<div class="mb-2 flex items-center justify-between">
55
<button
6-
class="rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
6+
class="rounded p-2 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
77
@click.stop="previousPeriod"
88
>
99
<svg
@@ -26,7 +26,7 @@
2626
</span>
2727

2828
<button
29-
class="rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
29+
class="rounded p-2 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
3030
:disabled="isCurrentPeriod"
3131
:class="{ 'opacity-30 cursor-not-allowed': isCurrentPeriod }"
3232
@click.stop="nextPeriod"
@@ -350,7 +350,7 @@ function getTooltip(day: ChartDay): string {
350350
351351
.day-label {
352352
font-size: 0.5rem;
353-
color: #9ca3af;
353+
color: #6b7280;
354354
margin-top: 2px;
355355
line-height: 1;
356356
}
@@ -379,7 +379,7 @@ function getTooltip(day: ChartDay): string {
379379
top: 50%;
380380
transform: translateY(-50%) rotate(-90deg);
381381
font-size: 0.5rem;
382-
color: #9ca3af;
382+
color: #6b7280;
383383
text-transform: uppercase;
384384
letter-spacing: 0.05em;
385385
}

app/components/RhythmCreateModal.vue

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ const emit = defineEmits<{
5252
): void;
5353
}>();
5454
55+
// Focus management
56+
const nameInputRef = ref<HTMLInputElement | null>(null);
57+
const triggerElement = ref<Element | null>(null);
58+
5559
// Form state
5660
const name = ref("");
5761
const entryType = ref<"timed" | "tally" | "moment" | "tada">("timed");
@@ -82,6 +86,9 @@ watch(
8286
() => props.isOpen,
8387
(isOpen) => {
8488
if (isOpen) {
89+
// Save trigger element for focus restoration on close
90+
triggerElement.value = document.activeElement;
91+
8592
if (props.editRhythm) {
8693
// Edit mode
8794
name.value = props.editRhythm.name;
@@ -118,6 +125,15 @@ watch(
118125
specificActivityName.value = "";
119126
}
120127
error.value = null;
128+
129+
// Move focus into modal after DOM update
130+
nextTick(() => {
131+
nameInputRef.value?.focus();
132+
});
133+
} else {
134+
// Restore focus to the element that opened the modal
135+
(triggerElement.value as HTMLElement | null)?.focus();
136+
triggerElement.value = null;
121137
}
122138
},
123139
);
@@ -345,11 +361,14 @@ async function save() {
345361

346362
<!-- Modal -->
347363
<div
364+
role="dialog"
365+
aria-modal="true"
366+
aria-labelledby="rhythm-create-title"
348367
class="relative my-auto w-full max-w-md max-h-[90vh] overflow-y-auto rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800"
349368
>
350369
<!-- Header -->
351370
<div class="mb-6 flex items-center justify-between">
352-
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
371+
<h2 id="rhythm-create-title" class="text-xl font-semibold text-gray-900 dark:text-white">
353372
{{ modalTitle }}
354373
</h2>
355374
<button
@@ -385,6 +404,7 @@ async function save() {
385404
</label>
386405
<input
387406
id="rhythm-name"
407+
ref="nameInputRef"
388408
v-model="name"
389409
type="text"
390410
placeholder="Daily Meditation"
@@ -400,11 +420,13 @@ async function save() {
400420
>
401421
Entry Type
402422
</label>
403-
<div class="grid grid-cols-2 gap-2">
423+
<div role="radiogroup" aria-label="Entry type" class="grid grid-cols-2 gap-2">
404424
<button
405425
v-for="option in entryTypeOptions"
406426
:key="option.value"
407427
type="button"
428+
role="radio"
429+
:aria-checked="entryType === option.value"
408430
:class="[
409431
'flex items-center justify-center gap-2 rounded-lg border-2 px-3 py-2.5 text-sm font-medium transition-colors',
410432
entryType === option.value
@@ -426,11 +448,13 @@ async function save() {
426448
>
427449
Type of moment
428450
</label>
429-
<div class="flex gap-2">
451+
<div role="radiogroup" aria-label="Type of moment" class="flex gap-2">
430452
<button
431453
v-for="option in momentSubcategories"
432454
:key="String(option.value)"
433455
type="button"
456+
role="radio"
457+
:aria-checked="subcategory === option.value"
434458
:class="[
435459
'flex-1 flex items-center justify-center gap-2 rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors',
436460
subcategory === option.value
@@ -483,11 +507,13 @@ async function save() {
483507
>
484508
Category
485509
</label>
486-
<div class="mt-2 grid grid-cols-2 gap-2">
510+
<div role="radiogroup" aria-label="Category" class="mt-2 grid grid-cols-2 gap-2">
487511
<button
488512
v-for="option in categoryOptions"
489513
:key="option.value"
490514
type="button"
515+
role="radio"
516+
:aria-checked="category === option.value"
491517
:class="[
492518
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors',
493519
category === option.value

0 commit comments

Comments
 (0)