diff --git a/index.html b/index.html index a0bb625..3c0ade9 100644 --- a/index.html +++ b/index.html @@ -69,14 +69,27 @@ to { transform: rotate(360deg); } } - + diff --git a/public/fonts/OpenDyslexic-Bold.otf b/public/fonts/OpenDyslexic-Bold.otf new file mode 100644 index 0000000..fc14a5a Binary files /dev/null and b/public/fonts/OpenDyslexic-Bold.otf differ diff --git a/public/fonts/OpenDyslexic-Regular.otf b/public/fonts/OpenDyslexic-Regular.otf new file mode 100644 index 0000000..039ceda Binary files /dev/null and b/public/fonts/OpenDyslexic-Regular.otf differ diff --git a/src/App.tsx b/src/App.tsx index d75113e..b5d58c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/hooks/useAuth"; import { PendoProvider } from "@/hooks/usePendo"; import { AppNavigationProvider } from "@/hooks/useAppNavigation"; +import { AccessibilityProvider } from "@/hooks/useAccessibility"; import { useWindowControlsOverlay } from "@/hooks/useWindowControlsOverlay"; import { ThemeProvider } from "next-themes"; import { PWAInstallBanner } from "@/components/PWAInstallBanner"; @@ -64,17 +65,19 @@ const AppContent = () => { const App = () => ( - - - - - - - - - - - + + + + + + + + + + + + + ); diff --git a/src/components/AccessibilitySettings.tsx b/src/components/AccessibilitySettings.tsx new file mode 100644 index 0000000..17b510f --- /dev/null +++ b/src/components/AccessibilitySettings.tsx @@ -0,0 +1,91 @@ +import { Type, Bold, ZoomIn } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useAccessibility, FontFamily } from "@/hooks/useAccessibility"; + +export function AccessibilitySettings() { + const { + fontFamily, + setFontFamily, + boldText, + setBoldText, + largeFont, + setLargeFont, + } = useAccessibility(); + + return ( +
+ {/* Font Selection */} +
+
+ + Font +
+ value && setFontFamily(value as FontFamily)} + className="justify-start flex-wrap" + > + + Default + + + OpenDyslexic + + + Arimo + + +

+ OpenDyslexic is designed to help readers with dyslexia +

+
+ + {/* Bold All Text */} +
+
+
+ +
+
+ +

+ Make all text bold for better readability +

+
+
+ +
+ + {/* Extra Large Text */} +
+
+
+ +
+
+ +

+ Increase text size throughout the app +

