feat(web): use shared accessibility helpers
This commit is contained in:
parent
8d8cf04835
commit
bfc4670fc3
@ -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-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) |
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
|
||||
const trapFocus = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !overlayRef.current) return;
|
||||
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();
|
||||
}
|
||||
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<HTMLElement>('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!'}
|
||||
</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())}
|
||||
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
|
||||
</p>
|
||||
@ -146,7 +134,7 @@ export function AlarmOverlay() {
|
||||
|
||||
{/* Multiple firing indicator */}
|
||||
{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
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -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<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();
|
||||
}
|
||||
if (dialogRef.current) trapFocusKeydown(e, dialogRef.current);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
document.addEventListener('keydown', trapFocus);
|
||||
const el = dialogRef.current?.querySelector<HTMLElement>('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) {
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<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="flex items-center gap-2 mb-1">
|
||||
<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
|
||||
</label>
|
||||
</div>
|
||||
@ -300,7 +290,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
)}
|
||||
</div>
|
||||
{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 ? (
|
||||
<span>
|
||||
{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}]` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span>
|
||||
<span style={{ color: 'var(--cm-text-secondary)' }}>{nlResult.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -323,7 +313,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
onClick={() => setTab(t.key)}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@ -400,7 +390,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
@ -441,7 +431,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
{ label: 'Rounds', value: rounds, setter: setRounds },
|
||||
].map((field) => (
|
||||
<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}
|
||||
</label>
|
||||
<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>}
|
||||
{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 · Milestone warnings at 30, 7, 3, 1 days
|
||||
</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"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@ -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"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@ -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"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@ -556,7 +546,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
{tab !== 'pomodoro' && (
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
@ -571,7 +561,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user