feat(web): use shared accessibility helpers

This commit is contained in:
OpenAI Codex 2026-05-05 01:21:31 -07:00
parent 8d8cf04835
commit bfc4670fc3
3 changed files with 30 additions and 52 deletions

View File

@ -18,7 +18,7 @@
|------|----------|-------|---------|---------| |------|----------|-------|---------|---------|
| **TODO-001** | medium | 0 | `web/src/app/providers.tsx` | Kill switch maintenance banner — create `<MaintenanceBanner />`, set React state when `checkKillSwitch()` returns `disabled=true`, disable timer creation buttons | | **TODO-001** | medium | 0 | `web/src/app/providers.tsx` | Kill switch maintenance banner — create `<MaintenanceBanner />`, 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-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-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-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) | | ✅ **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) ### 0.3 — Accessibility Package (Web)
- [x] Add `@bytelyst/accessibility` to web/package.json (commit: f3e14e2) - [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 - TODO-003: Apply `@bytelyst/accessibility` focus trap + screen reader to CreateTimerModal, AlarmOverlay
- Ensure all `--cm-*` color tokens meet WCAG AA contrast - 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 `@bytelyst/kill-switch-client` | **Critical** | Phase 0.1 |
| No settings feedback entry point using `@bytelyst/feedback-client` | Medium | Phase 0.2 — complete (d38b974) | | 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 feature flags for new features | **Critical** | Phase 0.4 |
| No telemetry events for new features | Medium | Phase 0.5 | | 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) | | MCP tools built in product backend (wrong arch) | **Critical** | Phase A (dual-layer: backend REST + mcp-server proxy) |

View File