+
+
+ +
+
+ ); +} diff --git a/src/components/ProfileModal.test.tsx b/src/components/ProfileModal.test.tsx index 4ad07d7..7e8a53d 100644 --- a/src/components/ProfileModal.test.tsx +++ b/src/components/ProfileModal.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor, within, fireEvent } from '@testing-library/rea import userEvent from '@testing-library/user-event'; import { ProfileModal } from './ProfileModal'; import { BrowserRouter } from 'react-router-dom'; +import { AccessibilityProvider } from '@/hooks/useAccessibility'; import React from 'react'; // Mock react-router-dom's useNavigate @@ -63,7 +64,9 @@ const defaultProps = { const renderProfileModal = (props = {}) => { return render( - + + + ); }; @@ -80,6 +83,20 @@ const clickMenuItem = async (user: ReturnType, label: st } }; +// Helper to navigate to Delete Account confirmation (now inline in Account view) +const navigateToDeleteConfirm = async (user: ReturnType) => { + // Navigate to Account view first + const accountMenuItem = screen.getByText('Name, email, password').closest('button'); + await user.click(accountMenuItem!); + // Click Delete Account button to show the inline confirmation + const deleteButton = screen.getByRole('button', { name: /^delete account$/i }); + await user.click(deleteButton); + // Wait for the confirmation to appear + await waitFor(() => { + expect(screen.getByText(/delete your account permanently/i)).toBeInTheDocument(); + }); +}; + describe('ProfileModal', () => { beforeEach(() => { vi.clearAllMocks(); @@ -93,8 +110,7 @@ describe('ProfileModal', () => { expect(screen.getByText('Settings')).toBeInTheDocument(); expect(screen.getByText('Account')).toBeInTheDocument(); expect(screen.getByText('Appearance')).toBeInTheDocument(); - // The delete account menu item - expect(screen.getByText('Delete Account')).toBeInTheDocument(); + expect(screen.getByText('Accessibility')).toBeInTheDocument(); }); it('displays user info', () => { @@ -139,14 +155,30 @@ describe('ProfileModal', () => { expect(screen.getByText('Theme')).toBeInTheDocument(); }); - it('navigates to Delete Account view when Delete Account is clicked', async () => { + it('navigates to Accessibility view when Accessibility is clicked', async () => { const user = userEvent.setup(); renderProfileModal(); - // Find the Delete Account menu item in main view (not the destructive button in danger view) - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); + // Find and click the Accessibility menu item + const accessibilityMenuItem = screen.getByText('Fonts, text size, contrast').closest('button'); + await user.click(accessibilityMenuItem!); + + expect(screen.getByText('Customize the app for your needs')).toBeInTheDocument(); + }); + + it('opens Delete Account dialog from Account view', async () => { + const user = userEvent.setup(); + renderProfileModal(); + // Navigate to Account view first + const accountMenuItem = screen.getByText('Name, email, password').closest('button'); + await user.click(accountMenuItem!); + + // Click Delete Account button to open the AlertDialog + const deleteButton = screen.getByRole('button', { name: /delete account/i }); + await user.click(deleteButton); + + // AlertDialog should open expect(screen.getByText(/delete your account permanently/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText('DELETE')).toBeInTheDocument(); }); @@ -263,13 +295,11 @@ describe('ProfileModal', () => { }); describe('Account Deletion', () => { - it('shows delete confirmation when navigating to Delete Account', async () => { + it('shows delete confirmation dialog from Account view', async () => { const user = userEvent.setup(); renderProfileModal(); - // Find and click the Delete Account menu item - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); + await navigateToDeleteConfirm(user); expect(screen.getByText(/delete your account permanently/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText('DELETE')).toBeInTheDocument(); @@ -279,12 +309,11 @@ describe('ProfileModal', () => { const user = userEvent.setup(); renderProfileModal(); - // Navigate to Delete Account view - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); + await navigateToDeleteConfirm(user); // The delete button should be disabled initially - expect(screen.getByRole('button', { name: /delete account forever/i })).toBeDisabled(); + const deleteBtn = await screen.findByText(/Delete Forever/i); + expect(deleteBtn.closest('button')).toBeDisabled(); // The input should have DELETE placeholder expect(screen.getByPlaceholderText('DELETE')).toBeInTheDocument(); @@ -296,14 +325,13 @@ describe('ProfileModal', () => { renderProfileModal(); - // Navigate to Delete Account view - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); + await navigateToDeleteConfirm(user); // Type DELETE and confirm - const confirmInput = screen.getByRole('textbox'); + const confirmInput = screen.getByPlaceholderText('DELETE'); fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); - await user.click(screen.getByRole('button', { name: /delete account forever/i })); + const deleteForeverBtn = await screen.findByText(/Delete Forever/i); + await user.click(deleteForeverBtn.closest('button')!); await waitFor(() => { expect(mockRpc).toHaveBeenCalledWith('delete_own_account'); @@ -316,11 +344,11 @@ describe('ProfileModal', () => { renderProfileModal(); - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); - const confirmInput = screen.getByRole('textbox'); + await navigateToDeleteConfirm(user); + const confirmInput = screen.getByPlaceholderText('DELETE'); fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); - await user.click(screen.getByRole('button', { name: /delete account forever/i })); + const deleteForeverBtn = await screen.findByText(/Delete Forever/i); + await user.click(deleteForeverBtn.closest('button')!); await waitFor(() => { expect(mockSignOut).toHaveBeenCalled(); @@ -333,11 +361,11 @@ describe('ProfileModal', () => { renderProfileModal(); - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); - const confirmInput = screen.getByRole('textbox'); + await navigateToDeleteConfirm(user); + const confirmInput = screen.getByPlaceholderText('DELETE'); fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); - await user.click(screen.getByRole('button', { name: /delete account forever/i })); + const deleteForeverBtn = await screen.findByText(/Delete Forever/i); + await user.click(deleteForeverBtn.closest('button')!); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/'); @@ -354,11 +382,11 @@ describe('ProfileModal', () => { renderProfileModal(); - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); - const confirmInput = screen.getByRole('textbox'); + await navigateToDeleteConfirm(user); + const confirmInput = screen.getByPlaceholderText('DELETE'); fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); - await user.click(screen.getByRole('button', { name: /delete account forever/i })); + const deleteForeverBtn = await screen.findByText(/Delete Forever/i); + await user.click(deleteForeverBtn.closest('button')!); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('Database error'); @@ -375,11 +403,11 @@ describe('ProfileModal', () => { renderProfileModal(); - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); - const confirmInput = screen.getByRole('textbox'); + await navigateToDeleteConfirm(user); + const confirmInput = screen.getByPlaceholderText('DELETE'); fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); - await user.click(screen.getByRole('button', { name: /delete account forever/i })); + const deleteForeverBtn = await screen.findByText(/Delete Forever/i); + await user.click(deleteForeverBtn.closest('button')!); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('Not authenticated'); @@ -389,22 +417,18 @@ describe('ProfileModal', () => { expect(mockSignOut).not.toHaveBeenCalled(); }); - it('can go back from delete view', async () => { + it('can cancel delete dialog', async () => { const user = userEvent.setup(); renderProfileModal(); - // Navigate to Delete Account view - const deleteMenuItem = screen.getByText('Delete Account').closest('button'); - await user.click(deleteMenuItem!); + await navigateToDeleteConfirm(user); expect(screen.getByText(/delete your account permanently/i)).toBeInTheDocument(); - // Find and click back button - const backButtons = screen.getAllByRole('button'); - const backButton = backButtons.find(btn => btn.querySelector('.rotate-180')); - await user.click(backButton!); + // Click Cancel button in the AlertDialog + await user.click(screen.getByRole('button', { name: /cancel/i })); - // Should be back on main view - expect(screen.getByText('Settings')).toBeInTheDocument(); + // Dialog should close, we should still be in Account view + expect(screen.getByText('Display Name')).toBeInTheDocument(); }); }); }); diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 331cbae..95f7c38 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Dialog, @@ -25,7 +25,9 @@ import { Check, Loader2, LogOut, + Accessibility, } from "lucide-react"; +import { AccessibilitySettings } from "@/components/AccessibilitySettings"; import { validateForumUsername } from "@/lib/validation"; import { cn } from "@/lib/utils"; @@ -42,7 +44,7 @@ interface ProfileModalProps { onSignOut?: () => void; } -type SettingsView = "main" | "account" | "appearance" | "danger"; +type SettingsView = "main" | "account" | "appearance" | "accessibility"; export function ProfileModal({ open, @@ -62,6 +64,7 @@ export function ProfileModal({ ); const [newEmail, setNewEmail] = useState(""); const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // Loading states const [isUpdatingName, setIsUpdatingName] = useState(false); @@ -74,6 +77,14 @@ export function ProfileModal({ const [isEditingName, setIsEditingName] = useState(false); const [isEditingForumUsername, setIsEditingForumUsername] = useState(false); + // Reset delete confirmation when navigating away from account view + useEffect(() => { + if (currentView !== "account") { + setShowDeleteConfirm(false); + setDeleteConfirmText(""); + } + }, [currentView]); + const handleUpdateDisplayName = async () => { if (!displayName.trim()) { toast.error("Display name cannot be empty"); @@ -428,10 +439,10 @@ export function ProfileModal({ onClick={() => setCurrentView("appearance")} /> setCurrentView("danger")} - variant="danger" + icon={Accessibility} + label="Accessibility" + description="Fonts, text size, contrast" + onClick={() => setCurrentView("accessibility")} /> @@ -547,6 +558,96 @@ export function ProfileModal({ We'll send a password reset link to your email

