diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 2f8671c..491a597 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -2,13 +2,13 @@ import { motion, type Variants } from "motion/react"; import dynamic from "next/dynamic"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene"; import { CV_URL } from "@/lib/site-navigation"; import { buildWhatsAppUrl } from "@/lib/whatsapp"; import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe"; -import MagneticButton from "./cinematic/MagneticButton"; import { HeroStarfield } from "./cinematic/HeroStarfield"; +import MagneticButton from "./cinematic/MagneticButton"; import { TelemetryStrip } from "./cinematic/TelemetryStrip"; const HeroScene = dynamic(() => import("./cinematic/HeroScene"), { @@ -455,8 +455,10 @@ function SideVisual({ export default function Hero() { const variant = HERO_VARIANTS[0]; + const heroRef = useRef(null); const [shouldReduceMotion, setShouldReduceMotion] = useState(false); const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false); + const [isHeroVisible, setIsHeroVisible] = useState(true); useEffect(() => { const reducedMotion = window.matchMedia( @@ -478,6 +480,17 @@ export default function Hero() { const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500); return () => window.clearTimeout(timer); }, []); + useEffect(() => { + const hero = heroRef.current; + if (!hero || !("IntersectionObserver" in window)) return undefined; + + const observer = new IntersectionObserver( + ([entry]) => setIsHeroVisible(entry?.isIntersecting ?? false), + { threshold: 0 }, + ); + observer.observe(hero); + return () => observer.disconnect(); + }, []); const handleWhatsAppClick = () => { window.open( @@ -532,11 +545,14 @@ export default function Hero() { return (
- {shouldRenderHeroScene && } + {shouldRenderHeroScene && ( + + )}
diff --git a/app/components/cinematic/HeroScene.tsx b/app/components/cinematic/HeroScene.tsx index c5402af..3904677 100644 --- a/app/components/cinematic/HeroScene.tsx +++ b/app/components/cinematic/HeroScene.tsx @@ -103,7 +103,13 @@ function CameraRig() { return null; } -export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) { +export default function HeroScene({ + variant, + isVisible = true, +}: { + variant?: HeroSceneVariant; + isVisible?: boolean; +}) { void variant; return (
{ expect(screen.getByRole("tabpanel")).toHaveAccessibleName( /SC Plus Aquicultura/i, ); + expect(screen.getByRole("status")).toHaveTextContent( + "Projeto ativo: SC Plus Aquicultura", + ); }); + it("preloads the next screenshot and keeps a short outgoing crossfade", () => { + const view = renderBrowser(); + expect( + view.container.querySelector(".notebook-screenshot-preload"), + ).toBeInTheDocument(); + + view.rerender( + , + ); + expect( + view.container.querySelector(".notebook-screenshot-outgoing"), + ).toBeInTheDocument(); + }); it("moves between tabs with arrow keys and Home/End", async () => { const user = userEvent.setup(); const onSelectTab = jest.fn(); @@ -56,6 +77,26 @@ describe("NotebookBrowser", () => { expect(onSelectTab).toHaveBeenNthCalledWith(3, 0); expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1); }); + it("moves DOM focus with the active tab", async () => { + const user = userEvent.setup(); + const tabs = + renderBrowser().container.querySelectorAll( + '[role="tab"]', + ); + + tabs[0].focus(); + await user.keyboard("{ArrowRight}"); + + expect(document.activeElement).toBe(tabs[1]); + }); + + it("removes the closed browser from the accessibility tree", () => { + renderBrowser({ interactive: false }); + + const browser = screen.getByTestId("notebook-browser"); + expect(browser).toHaveAttribute("aria-hidden", "true"); + expect(browser).toHaveAttribute("inert"); + }); it("bounds arrow navigation at the first and last tab", async () => { const user = userEvent.setup(); diff --git a/app/components/story/NotebookBrowser.tsx b/app/components/story/NotebookBrowser.tsx index 2e560b3..d798ad8 100644 --- a/app/components/story/NotebookBrowser.tsx +++ b/app/components/story/NotebookBrowser.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; import type { NotebookProject } from "./notebookProjects"; import type { NotebookFrame } from "./notebookTimeline"; @@ -12,6 +13,7 @@ export type NotebookBrowserProps = { "cursorVisible" | "cursorX" | "cursorY" | "cursorClicking" >; onSelectTab: (index: number) => void; + interactive?: boolean; }; function clampIndex(index: number, length: number): number { @@ -28,10 +30,35 @@ export default function NotebookBrowser({ activeIndex, frame, onSelectTab, + interactive = true, }: NotebookBrowserProps) { const selectedIndex = clampIndex(activeIndex, projects.length); const selectedProject = selectedIndex >= 0 ? projects[selectedIndex] : undefined; + const previousIndexRef = useRef(selectedIndex); + const [outgoingIndex, setOutgoingIndex] = useState(null); + useEffect(() => { + const previousIndex = previousIndexRef.current; + if (previousIndex === selectedIndex) return undefined; + + previousIndexRef.current = selectedIndex; + setOutgoingIndex(previousIndex >= 0 ? previousIndex : null); + const timeout = window.setTimeout(() => setOutgoingIndex(null), 180); + return () => window.clearTimeout(timeout); + }, [selectedIndex]); + + const outgoingProject = + outgoingIndex !== null ? projects[outgoingIndex] : undefined; + const nextProject = + selectedIndex >= 0 && projects.length > 1 + ? projects[(selectedIndex + 1) % projects.length] + : undefined; + const interactionProps = interactive + ? {} + : { + "aria-hidden": true, + inert: true, + }; function handleTabKeyDown( event: React.KeyboardEvent, @@ -52,12 +79,16 @@ export default function NotebookBrowser({ if (nextIndex === null) return; event.preventDefault(); + event.currentTarget.parentElement + ?.querySelectorAll('[role="tab"]') + [nextIndex]?.focus(); onSelectTab(nextIndex); } if (!selectedProject) { return (
+
+ + Projeto ativo: {selectedProject.title} +
+ {outgoingProject ? ( + + ) : null} {`Captura + {nextProject ? ( + + ) : null} {frame.cursorVisible ? (