diff --git a/README.md b/README.md index 0520413..949189f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ O notebook 3D permanece em celulares, tablets e desktop: tampa, tela, abas e cur As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha. -Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. +Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo. ### Identidade visual diff --git a/app/components/cinematic/CinematicHome.test.tsx b/app/components/cinematic/CinematicHome.test.tsx index 46bbb7e..64a5b4e 100644 --- a/app/components/cinematic/CinematicHome.test.tsx +++ b/app/components/cinematic/CinematicHome.test.tsx @@ -1,5 +1,8 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; -import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation"; +import { + resetHomeArrivalState, + useHomeFragmentNavigation, +} from "./useHomeFragmentNavigation"; function HomeNavigation() { const onLayoutReady = useHomeFragmentNavigation(); @@ -23,6 +26,7 @@ function HomeNavigation() { const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; beforeEach(() => { + resetHomeArrivalState(); window.history.replaceState(null, "", "/"); HTMLElement.prototype.scrollIntoView = jest.fn(); }); @@ -53,6 +57,26 @@ describe("home fragment navigation", () => { expect(restoredPosition).toBe(0); }); + it("leaves a same-document return to the position the browser restores", () => { + const scrollTo = jest + .spyOn(window, "scrollTo") + .mockImplementation(() => {}); + const firstVisit = render(); + fireEvent.click( + screen.getByRole("button", { name: "Establish notebook layout" }), + ); + firstVisit.unmount(); + expect(window.history.scrollRestoration).not.toBe("manual"); + + scrollTo.mockClear(); + render(); + fireEvent.click( + screen.getByRole("button", { name: "Establish notebook layout" }), + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(window.history.scrollRestoration).not.toBe("manual"); + }); + it("does not reset the visitor's position after intentional scrolling during startup", () => { let position = 0; jest diff --git a/app/components/cinematic/useHomeFragmentNavigation.ts b/app/components/cinematic/useHomeFragmentNavigation.ts index 97a7496..816f21a 100644 --- a/app/components/cinematic/useHomeFragmentNavigation.ts +++ b/app/components/cinematic/useHomeFragmentNavigation.ts @@ -16,6 +16,17 @@ function focusFragment(target: HTMLElement) { target.focus({ preventScroll: true }); } +/** + * A document owns one outstanding homepage arrival. Later mounts are same-document + * history, where the browser restores its own position. + */ +let arrivalSettled = false; + +/** Tests replay several documents inside one jsdom document. */ +export function resetHomeArrivalState() { + arrivalSettled = false; +} + /** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */ export function useHomeFragmentNavigation() { const ready = useRef(false); @@ -26,12 +37,11 @@ export function useHomeFragmentNavigation() { | PerformanceNavigationTiming | undefined; // A history restoration owns its saved position, not the URL's old fragment. - let pending = - navigation?.type === "back_forward" ? "" : window.location.hash; - let pendingTop = - navigation?.type !== "back_forward" && !window.location.hash; - if (navigation?.type !== "back_forward") - window.history.scrollRestoration = "manual"; + const restoration = navigation?.type === "back_forward"; + const arrival = !restoration && !arrivalSettled; + let pending = restoration ? "" : window.location.hash; + let pendingTop = arrival && !window.location.hash; + if (arrival) window.history.scrollRestoration = "manual"; const resolve = () => { if (!ready.current) return; @@ -69,6 +79,7 @@ export function useHomeFragmentNavigation() { const cancelArrival = () => { pending = ""; pendingTop = false; + window.history.scrollRestoration = "auto"; }; const onKeyDown = (event: KeyboardEvent) => { if ( @@ -151,5 +162,7 @@ export function useHomeFragmentNavigation() { return useCallback(() => { ready.current = true; resolveArrival.current?.(); + arrivalSettled = true; + window.history.scrollRestoration = "auto"; }, []); }