+ + {/* Danger Zone */} +
+
+
+ + Danger Zone +
+ {!showDeleteConfirm ? ( + <> + +

+ This action cannot be undone +

+ + ) : ( +
+
+
+ +
+
+

+ Delete your account permanently +

+

+ All your data including practice history, bookmarks, test results, + and forum account will be permanently deleted. This cannot be undone. +

+
+
+ +
+
+ + + setDeleteConfirmText(e.target.value.toUpperCase()) + } + placeholder="DELETE" + className="font-mono h-11 uppercase" + /> +
+ +
+ + +
+
+
+ )} +
+
); @@ -566,62 +667,13 @@ export function ProfileModal({ ); - // Danger zone view - extracted as inline JSX to avoid focus loss from function recreation - const dangerViewContent = ( + // Accessibility settings view + const AccessibilityView = () => (
-
-
-
- -
-
-

- Delete your account permanently -

-

- All your data including practice history, bookmarks, test results, - and forum account will be permanently deleted. This cannot be - undone. -

-
-
- -
-
- - - setDeleteConfirmText(e.target.value.toUpperCase()) - } - placeholder="DELETE" - className="font-mono h-11 uppercase" - /> -
- - -
-
+

+ Customize the app for your needs +

+
); @@ -629,7 +681,7 @@ export function ProfileModal({ main: "Settings", account: "Account", appearance: "Appearance", - danger: "Delete Account", + accessibility: "Accessibility", }; return ( @@ -664,7 +716,7 @@ export function ProfileModal({ {currentView === "main" && } {currentView === "account" && } {currentView === "appearance" && } - {currentView === "danger" && dangerViewContent} + {currentView === "accessibility" && } diff --git a/src/hooks/useAccessibility.test.tsx b/src/hooks/useAccessibility.test.tsx new file mode 100644 index 0000000..21ee74e --- /dev/null +++ b/src/hooks/useAccessibility.test.tsx @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { AccessibilityProvider, useAccessibility, FontFamily } from './useAccessibility'; +import React from 'react'; + +// Test component that exposes hook values +function TestComponent() { + const { fontFamily, setFontFamily, boldText, setBoldText, largeFont, setLargeFont } = useAccessibility(); + return ( +
+ {fontFamily} + {String(boldText)} + {String(largeFont)} + + + + + +
+ ); +} + +describe('useAccessibility', () => { + beforeEach(() => { + // Clear localStorage and document classes before each test + localStorage.clear(); + document.documentElement.classList.remove('font-dyslexic', 'font-arimo', 'font-bold', 'font-xl'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('default values', () => { + it('returns default values when localStorage is empty', () => { + render( + + + + ); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('default'); + expect(screen.getByTestId('boldText')).toHaveTextContent('false'); + expect(screen.getByTestId('largeFont')).toHaveTextContent('false'); + }); + }); + + describe('loading from localStorage', () => { + it('loads fontFamily from localStorage', () => { + localStorage.setItem('accessibility-font-family', 'dyslexic'); + + render( + + + + ); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('dyslexic'); + }); + + it('loads boldText from localStorage', () => { + localStorage.setItem('accessibility-bold-text', 'true'); + + render( + + + + ); + + expect(screen.getByTestId('boldText')).toHaveTextContent('true'); + }); + + it('loads largeFont from localStorage', () => { + localStorage.setItem('accessibility-large-font', 'true'); + + render( + + + + ); + + expect(screen.getByTestId('largeFont')).toHaveTextContent('true'); + }); + }); + + describe('invalid stored values', () => { + it('falls back to default for invalid fontFamily', () => { + localStorage.setItem('accessibility-font-family', 'invalid-font'); + + render( + + + + ); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('default'); + }); + + it('falls back to false for invalid boolean values', () => { + localStorage.setItem('accessibility-bold-text', 'not-a-boolean'); + + render( + + + + ); + + expect(screen.getByTestId('boldText')).toHaveTextContent('false'); + }); + }); + + describe('setting values', () => { + it('updates fontFamily and persists to localStorage', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('setDyslexic').click(); + }); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('dyslexic'); + expect(localStorage.getItem('accessibility-font-family')).toBe('dyslexic'); + }); + + it('removes fontFamily from localStorage when set to default', async () => { + localStorage.setItem('accessibility-font-family', 'dyslexic'); + + render( + + + + ); + + await act(async () => { + screen.getByTestId('setDefault').click(); + }); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('default'); + expect(localStorage.getItem('accessibility-font-family')).toBeNull(); + }); + + it('updates boldText and persists to localStorage', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('toggleBold').click(); + }); + + expect(screen.getByTestId('boldText')).toHaveTextContent('true'); + expect(localStorage.getItem('accessibility-bold-text')).toBe('true'); + }); + + it('removes boldText from localStorage when disabled', async () => { + localStorage.setItem('accessibility-bold-text', 'true'); + + render( + + + + ); + + await act(async () => { + screen.getByTestId('toggleBold').click(); + }); + + expect(screen.getByTestId('boldText')).toHaveTextContent('false'); + expect(localStorage.getItem('accessibility-bold-text')).toBeNull(); + }); + + it('updates largeFont and persists to localStorage', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('toggleLarge').click(); + }); + + expect(screen.getByTestId('largeFont')).toHaveTextContent('true'); + expect(localStorage.getItem('accessibility-large-font')).toBe('true'); + }); + }); + + describe('CSS class application', () => { + it('adds font-dyslexic class when dyslexic font is selected', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('setDyslexic').click(); + }); + + expect(document.documentElement.classList.contains('font-dyslexic')).toBe(true); + }); + + it('adds font-arimo class when arimo font is selected', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('setArimo').click(); + }); + + expect(document.documentElement.classList.contains('font-arimo')).toBe(true); + }); + + it('removes font classes when default is selected', async () => { + localStorage.setItem('accessibility-font-family', 'dyslexic'); + + render( + + + + ); + + // Initial state should have dyslexic class + expect(document.documentElement.classList.contains('font-dyslexic')).toBe(true); + + await act(async () => { + screen.getByTestId('setDefault').click(); + }); + + expect(document.documentElement.classList.contains('font-dyslexic')).toBe(false); + expect(document.documentElement.classList.contains('font-arimo')).toBe(false); + }); + + it('adds font-bold class when bold is enabled', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('toggleBold').click(); + }); + + expect(document.documentElement.classList.contains('font-bold')).toBe(true); + }); + + it('adds font-xl class when large font is enabled', async () => { + render( + + + + ); + + await act(async () => { + screen.getByTestId('toggleLarge').click(); + }); + + expect(document.documentElement.classList.contains('font-xl')).toBe(true); + }); + }); + + describe('localStorage unavailable', () => { + it('handles localStorage errors gracefully', async () => { + // Mock localStorage to throw + const originalGetItem = localStorage.getItem; + const originalSetItem = localStorage.setItem; + + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('localStorage unavailable'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('localStorage unavailable'); + }); + + // Should not throw, should use defaults + render( + + + + ); + + expect(screen.getByTestId('fontFamily')).toHaveTextContent('default'); + expect(screen.getByTestId('boldText')).toHaveTextContent('false'); + + // Setting values should not throw either + await act(async () => { + screen.getByTestId('setDyslexic').click(); + }); + + // State should still update even if localStorage fails + expect(screen.getByTestId('fontFamily')).toHaveTextContent('dyslexic'); + }); + }); + + describe('hook usage outside provider', () => { + it('throws error when used outside AccessibilityProvider', () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useAccessibility must be used within an AccessibilityProvider'); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/hooks/useAccessibility.tsx b/src/hooks/useAccessibility.tsx new file mode 100644 index 0000000..e856cac --- /dev/null +++ b/src/hooks/useAccessibility.tsx @@ -0,0 +1,148 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +// Storage keys for localStorage persistence +const STORAGE_KEY_FONT = 'accessibility-font-family'; +const STORAGE_KEY_BOLD = 'accessibility-bold-text'; +const STORAGE_KEY_LARGE = 'accessibility-large-font'; + +export type FontFamily = 'default' | 'dyslexic' | 'arimo'; + +const VALID_FONT_FAMILIES: FontFamily[] = ['default', 'dyslexic', 'arimo']; + +function isValidFontFamily(value: string): value is FontFamily { + return VALID_FONT_FAMILIES.includes(value as FontFamily); +} + +interface AccessibilityContextType { + fontFamily: FontFamily; + setFontFamily: (font: FontFamily) => void; + boldText: boolean; + setBoldText: (enabled: boolean) => void; + largeFont: boolean; + setLargeFont: (enabled: boolean) => void; +} + +const AccessibilityContext = createContext(undefined); + +// Helper to safely get localStorage value +function getStoredValue(key: string, defaultValue: T): T { + if (typeof window === 'undefined') return defaultValue; + try { + const stored = localStorage.getItem(key); + if (stored === null) return defaultValue; + if (typeof defaultValue === 'boolean') { + return (stored === 'true') as T; + } + return stored as T; + } catch { + return defaultValue; + } +} + +export function AccessibilityProvider({ children }: { children: ReactNode }) { + // Initialize state from localStorage with validation + const [fontFamily, setFontFamilyState] = useState(() => { + const stored = getStoredValue(STORAGE_KEY_FONT, 'default'); + return isValidFontFamily(stored) ? stored : 'default'; + }); + const [boldText, setBoldTextState] = useState(() => + getStoredValue(STORAGE_KEY_BOLD, false) + ); + const [largeFont, setLargeFontState] = useState(() => + getStoredValue(STORAGE_KEY_LARGE, false) + ); + + // Apply font family class to document + useEffect(() => { + const root = document.documentElement; + // Remove all font classes first + root.classList.remove('font-dyslexic', 'font-arimo'); + // Add the appropriate class + if (fontFamily === 'dyslexic') { + root.classList.add('font-dyslexic'); + } else if (fontFamily === 'arimo') { + root.classList.add('font-arimo'); + } + }, [fontFamily]); + + // Apply bold text class + useEffect(() => { + const root = document.documentElement; + if (boldText) { + root.classList.add('font-bold'); + } else { + root.classList.remove('font-bold'); + } + }, [boldText]); + + // Apply large font class + useEffect(() => { + const root = document.documentElement; + if (largeFont) { + root.classList.add('font-xl'); + } else { + root.classList.remove('font-xl'); + } + }, [largeFont]); + + // Setters that persist to localStorage + const setFontFamily = (font: FontFamily) => { + setFontFamilyState(font); + try { + if (font === 'default') { + localStorage.removeItem(STORAGE_KEY_FONT); + } else { + localStorage.setItem(STORAGE_KEY_FONT, font); + } + } catch { + // localStorage may be unavailable + } + }; + + const setBoldText = (enabled: boolean) => { + setBoldTextState(enabled); + try { + if (enabled) { + localStorage.setItem(STORAGE_KEY_BOLD, 'true'); + } else { + localStorage.removeItem(STORAGE_KEY_BOLD); + } + } catch { + // localStorage may be unavailable + } + }; + + const setLargeFont = (enabled: boolean) => { + setLargeFontState(enabled); + try { + if (enabled) { + localStorage.setItem(STORAGE_KEY_LARGE, 'true'); + } else { + localStorage.removeItem(STORAGE_KEY_LARGE); + } + } catch { + // localStorage may be unavailable + } + }; + + return ( + + {children} + + ); +} + +export function useAccessibility() { + const context = useContext(AccessibilityContext); + if (context === undefined) { + throw new Error('useAccessibility must be used within an AccessibilityProvider'); + } + return context; +} diff --git a/src/index.css b/src/index.css index 46a87b3..4f3feb0 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,23 @@ -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap'); @import "katex/dist/katex.min.css"; +/* OpenDyslexic font - self-hosted for accessibility */ +@font-face { + font-family: 'OpenDyslexic'; + src: url('/fonts/OpenDyslexic-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'OpenDyslexic'; + src: url('/fonts/OpenDyslexic-Bold.otf') format('opentype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + @tailwind base; @tailwind components; @tailwind utilities; @@ -114,6 +131,83 @@ --sidebar-border: 222 25% 20%; --sidebar-ring: 45 90% 55%; } + +} + +/* Accessibility: Font modes + Note: Excludes code, pre, .mono, and .katex elements to preserve + monospace fonts for code blocks and math notation readability */ +html.font-dyslexic body, +html.font-dyslexic p, +html.font-dyslexic span:not(.katex):not(.katex-html):not(.katex *), +html.font-dyslexic div:not(.katex):not(.katex-html):not(.katex *), +html.font-dyslexic h1, +html.font-dyslexic h2, +html.font-dyslexic h3, +html.font-dyslexic h4, +html.font-dyslexic h5, +html.font-dyslexic h6, +html.font-dyslexic li, +html.font-dyslexic td, +html.font-dyslexic th, +html.font-dyslexic label, +html.font-dyslexic input, +html.font-dyslexic textarea, +html.font-dyslexic button, +html.font-dyslexic select, +html.font-dyslexic a { + font-family: 'OpenDyslexic', sans-serif !important; +} + +html.font-arimo body, +html.font-arimo p, +html.font-arimo span:not(.katex):not(.katex-html):not(.katex *), +html.font-arimo div:not(.katex):not(.katex-html):not(.katex *), +html.font-arimo h1, +html.font-arimo h2, +html.font-arimo h3, +html.font-arimo h4, +html.font-arimo h5, +html.font-arimo h6, +html.font-arimo li, +html.font-arimo td, +html.font-arimo th, +html.font-arimo label, +html.font-arimo input, +html.font-arimo textarea, +html.font-arimo button, +html.font-arimo select, +html.font-arimo a { + font-family: 'Arimo', sans-serif !important; +} + +/* Accessibility: Bold all text mode + Note: Excludes code, pre, .mono, and .katex elements */ +html.font-bold body, +html.font-bold p, +html.font-bold span:not(.katex):not(.katex-html):not(.katex *), +html.font-bold div:not(.katex):not(.katex-html):not(.katex *), +html.font-bold li, +html.font-bold td, +html.font-bold th, +html.font-bold label, +html.font-bold button, +html.font-bold input, +html.font-bold textarea, +html.font-bold select, +html.font-bold a, +html.font-bold h1, +html.font-bold h2, +html.font-bold h3, +html.font-bold h4, +html.font-bold h5, +html.font-bold h6 { + font-weight: 700 !important; +} + +/* Accessibility: Extra large font mode - 125% scaling */ +html.font-xl { + font-size: 125%; } @layer base {