@ -4,7 +4,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { useTimerStore } from '@/lib/store'; import { useTimerStore } from '@/lib/store';
import { getUrgencyConfig } from '@/lib/urgency'; import { getUrgencyConfig } from '@/lib/urgency';
import { formatTime } from '@/lib/format'; import { formatTime } from '@/lib/format';
import { alertLabel } from '@bytelyst/accessibility'; import { alertLabel, announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
import { Bell, BellOff } from 'lucide-react'; import { Bell, BellOff } from 'lucide-react';
export function AlarmOverlay() { export function AlarmOverlay() {
@ -16,28 +16,16 @@ export function AlarmOverlay() {
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const trapFocus = useCallback((e: KeyboardEvent) => { const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key !== 'Tab' || !overlayRef.current) return; if (overlayRef.current) trapFocusKeydown(e, overlayRef.current);
const focusable = overlayRef.current.querySelectorAll<HTMLElement>(
'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();
}
}, []); }, []);
useEffect(() => { useEffect(() => {
if (firingTimers.length === 0) return; if (firingTimers.length === 0) return;
document.addEventListener('keydown', trapFocus); document.addEventListener('keydown', trapFocus);
// Auto-focus dismiss button if (overlayRef.current) {
const el = overlayRef.current?.querySelector<HTMLElement>('button'); focusFirstElement(overlayRef.current, 'button');
el?.focus(); }
announceToScreenReader(`Timer fired: ${firingTimers[0]?.label ?? 'timer'}`, 'assertive');
return () => document.removeEventListener('keydown', trapFocus); return () => document.removeEventListener('keydown', trapFocus);
}, [firingTimers.length, trapFocus]); }, [firingTimers.length, trapFocus]);
@ -87,7 +75,7 @@ export function AlarmOverlay() {
: 'Timer fired!'} : 'Timer fired!'}
</p> </p>
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="text-sm mb-8" style={{ color: 'var(--cm-text-secondary)' }}>
{formatTime(timer.firedAt ?? Date.now())} {formatTime(timer.firedAt ?? Date.now())}
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`} {timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
</p> </p>
@ -146,7 +134,7 @@ export function AlarmOverlay() {
{/* Multiple firing indicator */} {/* Multiple firing indicator */}
{firingTimers.length > 1 && ( {firingTimers.length > 1 && (
<p className="mt-4 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="mt-4 text-xs" style={{ color: 'var(--cm-text-secondary)' }}>
+{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing +{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing
</p> </p>
)} )}

View File

@ -12,6 +12,7 @@ import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
import { parseNaturalLanguage } from '@/lib/nl-parser'; import { parseNaturalLanguage } from '@/lib/nl-parser';
import type { ParseResult } from '@/lib/nl-parser'; import type { ParseResult } from '@/lib/nl-parser';
import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas'; import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas';
import { announceToScreenReader, focusFirstElement, trapFocusKeydown } from '@bytelyst/accessibility';
type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event'; type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
@ -27,27 +28,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const trapFocus = useCallback((e: KeyboardEvent) => { const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; } if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab' || !dialogRef.current) return; if (dialogRef.current) trapFocusKeydown(e, dialogRef.current);
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'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();
}
}, [onClose]); }, [onClose]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
document.addEventListener('keydown', trapFocus); document.addEventListener('keydown', trapFocus);
const el = dialogRef.current?.querySelector<HTMLElement>('input, button'); if (dialogRef.current) {
el?.focus(); focusFirstElement(dialogRef.current, 'input, button');
}
announceToScreenReader('Create timer dialog opened', 'polite');
return () => document.removeEventListener('keydown', trapFocus); return () => document.removeEventListener('keydown', trapFocus);
}, [isOpen, trapFocus]); }, [isOpen, trapFocus]);
@ -260,7 +250,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
<button <button
onClick={onClose} onClick={onClose}
className="p-1 rounded-lg transition-colors cursor-pointer" className="p-1 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }} style={{ color: 'var(--cm-text-secondary)' }}
aria-label="Close dialog" aria-label="Close dialog"
> >
<X size={20} /> <X size={20} />
@ -271,7 +261,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}> <div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} /> <Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}> <label className="text-xs font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
Quick create type naturally Quick create type naturally
</label> </label>
</div> </div>
@ -300,7 +290,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
)} )}
</div> </div>
{nlResult && nlInput.trim() && ( {nlResult && nlInput.trim() && (
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)' }}> <div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-secondary)' }}>
{nlResult.success && nlResult.timer ? ( {nlResult.success && nlResult.timer ? (
<span> <span>
{nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'} {nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'}
@ -309,7 +299,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
{nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''} {nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''}
</span> </span>
) : ( ) : (
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span> <span style={{ color: 'var(--cm-text-secondary)' }}>{nlResult.error}</span>
)} )}
</div> </div>
)} )}
@ -323,7 +313,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
onClick={() => setTab(t.key)} onClick={() => setTab(t.key)}
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer" className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
style={{ style={{
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)', color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-secondary)',
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent', borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
}} }}
> >
@ -400,7 +390,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
color: 'var(--cm-text-primary)', color: 'var(--cm-text-primary)',
}} }}
/> />
<span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}> <span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
{field.label} {field.label}
</span> </span>
</div> </div>
@ -441,7 +431,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
{ label: 'Rounds', value: rounds, setter: setRounds }, { label: 'Rounds', value: rounds, setter: setRounds },
].map((field) => ( ].map((field) => (
<div key={field.label}> <div key={field.label}>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-tertiary)' }}> <label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
{field.label} {field.label}
</label> </label>
<input <input
@ -481,7 +471,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
/> />
{errors.eventDate && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.eventDate}</p>} {errors.eventDate && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.eventDate}</p>}
{eventDate && new Date(eventDate).getTime() > Date.now() && ( {eventDate && new Date(eventDate).getTime() > Date.now() && (
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now &middot; Milestone warnings at 30, 7, 3, 1 days {Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now &middot; Milestone warnings at 30, 7, 3, 1 days
</p> </p>
)} )}
@ -500,7 +490,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer" className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{ style={{
backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)', backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
color: !category ? 'var(--cm-white)' : 'var(--cm-text-tertiary)', color: !category ? 'var(--cm-white)' : 'var(--cm-text-secondary)',
border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent', border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent',
}} }}
> >
@ -513,7 +503,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer" className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{ style={{
backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)', backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)',
color: category === cat.id ? cat.color : 'var(--cm-text-tertiary)', color: category === cat.id ? cat.color : 'var(--cm-text-secondary)',
border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent', border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent',
}} }}
> >
@ -540,7 +530,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer" className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{ style={{
backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)', backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)',
color: urgency === level ? config.color : 'var(--cm-text-tertiary)', color: urgency === level ? config.color : 'var(--cm-text-secondary)',
border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent', border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent',
}} }}
> >
@ -556,7 +546,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
{tab !== 'pomodoro' && ( {tab !== 'pomodoro' && (
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}> <label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Custom Warning Message <span className="text-xs font-normal" style={{ color: 'var(--cm-text-tertiary)' }}>(optional)</span> Custom Warning Message <span className="text-xs font-normal" style={{ color: 'var(--cm-text-secondary)' }}>(optional)</span>
</label> </label>
<input <input
type="text" type="text"
@ -571,7 +561,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
color: 'var(--cm-text-primary)', color: 'var(--cm-text-primary)',
}} }}
/> />
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--cm-text-secondary)' }}>
Shown in pre-warning notifications instead of auto-generated tips Shown in pre-warning notifications instead of auto-generated tips
</p> </p>
</div> </div>