fix(story): harden notebook interaction and fallback
This commit is contained in:
1 parent
bb4bf8e7e2
commit
f3ed9da777
12 files changed
+344
-57
No files matched your search
+19
-3
@@ -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<HTMLElement>(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 (
|
||||
<section
|
||||
ref={heroRef}
|
||||
id="home"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
data-hero-variant={variant.id}
|
||||
>
|
||||
{shouldRenderHeroScene && <HeroScene variant={variant.scene} />}
|
||||
{shouldRenderHeroScene && (
|
||||
<HeroScene isVisible={isHeroVisible} variant={variant.scene} />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -113,6 +119,7 @@ export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
|
||||
<Canvas
|
||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={isVisible ? "always" : "never"}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
|
||||
@@ -38,8 +38,29 @@ describe("NotebookBrowser", () => {
|
||||
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(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
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<HTMLButtonElement>(
|
||||
'[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();
|
||||
|
||||
@@ -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<number | null>(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<HTMLButtonElement>,
|
||||
@@ -52,12 +79,16 @@ export default function NotebookBrowser({
|
||||
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
||||
[nextIndex]?.focus();
|
||||
onSelectTab(nextIndex);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
data-empty
|
||||
@@ -71,7 +102,11 @@ export default function NotebookBrowser({
|
||||
const panelId = `notebook-panel-${selectedId}`;
|
||||
|
||||
return (
|
||||
<div className="notebook-browser" data-testid="notebook-browser">
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
>
|
||||
<div className="notebook-browser-bar" aria-hidden="true">
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-dot" />
|
||||
@@ -89,6 +124,9 @@ export default function NotebookBrowser({
|
||||
{String(projects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<output className="sr-only" aria-live="polite">
|
||||
Projeto ativo: {selectedProject.title}
|
||||
</output>
|
||||
|
||||
<div
|
||||
className="notebook-tablist"
|
||||
@@ -145,14 +183,36 @@ export default function NotebookBrowser({
|
||||
</div>
|
||||
|
||||
<div className="notebook-screenshot-wrap">
|
||||
{outgoingProject ? (
|
||||
<Image
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="notebook-screenshot notebook-screenshot-outgoing"
|
||||
fill
|
||||
sizes="(min-width: 681px) 60vw, 100vw"
|
||||
src={outgoingProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<Image
|
||||
alt={`Captura de tela de ${selectedProject.title}`}
|
||||
className="notebook-screenshot"
|
||||
className="notebook-screenshot notebook-screenshot-current"
|
||||
fill
|
||||
sizes="(min-width: 681px) 60vw, 100vw"
|
||||
src={selectedProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
{nextProject ? (
|
||||
<Image
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="notebook-screenshot-preload"
|
||||
fill
|
||||
sizes="1px"
|
||||
src={nextProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
{frame.cursorVisible ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -72,15 +72,17 @@ describe("NotebookStage3D", () => {
|
||||
|
||||
it("returns usable browser content when the GLB boundary fails", () => {
|
||||
const error = new Error("GLB failed");
|
||||
const onStageError = jest.fn();
|
||||
mockedUseGLTF.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
render(<NotebookStage3D {...props} />);
|
||||
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
||||
expect(onStageError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export type NotebookStage3DProps = {
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
isVisible: boolean;
|
||||
onStageError?: () => void;
|
||||
};
|
||||
|
||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||
@@ -43,13 +44,13 @@ const OPEN_LID_ANGLE = 112;
|
||||
function createMaterials(): MaterialMap {
|
||||
return {
|
||||
body: new THREE.MeshStandardMaterial({
|
||||
color: "#102020",
|
||||
color: "#183333",
|
||||
metalness: 0.78,
|
||||
roughness: 0.3,
|
||||
transparent: true,
|
||||
}),
|
||||
frame: new THREE.MeshStandardMaterial({
|
||||
color: "#294345",
|
||||
color: "#3f6264",
|
||||
metalness: 0.64,
|
||||
roughness: 0.38,
|
||||
transparent: true,
|
||||
@@ -79,16 +80,17 @@ function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
|
||||
meshes.push(object);
|
||||
|
||||
object.geometry = object.geometry.clone();
|
||||
const sourceMaterial = Array.isArray(object.material)
|
||||
? object.material[0]
|
||||
: object.material;
|
||||
const materialName = sourceMaterial?.name ?? "";
|
||||
object.material =
|
||||
materialName === "metal"
|
||||
const mapMaterial = (sourceMaterial: THREE.Material) => {
|
||||
const materialName = sourceMaterial.name;
|
||||
return materialName === "metal"
|
||||
? materials.screen
|
||||
: materialName === "metalMedium"
|
||||
? materials.frame
|
||||
: materials.body;
|
||||
};
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(mapMaterial)
|
||||
: mapMaterial(object.material);
|
||||
object.frustumCulled = false;
|
||||
});
|
||||
|
||||
@@ -139,7 +141,7 @@ function ScreenSurface({
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
distanceFactor={4.6}
|
||||
distanceFactor={0.52}
|
||||
position={[-0.132, 0.087, 0.081]}
|
||||
transform
|
||||
zIndexRange={[20, 0]}
|
||||
@@ -151,6 +153,7 @@ function ScreenSurface({
|
||||
<NotebookBrowser
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
interactive={frame.screenOpacity > 0.05}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
@@ -214,15 +217,14 @@ function NotebookScene({
|
||||
{base && lid ? (
|
||||
<>
|
||||
<primitive object={base} />
|
||||
<group ref={lidRig}>
|
||||
<primitive object={lid} />
|
||||
<primitive ref={lidRig} object={lid}>
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</primitive>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -251,6 +253,10 @@ class NotebookStageBoundary extends Component<
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
this.props.onStageError?.();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import NotebookStory, {
|
||||
canUseNotebook3D,
|
||||
getNotebookRunwayVh,
|
||||
getNotebookSeekProgress,
|
||||
} from "./NotebookStory";
|
||||
|
||||
@@ -88,25 +89,54 @@ describe("NotebookStory", () => {
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toHaveAttribute("data-notebook-runway-vh", "520");
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||
mockMatchMedia();
|
||||
|
||||
render(<NotebookStory projects={[]} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("scales the runway from the number of projects", () => {
|
||||
expect(getNotebookRunwayVh(1)).toBe(400);
|
||||
expect(getNotebookRunwayVh(4)).toBe(520);
|
||||
expect(getNotebookRunwayVh(99)).toBe(4320);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
const matchMedia = (query: string) =>
|
||||
({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
@@ -30,6 +31,14 @@ export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotebookRunwayVh(tabCount: number): number {
|
||||
const count = Math.max(
|
||||
1,
|
||||
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
|
||||
);
|
||||
return 360 + count * 40;
|
||||
}
|
||||
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
@@ -58,7 +67,28 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
||||
const updateEngine = () => {
|
||||
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
|
||||
const mediaQueries = [
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)"),
|
||||
window.matchMedia("(pointer: coarse)"),
|
||||
window.matchMedia("(hover: hover)"),
|
||||
];
|
||||
window.addEventListener("resize", updateEngine, { passive: true });
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.addEventListener("change", updateEngine);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateEngine);
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.removeEventListener("change", updateEngine);
|
||||
});
|
||||
};
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,6 +157,10 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
};
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const handleStageError = useCallback(() => {
|
||||
setEngine(false);
|
||||
}, []);
|
||||
|
||||
const seekToTab = useCallback(
|
||||
(index: number) => {
|
||||
const section = sectionRef.current;
|
||||
@@ -161,7 +195,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção"
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-static"
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
@@ -183,12 +217,18 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Apresentação em scroll contínuo"
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-runway"
|
||||
data-notebook-engine="3d"
|
||||
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-sticky">
|
||||
<div className="notebook-story-grid">
|
||||
@@ -208,6 +248,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
activeIndex={activeIndex}
|
||||
isVisible={isVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
|
||||
@@ -47,10 +47,8 @@ const TO_VARS = {
|
||||
};
|
||||
|
||||
export default function ReelDirector() {
|
||||
const alive = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
let cancelled = false;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return undefined;
|
||||
|
||||
@@ -64,14 +62,14 @@ export default function ReelDirector() {
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (!alive.current) return;
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const done = new WeakSet<Element>();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scan = () => {
|
||||
if (!alive.current) return;
|
||||
if (cancelled) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel]",
|
||||
)) {
|
||||
@@ -94,24 +92,21 @@ export default function ReelDirector() {
|
||||
)) {
|
||||
if (done.has(container)) continue;
|
||||
done.add(container);
|
||||
const dir = (container.dataset.reelStagger || "bottom") as Direction;
|
||||
const dir = (container.dataset.reelStagger ||
|
||||
"bottom") as Direction;
|
||||
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) continue;
|
||||
gsap.fromTo(
|
||||
children,
|
||||
fromVars(dir, dist),
|
||||
{
|
||||
...TO_VARS,
|
||||
stagger: { each: 0.09 },
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: container.dataset.reelStart || "top 85%",
|
||||
end: container.dataset.reelEnd || "top 25%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
gsap.fromTo(children, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
stagger: { each: 0.09 },
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: container.dataset.reelStart || "top 85%",
|
||||
end: container.dataset.reelEnd || "top 25%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,7 +137,7 @@ export default function ReelDirector() {
|
||||
|
||||
void boot();
|
||||
return () => {
|
||||
alive.current = false;
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -64,8 +64,8 @@ describe("getNotebookFrame", () => {
|
||||
});
|
||||
|
||||
it("emits a deterministic cursor click pulse for each tab", () => {
|
||||
const first = getNotebookFrame(0.447, 4);
|
||||
const later = getNotebookFrame(0.53, 4);
|
||||
const first = getNotebookFrame(0.475, 4);
|
||||
const later = getNotebookFrame(0.56, 4);
|
||||
|
||||
expect(first.cursorClicking).toBe(true);
|
||||
expect(later.cursorClicking).toBe(true);
|
||||
@@ -75,6 +75,17 @@ describe("getNotebookFrame", () => {
|
||||
expect(first.cursorY).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("moves the cursor between tab targets instead of teleporting", () => {
|
||||
const previous = getNotebookFrame(0.525, 4);
|
||||
const moving = getNotebookFrame(0.532, 4);
|
||||
const target = getNotebookFrame(0.56, 4);
|
||||
|
||||
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
|
||||
expect(moving.cursorX).toBeLessThan(target.cursorX);
|
||||
expect(moving.cursorY).toBeLessThan(previous.cursorY);
|
||||
expect(moving.cursorY).toBeGreaterThan(target.cursorY);
|
||||
});
|
||||
|
||||
it("is deterministic when rewound to the same progress", () => {
|
||||
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ function getCursorPoint(
|
||||
progress: number,
|
||||
phase: NotebookPhase,
|
||||
activeTabIndex: number,
|
||||
tabCount: number,
|
||||
): { x: number; y: number } {
|
||||
if (phase === "cursor-in") {
|
||||
const cursorProgress = (progress - 0.34) / 0.1;
|
||||
@@ -94,8 +95,19 @@ function getCursorPoint(
|
||||
return { x: 0.06, y: 0.88 };
|
||||
}
|
||||
|
||||
const point = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
|
||||
return point;
|
||||
const target = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
|
||||
if (phase !== "browse" || activeTabIndex === 0) return target;
|
||||
|
||||
const browseProgress =
|
||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||
const slotProgress = browseProgress * tabCount - activeTabIndex;
|
||||
const previous = CURSOR_POINTS[(activeTabIndex - 1) % CURSOR_POINTS.length];
|
||||
const moveProgress = clamp(slotProgress / 0.36, 0, 1);
|
||||
|
||||
return {
|
||||
x: interpolate(previous.x, target.x, moveProgress),
|
||||
y: interpolate(previous.y, target.y, moveProgress),
|
||||
};
|
||||
}
|
||||
|
||||
function isCursorClicking(
|
||||
@@ -124,7 +136,7 @@ function isCursorClicking(
|
||||
const browseProgress =
|
||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||
const slotProgress = (browseProgress * tabCount) % 1;
|
||||
return slotProgress < 0.12;
|
||||
return slotProgress >= 0.36 && slotProgress < 0.5;
|
||||
}
|
||||
|
||||
export function getNotebookFrame(
|
||||
@@ -173,6 +185,7 @@ export function getNotebookFrame(
|
||||
normalizedProgress,
|
||||
phase,
|
||||
activeTabIndex,
|
||||
normalizedTabCount,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+73
-8
@@ -835,7 +835,7 @@ textarea {
|
||||
|
||||
.notebook-story-runway {
|
||||
position: relative;
|
||||
min-height: 520vh;
|
||||
min-height: var(--notebook-runway, 520vh);
|
||||
}
|
||||
|
||||
.notebook-story-sticky {
|
||||
@@ -1023,11 +1023,12 @@ textarea {
|
||||
.notebook-tab {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 14px 10px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-width: 2px;
|
||||
background: transparent;
|
||||
color: rgba(244, 242, 237, 0.48);
|
||||
cursor: pointer;
|
||||
@@ -1104,20 +1105,39 @@ textarea {
|
||||
|
||||
.notebook-panel-copy a {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: var(--sp-5);
|
||||
color: var(--slash);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--slash);
|
||||
background: var(--slash);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.notebook-panel-copy a:hover {
|
||||
color: var(--white);
|
||||
border-color: var(--white);
|
||||
background: var(--white);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.notebook-panel-copy a:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.notebook-panel-copy a:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.notebook-screenshot-wrap {
|
||||
@@ -1132,7 +1152,49 @@ textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
.notebook-screenshot-current,
|
||||
.notebook-screenshot-outgoing {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.notebook-screenshot-current {
|
||||
z-index: 0;
|
||||
animation: notebook-screenshot-in 180ms ease both;
|
||||
}
|
||||
|
||||
.notebook-screenshot-outgoing {
|
||||
z-index: 0;
|
||||
animation: notebook-screenshot-out 180ms ease both;
|
||||
}
|
||||
|
||||
.notebook-screenshot-wrap img.notebook-screenshot-preload {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes notebook-screenshot-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notebook-screenshot-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notebook-cursor {
|
||||
@@ -1228,7 +1290,10 @@ textarea {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notebook-tab,
|
||||
.notebook-cursor {
|
||||
.notebook-cursor,
|
||||
.notebook-screenshot-current,
|
||||
.notebook-screenshot-outgoing {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user