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) {