diff --git a/docs/AGENTIC_AI_ROADMAP.md b/docs/AGENTIC_AI_ROADMAP.md
index 93b4978..02d3698 100644
--- a/docs/AGENTIC_AI_ROADMAP.md
+++ b/docs/AGENTIC_AI_ROADMAP.md
@@ -18,7 +18,7 @@
|------|----------|-------|---------|---------|
| **TODO-001** | medium | 0 | `web/src/app/providers.tsx` | Kill switch maintenance banner — create ``, set React state when `checkKillSwitch()` returns `disabled=true`, disable timer creation buttons |
| ✅ **TODO-002** | medium | 0 | `web/src/app/settings/page.tsx` | ~~Wire feedback button into settings page using `feedbackClient`~~ — settings feedback form submits through `@bytelyst/feedback-client`; verification blocked locally by missing `GITEA_NPM_TOKEN` for private `@bytelyst/*` install (d38b974) |
-| **TODO-003** | medium | 0 | `web/src/components/CreateTimerModal.tsx`, `web/src/components/AlarmOverlay.tsx` | Apply `@bytelyst/accessibility` focus trap + screen reader announcements. Ensure `--cm-*` color tokens meet WCAG AA contrast |
+| ✅ **TODO-003** | medium | 0 | `web/src/components/CreateTimerModal.tsx`, `web/src/components/AlarmOverlay.tsx` | ~~Apply `@bytelyst/accessibility` focus trap + screen reader announcements~~ — shared helpers added in common-plat `42f60cb`, consumed by ChronoMind modals (pending commit) |
| ✅ **TODO-004** | medium | A.1 | `backend/src/modules/routines/routes.ts` | ~~Clone template~~ — templates now cloned via `crypto.randomUUID()`, original stays reusable (0e7c1ae) |
| ✅ **TODO-005** | high | A.4 | `backend/src/lib/ai-context.ts` | ~~Wire LLM~~ — dual-path: extraction-service → Ollama `/api/generate` (5s timeout, feature-gated) (229ce4f) |
| ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) |
@@ -130,7 +130,7 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`.
### 0.3 — Accessibility Package (Web)
- [x] Add `@bytelyst/accessibility` to web/package.json (commit: f3e14e2)
-- [ ] Integrate helpers into existing modals (commit: )
+- [x] Integrate helpers into existing modals (commit: pending; shared package: `learning_ai_common_plat` 42f60cb; status: `@bytelyst/accessibility` build passed, web typecheck/test/build passed, modal small text promoted from tertiary to secondary for AA contrast)
- TODO-003: Apply `@bytelyst/accessibility` focus trap + screen reader to CreateTimerModal, AlarmOverlay
- Ensure all `--cm-*` color tokens meet WCAG AA contrast
@@ -643,7 +643,7 @@ Systematic audit against `learning_ai_common_plat/packages/` (58 packages) revea
|-----|----------|----------|
| No `@bytelyst/kill-switch-client` | **Critical** | Phase 0.1 |
| No settings feedback entry point using `@bytelyst/feedback-client` | Medium | Phase 0.2 — complete (d38b974) |
-| No `@bytelyst/accessibility` helpers | Medium | Phase 0.3 |
+| No `@bytelyst/accessibility` focus trap and screen reader helpers | Medium | Phase 0.3 — complete pending commit |
| No feature flags for new features | **Critical** | Phase 0.4 |
| No telemetry events for new features | Medium | Phase 0.5 |
| MCP tools built in product backend (wrong arch) | **Critical** | Phase A (dual-layer: backend REST + mcp-server proxy) |
diff --git a/web/src/components/AlarmOverlay.tsx b/web/src/components/AlarmOverlay.tsx
index 9d81880..e13170f 100644
--- a/web/src/components/AlarmOverlay.tsx
+++ b/web/src/components/AlarmOverlay.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { useTimerStore } from '@/lib/store';
import { getUrgencyConfig } from '@/lib/urgency';
import { formatTime } from '@/lib/format';
-import { alertLabel } from '@bytelyst/accessibility';
+import { alertLabel, announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
import { Bell, BellOff } from 'lucide-react';
export function AlarmOverlay() {
@@ -16,28 +16,16 @@ export function AlarmOverlay() {
const overlayRef = useRef(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
- if (e.key !== 'Tab' || !overlayRef.current) return;
- const focusable = overlayRef.current.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (focusable.length === 0) return;
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
+ if (overlayRef.current) trapFocusKeydown(e, overlayRef.current);
}, []);
useEffect(() => {
if (firingTimers.length === 0) return;
document.addEventListener('keydown', trapFocus);
- // Auto-focus dismiss button
- const el = overlayRef.current?.querySelector('button');
- el?.focus();
+ if (overlayRef.current) {
+ focusFirstElement(overlayRef.current, 'button');
+ }
+ announceToScreenReader(`Timer fired: ${firingTimers[0]?.label ?? 'timer'}`, 'assertive');
return () => document.removeEventListener('keydown', trapFocus);
}, [firingTimers.length, trapFocus]);
@@ -87,7 +75,7 @@ export function AlarmOverlay() {
: 'Timer fired!'}
-
+
{formatTime(timer.firedAt ?? Date.now())}
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
@@ -146,7 +134,7 @@ export function AlarmOverlay() {
{/* Multiple firing indicator */}
{firingTimers.length > 1 && (
-
+
+{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing
)}
diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx
index 18e0299..68bb215 100644
--- a/web/src/components/CreateTimerModal.tsx
+++ b/web/src/components/CreateTimerModal.tsx
@@ -12,6 +12,7 @@ import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
import { parseNaturalLanguage } from '@/lib/nl-parser';
import type { ParseResult } from '@/lib/nl-parser';
import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas';
+import { announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
@@ -27,27 +28,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
- if (e.key !== 'Tab' || !dialogRef.current) return;
- const focusable = dialogRef.current.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- );
- if (focusable.length === 0) return;
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
+ if (dialogRef.current) trapFocusKeydown(e, dialogRef.current);
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', trapFocus);
- const el = dialogRef.current?.querySelector('input, button');
- el?.focus();
+ if (dialogRef.current) {
+ focusFirstElement(dialogRef.current, 'input, button');
+ }
+ announceToScreenReader('Create timer dialog opened', 'polite');
return () => document.removeEventListener('keydown', trapFocus);
}, [isOpen, trapFocus]);
@@ -260,7 +250,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {