-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Accessibility Settings (Reduced Motion & High Contrast) #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
0d3006f
65102c5
ab1ec70
07f8953
97b9486
a75e516
f97e608
d4d3d11
875e74a
cc5820f
7675841
314fc5c
c05f4da
b52dd6d
a793b47
7e7c126
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -177,22 +177,6 @@ body { | |
| } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| /* -- Tutorial highlight ring: green glow pulse (1.5 s infinite) ---------- */ | ||
|
|
||
| @keyframes tutorial-pulse { | ||
| 0%, | ||
| 100% { | ||
| box-shadow: 0 0 4px rgba(64, 192, 87, 0.4); | ||
| opacity: 1; | ||
| } | ||
| 50% { | ||
| box-shadow: | ||
| 0 0 14px rgba(64, 192, 87, 0.8), | ||
| 0 0 28px rgba(64, 192, 87, 0.4); | ||
| opacity: 0.85; | ||
| } | ||
| } | ||
|
|
||
| /* ── ASCII art color classes ─────────────────────────────── */ | ||
|
|
||
| .ascii-eyes { | ||
|
|
@@ -336,3 +320,49 @@ body { | |
| animation: none; | ||
| } | ||
| } | ||
|
|
||
| /* ── High Contrast mode ─────────────────────────────────────────────────── */ | ||
| /* Applied via data-high-contrast="true" on <html>, set by useHighContrast */ | ||
| /* hook. An inline script in index.html sets this attribute before React */ | ||
| /* hydrates to avoid flash of un-styled content. */ | ||
|
|
||
| [data-high-contrast="true"] body { | ||
| background-color: #000; | ||
| color: #fff; | ||
| } | ||
|
|
||
| [data-high-contrast="true"] { | ||
| --mantine-color-body: #000; | ||
| --mantine-color-text: #fff; | ||
| --neon-green: #39ff14; | ||
| } | ||
|
|
||
| /* Interactive elements: bright yellow */ | ||
| [data-high-contrast="true"] button, | ||
| [data-high-contrast="true"] [role="button"], | ||
| [data-high-contrast="true"] a { | ||
| color: #ffff00 !important; | ||
| } | ||
|
|
||
| /* ASCII art color spans: revert to terminal green in high contrast */ | ||
| [data-high-contrast="true"] .ascii-eyes, | ||
| [data-high-contrast="true"] .ascii-electric, | ||
| [data-high-contrast="true"] .ascii-warm, | ||
| [data-high-contrast="true"] .ascii-glow, | ||
| [data-high-contrast="true"] .ascii-dim { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: This rule disables particles in high-contrast mode, but high contrast ≠ reduced motion. A user might want high-contrast colors while still seeing animations. Consider gating particle visibility only on reduced-motion preference rather than high-contrast mode. |
||
| color: #39ff14; | ||
| text-shadow: none; | ||
| } | ||
|
|
||
| /* Mantine dark panel backgrounds → pure black */ | ||
| [data-high-contrast="true"] .mantine-AppShell-root, | ||
| [data-high-contrast="true"] .mantine-AppShell-header, | ||
| [data-high-contrast="true"] .mantine-AppShell-main { | ||
| background-color: #000; | ||
| } | ||
|
|
||
| /* Reduce motion: also disable animations in high-contrast mode */ | ||
| [data-high-contrast="true"] .float-particle { | ||
| animation: none; | ||
| opacity: 0; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| // @vitest-environment jsdom | ||
| import { act, renderHook } from "@testing-library/react"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { initialSettings, useSettingsStore } from "../store/settingsStore"; | ||
| import { useHighContrast } from "./useHighContrast"; | ||
|
|
||
| const HC_ATTR = "data-high-contrast"; | ||
|
|
||
| beforeEach(() => { | ||
| useSettingsStore.setState(initialSettings); | ||
| document.documentElement.removeAttribute(HC_ATTR); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| document.documentElement.removeAttribute(HC_ATTR); | ||
| }); | ||
|
|
||
| describe("useHighContrast", () => { | ||
| it("returns false when highContrast is off", () => { | ||
| const { result } = renderHook(() => useHighContrast()); | ||
| expect(result.current).toBe(false); | ||
| }); | ||
|
|
||
| it("sets data-high-contrast=false on <html> when off", () => { | ||
| renderHook(() => useHighContrast()); | ||
| expect(document.documentElement.getAttribute(HC_ATTR)).toBe("false"); | ||
| }); | ||
|
|
||
| it("returns true when highContrast is on", () => { | ||
| useSettingsStore.setState({ highContrast: true }); | ||
| const { result } = renderHook(() => useHighContrast()); | ||
| expect(result.current).toBe(true); | ||
| }); | ||
|
|
||
| it("sets data-high-contrast=true on <html> when enabled", () => { | ||
| useSettingsStore.setState({ highContrast: true }); | ||
| renderHook(() => useHighContrast()); | ||
| expect(document.documentElement.getAttribute(HC_ATTR)).toBe("true"); | ||
| }); | ||
|
|
||
| it("updates data-high-contrast when store is toggled on", () => { | ||
| const { result } = renderHook(() => useHighContrast()); | ||
| expect(result.current).toBe(false); | ||
| act(() => { | ||
| useSettingsStore.setState({ highContrast: true }); | ||
| }); | ||
| expect(result.current).toBe(true); | ||
| expect(document.documentElement.getAttribute(HC_ATTR)).toBe("true"); | ||
| }); | ||
|
|
||
| it("updates data-high-contrast back to false when disabled", () => { | ||
| useSettingsStore.setState({ highContrast: true }); | ||
| const { result } = renderHook(() => useHighContrast()); | ||
| expect(result.current).toBe(true); | ||
| act(() => { | ||
| useSettingsStore.setState({ highContrast: false }); | ||
| }); | ||
| expect(result.current).toBe(false); | ||
| expect(document.documentElement.getAttribute(HC_ATTR)).toBe("false"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { useEffect } from "react"; | ||
| import { useSettingsStore } from "../store/settingsStore"; | ||
|
|
||
| const HC_ATTR = "data-high-contrast"; | ||
|
|
||
| /** | ||
| * Reads the highContrast setting from the settings store and syncs it as a | ||
| * `data-high-contrast` attribute on <html>. CSS in global.css uses this | ||
| * attribute to apply high-contrast overrides. | ||
| * | ||
| * Call this hook once near the root of the component tree (GameLayout). | ||
| */ | ||
| export function useHighContrast(): boolean { | ||
| const highContrast = useSettingsStore((s) => s.highContrast); | ||
|
|
||
| useEffect(() => { | ||
| document.documentElement.setAttribute( | ||
| HC_ATTR, | ||
| highContrast ? "true" : "false", | ||
| ); | ||
| }, [highContrast]); | ||
|
|
||
| return highContrast; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking: This line removes
TutorialOverlayfromGameLayout, butTutorialOverlaywas merged to main via PR #131 (Issue #97). This is a rebase conflict resolution error — the tutorial import and<TutorialOverlay />JSX should be preserved from main. Please re-rebase and keep these lines.