Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { Header } from "../components/Header"
import { useAppState } from "../lib/store"

vi.mock("../lib/store", () => ({
useAppState: vi.fn(),
}))

vi.mock("../components/UserProfileModal", () => ({
UserProfileModal: () => null,
}))

describe("Header camera announcements", () => {
it("renders camera target changes in a polite live region", () => {
;(useAppState as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
simulationRunning: true,
toggleSimulation: vi.fn(),
riskLevel: "LOW",
triggerReset: vi.fn(),
selectedAsteroid: null,
cameraAnnouncement: "Camera focused on AST-0042.",
})

render(<Header />)

const status = screen.getByRole("status")
expect(status).toHaveAttribute("aria-live", "polite")
expect(status).toHaveTextContent("Camera focused on AST-0042.")
})
})
27 changes: 23 additions & 4 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useState, useEffect } from "react"
import { useState, useEffect, type ReactNode } from "react"
import Link from "next/link"
import { useAppState } from "@/lib/store"
import { UserProfileModal } from "./UserProfileModal"
Expand Down Expand Up @@ -38,7 +38,7 @@ function LiveClock() {
}

export function Header() {
const { simulationRunning, toggleSimulation, riskLevel, triggerReset, selectedAsteroid } = useAppState()
const { simulationRunning, toggleSimulation, riskLevel, triggerReset, selectedAsteroid, cameraAnnouncement } = useAppState()
const [profileOpen, setProfileOpen] = useState(false)

return (
Expand All @@ -63,6 +63,25 @@ export function Header() {
boxShadow: "0 1px 20px rgba(0, 0, 0, 0.5)",
}}
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{cameraAnnouncement}
</div>

{/* Left: Brand */}
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
Expand Down Expand Up @@ -204,7 +223,7 @@ export const generateNavAnalytics = (timeMs: number) => ({ event: 'nav_toggle',
// Aria-labels for cache data warnings
export const cacheAriaProps = { 'aria-label': 'Cached data displayed', 'aria-live': 'polite' };
// Memoized Navbar export
export const MemoMobileNav = (Nav: any) => Nav;
export const MemoMobileNav = (Nav: ReactNode) => Nav;
// Consolidated mobile navigation state
export const useConsolidatedNav = () => { return { isOpen: false }; };
// Modern optional chaining handler
Expand All @@ -214,7 +233,7 @@ export const handleNavClick = (onClick?: () => void) => { onClick?.(); };
*/
export const NAV_DOCS = true;
// Mobile Navbar dependency updates
export const MobileNavContainer = ({ children }: any) => { return children; };
export const MobileNavContainer = ({ children }: { children: ReactNode }) => { return children; };
// Auto-resolved #229: Improve performance of the Mobile Navbar
// Fixed #206: Standardized Mobile Navbar toggles to semantic button elements.
// Issue #206: Refactored Mobile Navbar
Expand Down
36 changes: 30 additions & 6 deletions src/lib/store.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from "react"
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react"
import type { AsteroidData } from "./types"

// ==========================================
Expand Down Expand Up @@ -116,6 +116,14 @@ interface AppState {
focusedObjectId: string | null
/** Sets the currently focused object ID, or null to clear focus */
setFocusedObjectId: (id: string | null) => void
/** Screen-reader announcement for camera focus target changes */
cameraAnnouncement: string
cinematicMode: boolean
cameraFov: number
autoRotate: boolean
bloomIntensity: number
toggleCinematicMode: () => void
toggleAutoRotate: () => void
}

// ==========================================
Expand Down Expand Up @@ -200,6 +208,7 @@ export function AppProvider({

// ========== NEW: Focused Object ID ==========
const [focusedObjectId, setFocusedObjectIdState] = useState<string | null>(initialObjectId)
const [cameraAnnouncement, setCameraAnnouncement] = useState("")

const addToast = useCallback((message: string, type: "success" | "error" | "info" = "info") => {
const id = nextToastId.current++
Expand All @@ -225,6 +234,9 @@ export function AppProvider({
// Action Handlers
const selectAsteroid = useCallback((asteroid: AsteroidData | null) => {
setSelectedAsteroid(asteroid)
setCameraAnnouncement(
asteroid ? `Camera focused on ${asteroid.name}.` : "Camera returned to Earth."
)
}, [])

const claimAsteroid = useCallback((id: number) => {
Expand All @@ -249,6 +261,7 @@ export function AppProvider({
const triggerReset = useCallback(() => {
setResetCamera(true)
setSelectedAsteroid(null)
setCameraAnnouncement("Camera returned to Earth.")
}, [])

const clearReset = useCallback(() => setResetCamera(false), [])
Expand Down Expand Up @@ -294,6 +307,7 @@ export function AppProvider({
const found = asteroidDataRef.current.find((item) => item.id === id)
if (found) {
setSelectedAsteroid(found)
setCameraAnnouncement(`Camera focused on ${found.name}.`)
}
}, [])

Expand All @@ -305,6 +319,7 @@ export function AppProvider({
: -1
const nextIndex = (currentIndex + 1) % catalog.length
setSelectedAsteroid(catalog[nextIndex])
setCameraAnnouncement(`Camera focused on ${catalog[nextIndex].name}.`)
}, [selectedAsteroid])

const selectPrevAsteroid = useCallback(() => {
Expand All @@ -315,6 +330,7 @@ export function AppProvider({
: catalog.length
const prevIndex = (currentIndex - 1 + catalog.length) % catalog.length
setSelectedAsteroid(catalog[prevIndex])
setCameraAnnouncement(`Camera focused on ${catalog[prevIndex].name}.`)
}, [selectedAsteroid])

const triggerDeltaVLog = useCallback(() => {
Expand Down Expand Up @@ -394,6 +410,7 @@ export function AppProvider({
const found = asteroidDataRef.current.find((item) => item.id === numId)
if (found) {
setSelectedAsteroid(found)
setCameraAnnouncement(`Camera focused on ${found.name}.`)
}
}
}
Expand Down Expand Up @@ -448,6 +465,13 @@ export function AppProvider({
// ========== NEW: include the new state and setter ==========
focusedObjectId,
setFocusedObjectId,
cameraAnnouncement,
cinematicMode,
cameraFov,
autoRotate,
bloomIntensity,
toggleCinematicMode,
toggleAutoRotate,
}}
>
{children}
Expand Down Expand Up @@ -482,7 +506,7 @@ export const simClock = 0
// Fixed #1097: Replaced localStorage with a typed cache wrapper
// Fixed #1100: Consolidate satellite state parameters
// Development warning for out-of-bounds context consumption
export const verifyProviderBounds = (ctx: any) => { if(!ctx) console.warn('Missing Provider Context'); return ctx; };
export const verifyProviderBounds = <T,>(ctx: T | null) => { if(!ctx) console.warn('Missing Provider Context'); return ctx; };
// API payload size analytics event exporter
export const logApiPayloadSize = (bytes: number) => console.debug(`API Payload: ${bytes}b`);
/**
Expand All @@ -493,15 +517,15 @@ export const API_WRAPPER_DOCS = true;
// Strict standard convention format for Provider exports
export const StandardProviderExport = true;
// Malformed JSON API response guard
export const parseApiSafe = (raw: string) => { try{ return JSON.parse(raw); }catch(e){ return {}; } };
export const parseApiSafe = (raw: string) => { try{ return JSON.parse(raw); }catch{ return {}; } };
// Unified CSS token dictionary export
export const CSSTokens = { colors: { bg: '#000', fg: '#fff' } };
// Explicit React Context generic dependency typing
export interface GenericAppContext<T> { state: T; dispatch: any; }
export interface GenericAppContext<T> { state: T; dispatch: (action: unknown) => void; }
// Isolated API wrapper sandbox context
export const apiSandbox = { fetch: async () => null };
// Explicit API response shape mapping
export interface AsteroidApiResponse { data: any[]; success: boolean; }
export interface AsteroidApiResponse { data: unknown[]; success: boolean; }
// AbortController signal generator for stale fetches
export const createFetchSignal = () => new AbortController().signal;
// A11y status formatter for screen readers
Expand All @@ -515,7 +539,7 @@ export const DATA_REFRESH_INTERVAL_MS = 60000;
// Unified context exports
export const useUnifiedContext = () => { return null; };
// Asteroid data fetch caching helper
export const asteroidFetchCache = new Map<string, any>();
export const asteroidFetchCache = new Map<string, unknown>();
// Error state wrapper for asteroid fetching
export interface FetchError { message: string; code: number };
// Auto-resolved #236: Improve performance of the Asteroid data fetching hook
Expand Down
Loading