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 { motion, type Variants } from "motion/react";
|
||||||
import dynamic from "next/dynamic";
|
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 { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
|
||||||
import { CV_URL } from "@/lib/site-navigation";
|
import { CV_URL } from "@/lib/site-navigation";
|
||||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||||
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
import { HeroStarfield } from "./cinematic/HeroStarfield";
|
import { HeroStarfield } from "./cinematic/HeroStarfield";
|
||||||
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||||
@@ -455,8 +455,10 @@ function SideVisual({
|
|||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const variant = HERO_VARIANTS[0];
|
const variant = HERO_VARIANTS[0];
|
||||||
|
const heroRef = useRef<HTMLElement>(null);
|
||||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||||
|
const [isHeroVisible, setIsHeroVisible] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reducedMotion = window.matchMedia(
|
const reducedMotion = window.matchMedia(
|
||||||
@@ -478,6 +480,17 @@ export default function Hero() {
|
|||||||
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
|
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
|
||||||
return () => window.clearTimeout(timer);
|
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 = () => {
|
const handleWhatsAppClick = () => {
|
||||||
window.open(
|
window.open(
|
||||||
@@ -532,11 +545,14 @@ export default function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
ref={heroRef}
|
||||||
id="home"
|
id="home"
|
||||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||||
data-hero-variant={variant.id}
|
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-[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)]" />
|
<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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
|
export default function HeroScene({
|
||||||
|
variant,
|
||||||
|
isVisible = true,
|
||||||
|
}: {
|
||||||
|
variant?: HeroSceneVariant;
|
||||||
|
isVisible?: boolean;
|
||||||
|
}) {
|
||||||
void variant;
|
void variant;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -113,6 +119,7 @@ export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
|
|||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
|
frameloop={isVisible ? "always" : "never"}
|
||||||
gl={{
|
gl={{
|
||||||
antialias: false,
|
antialias: false,
|
||||||
alpha: true,
|
alpha: true,
|
||||||
|
|||||||
@@ -38,8 +38,29 @@ describe("NotebookBrowser", () => {
|
|||||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
||||||
/SC Plus Aquicultura/i,
|
/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 () => {
|
it("moves between tabs with arrow keys and Home/End", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelectTab = jest.fn();
|
const onSelectTab = jest.fn();
|
||||||
@@ -56,6 +77,26 @@ describe("NotebookBrowser", () => {
|
|||||||
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
|
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
|
||||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
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 () => {
|
it("bounds arrow navigation at the first and last tab", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { NotebookProject } from "./notebookProjects";
|
import type { NotebookProject } from "./notebookProjects";
|
||||||
import type { NotebookFrame } from "./notebookTimeline";
|
import type { NotebookFrame } from "./notebookTimeline";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export type NotebookBrowserProps = {
|
|||||||
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
||||||
>;
|
>;
|
||||||
onSelectTab: (index: number) => void;
|
onSelectTab: (index: number) => void;
|
||||||
|
interactive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampIndex(index: number, length: number): number {
|
function clampIndex(index: number, length: number): number {
|
||||||
@@ -28,10 +30,35 @@ export default function NotebookBrowser({
|
|||||||
activeIndex,
|
activeIndex,
|
||||||
frame,
|
frame,
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
|
interactive = true,
|
||||||
}: NotebookBrowserProps) {
|
}: NotebookBrowserProps) {
|
||||||
const selectedIndex = clampIndex(activeIndex, projects.length);
|
const selectedIndex = clampIndex(activeIndex, projects.length);
|
||||||
const selectedProject =
|
const selectedProject =
|
||||||
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
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(
|
function handleTabKeyDown(
|
||||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||||
@@ -52,12 +79,16 @@ export default function NotebookBrowser({
|
|||||||
|
|
||||||
if (nextIndex === null) return;
|
if (nextIndex === null) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.currentTarget.parentElement
|
||||||
|
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
||||||
|
[nextIndex]?.focus();
|
||||||
onSelectTab(nextIndex);
|
onSelectTab(nextIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
{...interactionProps}
|
||||||
className="notebook-browser"
|
className="notebook-browser"
|
||||||
data-testid="notebook-browser"
|
data-testid="notebook-browser"
|
||||||
data-empty
|
data-empty
|
||||||
@@ -71,7 +102,11 @@ export default function NotebookBrowser({
|
|||||||
const panelId = `notebook-panel-${selectedId}`;
|
const panelId = `notebook-panel-${selectedId}`;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="notebook-browser-bar" aria-hidden="true">
|
||||||
<span className="notebook-browser-dot" />
|
<span className="notebook-browser-dot" />
|
||||||
<span className="notebook-browser-dot" />
|
<span className="notebook-browser-dot" />
|
||||||
@@ -89,6 +124,9 @@ export default function NotebookBrowser({
|
|||||||
{String(projects.length).padStart(2, "0")}
|
{String(projects.length).padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<output className="sr-only" aria-live="polite">
|
||||||
|
Projeto ativo: {selectedProject.title}
|
||||||
|
</output>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="notebook-tablist"
|
className="notebook-tablist"
|
||||||
@@ -145,14 +183,36 @@ export default function NotebookBrowser({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="notebook-screenshot-wrap">
|
<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
|
<Image
|
||||||
alt={`Captura de tela de ${selectedProject.title}`}
|
alt={`Captura de tela de ${selectedProject.title}`}
|
||||||
className="notebook-screenshot"
|
className="notebook-screenshot notebook-screenshot-current"
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 681px) 60vw, 100vw"
|
sizes="(min-width: 681px) 60vw, 100vw"
|
||||||
src={selectedProject.screenshotSrc}
|
src={selectedProject.screenshotSrc}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
{nextProject ? (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="notebook-screenshot-preload"
|
||||||
|
fill
|
||||||
|
sizes="1px"
|
||||||
|
src={nextProject.screenshotSrc}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{frame.cursorVisible ? (
|
{frame.cursorVisible ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -72,15 +72,17 @@ describe("NotebookStage3D", () => {
|
|||||||
|
|
||||||
it("returns usable browser content when the GLB boundary fails", () => {
|
it("returns usable browser content when the GLB boundary fails", () => {
|
||||||
const error = new Error("GLB failed");
|
const error = new Error("GLB failed");
|
||||||
|
const onStageError = jest.fn();
|
||||||
mockedUseGLTF.mockImplementation(() => {
|
mockedUseGLTF.mockImplementation(() => {
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
render(<NotebookStage3D {...props} />);
|
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
||||||
|
expect(onStageError).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -27,6 +27,7 @@ export type NotebookStage3DProps = {
|
|||||||
activeIndex: number;
|
activeIndex: number;
|
||||||
onSelectTab: (index: number) => void;
|
onSelectTab: (index: number) => void;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
onStageError?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||||
@@ -43,13 +44,13 @@ const OPEN_LID_ANGLE = 112;
|
|||||||
function createMaterials(): MaterialMap {
|
function createMaterials(): MaterialMap {
|
||||||
return {
|
return {
|
||||||
body: new THREE.MeshStandardMaterial({
|
body: new THREE.MeshStandardMaterial({
|
||||||
color: "#102020",
|
color: "#183333",
|
||||||
metalness: 0.78,
|
metalness: 0.78,
|
||||||
roughness: 0.3,
|
roughness: 0.3,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
}),
|
}),
|
||||||
frame: new THREE.MeshStandardMaterial({
|
frame: new THREE.MeshStandardMaterial({
|
||||||
color: "#294345",
|
color: "#3f6264",
|
||||||
metalness: 0.64,
|
metalness: 0.64,
|
||||||
roughness: 0.38,
|
roughness: 0.38,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -79,16 +80,17 @@ function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
|
|||||||
meshes.push(object);
|
meshes.push(object);
|
||||||
|
|
||||||
object.geometry = object.geometry.clone();
|
object.geometry = object.geometry.clone();
|
||||||
const sourceMaterial = Array.isArray(object.material)
|
const mapMaterial = (sourceMaterial: THREE.Material) => {
|
||||||
? object.material[0]
|
const materialName = sourceMaterial.name;
|
||||||
: object.material;
|
return materialName === "metal"
|
||||||
const materialName = sourceMaterial?.name ?? "";
|
|
||||||
object.material =
|
|
||||||
materialName === "metal"
|
|
||||||
? materials.screen
|
? materials.screen
|
||||||
: materialName === "metalMedium"
|
: materialName === "metalMedium"
|
||||||
? materials.frame
|
? materials.frame
|
||||||
: materials.body;
|
: materials.body;
|
||||||
|
};
|
||||||
|
object.material = Array.isArray(object.material)
|
||||||
|
? object.material.map(mapMaterial)
|
||||||
|
: mapMaterial(object.material);
|
||||||
object.frustumCulled = false;
|
object.frustumCulled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ function ScreenSurface({
|
|||||||
return (
|
return (
|
||||||
<Html
|
<Html
|
||||||
center
|
center
|
||||||
distanceFactor={4.6}
|
distanceFactor={0.52}
|
||||||
position={[-0.132, 0.087, 0.081]}
|
position={[-0.132, 0.087, 0.081]}
|
||||||
transform
|
transform
|
||||||
zIndexRange={[20, 0]}
|
zIndexRange={[20, 0]}
|
||||||
@@ -151,6 +153,7 @@ function ScreenSurface({
|
|||||||
<NotebookBrowser
|
<NotebookBrowser
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
frame={frame}
|
frame={frame}
|
||||||
|
interactive={frame.screenOpacity > 0.05}
|
||||||
onSelectTab={onSelectTab}
|
onSelectTab={onSelectTab}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
@@ -214,15 +217,14 @@ function NotebookScene({
|
|||||||
{base && lid ? (
|
{base && lid ? (
|
||||||
<>
|
<>
|
||||||
<primitive object={base} />
|
<primitive object={base} />
|
||||||
<group ref={lidRig}>
|
<primitive ref={lidRig} object={lid}>
|
||||||
<primitive object={lid} />
|
|
||||||
<ScreenSurface
|
<ScreenSurface
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
frame={frame}
|
frame={frame}
|
||||||
onSelectTab={onSelectTab}
|
onSelectTab={onSelectTab}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</group>
|
</primitive>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -251,6 +253,10 @@ class NotebookStageBoundary extends Component<
|
|||||||
return { failed: true };
|
return { failed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch() {
|
||||||
|
this.props.onStageError?.();
|
||||||
|
}
|
||||||
|
|
||||||
render(): ReactNode {
|
render(): ReactNode {
|
||||||
if (this.state.failed) {
|
if (this.state.failed) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import { portfolio } from "@/lib/portfolio-data";
|
import { portfolio } from "@/lib/portfolio-data";
|
||||||
import NotebookStory, {
|
import NotebookStory, {
|
||||||
canUseNotebook3D,
|
canUseNotebook3D,
|
||||||
|
getNotebookRunwayVh,
|
||||||
getNotebookSeekProgress,
|
getNotebookSeekProgress,
|
||||||
} from "./NotebookStory";
|
} from "./NotebookStory";
|
||||||
|
|
||||||
@@ -88,25 +89,54 @@ describe("NotebookStory", () => {
|
|||||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||||
render(<NotebookStory projects={projects} />);
|
render(<NotebookStory projects={projects} />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
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 () => {
|
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||||
mockMatchMedia();
|
mockMatchMedia();
|
||||||
|
|
||||||
render(<NotebookStory projects={[]} />);
|
render(<NotebookStory projects={[]} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Projetos em produção no notebook"),
|
||||||
|
).toBeInTheDocument();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
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", () => {
|
it("requires desktop hover input without reduced motion", () => {
|
||||||
const matchMedia = (query: string) =>
|
const matchMedia = (query: string) =>
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||||
import NotebookFallback from "./NotebookFallback";
|
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(
|
export function getNotebookSeekProgress(
|
||||||
index: number,
|
index: number,
|
||||||
tabCount: number,
|
tabCount: number,
|
||||||
@@ -58,7 +67,28 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
|||||||
const [isVisible, setIsVisible] = useState(true);
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||||
useEffect(() => {
|
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]);
|
}, [notebookProjects.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,6 +157,10 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
|||||||
};
|
};
|
||||||
}, [engine, notebookProjects.length]);
|
}, [engine, notebookProjects.length]);
|
||||||
|
|
||||||
|
const handleStageError = useCallback(() => {
|
||||||
|
setEngine(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const seekToTab = useCallback(
|
const seekToTab = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const section = sectionRef.current;
|
const section = sectionRef.current;
|
||||||
@@ -161,7 +195,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
|||||||
if (!engine) {
|
if (!engine) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Projetos em produção"
|
aria-label="Projetos em produção no notebook"
|
||||||
className="notebook-story notebook-story-static"
|
className="notebook-story notebook-story-static"
|
||||||
data-testid="notebook-story"
|
data-testid="notebook-story"
|
||||||
id="story"
|
id="story"
|
||||||
@@ -183,12 +217,18 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Apresentação em scroll contínuo"
|
aria-label="Projetos em produção no notebook"
|
||||||
className="notebook-story notebook-story-runway"
|
className="notebook-story notebook-story-runway"
|
||||||
data-notebook-engine="3d"
|
data-notebook-engine="3d"
|
||||||
|
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
|
||||||
data-testid="notebook-story"
|
data-testid="notebook-story"
|
||||||
id="story"
|
id="story"
|
||||||
ref={sectionRef}
|
ref={sectionRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="notebook-story-sticky">
|
<div className="notebook-story-sticky">
|
||||||
<div className="notebook-story-grid">
|
<div className="notebook-story-grid">
|
||||||
@@ -208,6 +248,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
|||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
onSelectTab={handleSelectTab}
|
onSelectTab={handleSelectTab}
|
||||||
|
onStageError={handleStageError}
|
||||||
progress={progressRef}
|
progress={progressRef}
|
||||||
projects={notebookProjects}
|
projects={notebookProjects}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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.
|
* 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() {
|
export default function ReelDirector() {
|
||||||
const alive = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true;
|
let cancelled = false;
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
@@ -64,14 +62,14 @@ export default function ReelDirector() {
|
|||||||
import("gsap"),
|
import("gsap"),
|
||||||
import("gsap/ScrollTrigger"),
|
import("gsap/ScrollTrigger"),
|
||||||
]);
|
]);
|
||||||
if (!alive.current) return;
|
if (cancelled) return;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const done = new WeakSet<Element>();
|
const done = new WeakSet<Element>();
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const scan = () => {
|
const scan = () => {
|
||||||
if (!alive.current) return;
|
if (cancelled) return;
|
||||||
for (const el of document.querySelectorAll<HTMLElement>(
|
for (const el of document.querySelectorAll<HTMLElement>(
|
||||||
"[data-reel]",
|
"[data-reel]",
|
||||||
)) {
|
)) {
|
||||||
@@ -94,24 +92,21 @@ export default function ReelDirector() {
|
|||||||
)) {
|
)) {
|
||||||
if (done.has(container)) continue;
|
if (done.has(container)) continue;
|
||||||
done.add(container);
|
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 dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||||
const children = Array.from(container.children) as HTMLElement[];
|
const children = Array.from(container.children) as HTMLElement[];
|
||||||
if (children.length === 0) continue;
|
if (children.length === 0) continue;
|
||||||
gsap.fromTo(
|
gsap.fromTo(children, fromVars(dir, dist), {
|
||||||
children,
|
...TO_VARS,
|
||||||
fromVars(dir, dist),
|
stagger: { each: 0.09 },
|
||||||
{
|
scrollTrigger: {
|
||||||
...TO_VARS,
|
trigger: container,
|
||||||
stagger: { each: 0.09 },
|
start: container.dataset.reelStart || "top 85%",
|
||||||
scrollTrigger: {
|
end: container.dataset.reelEnd || "top 25%",
|
||||||
trigger: container,
|
scrub: 0.6,
|
||||||
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();
|
void boot();
|
||||||
return () => {
|
return () => {
|
||||||
alive.current = false;
|
cancelled = true;
|
||||||
window.cancelAnimationFrame(raf1);
|
window.cancelAnimationFrame(raf1);
|
||||||
window.cancelAnimationFrame(raf2);
|
window.cancelAnimationFrame(raf2);
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ describe("getNotebookFrame", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits a deterministic cursor click pulse for each tab", () => {
|
it("emits a deterministic cursor click pulse for each tab", () => {
|
||||||
const first = getNotebookFrame(0.447, 4);
|
const first = getNotebookFrame(0.475, 4);
|
||||||
const later = getNotebookFrame(0.53, 4);
|
const later = getNotebookFrame(0.56, 4);
|
||||||
|
|
||||||
expect(first.cursorClicking).toBe(true);
|
expect(first.cursorClicking).toBe(true);
|
||||||
expect(later.cursorClicking).toBe(true);
|
expect(later.cursorClicking).toBe(true);
|
||||||
@@ -75,6 +75,17 @@ describe("getNotebookFrame", () => {
|
|||||||
expect(first.cursorY).toBeLessThanOrEqual(1);
|
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", () => {
|
it("is deterministic when rewound to the same progress", () => {
|
||||||
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
|
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function getCursorPoint(
|
|||||||
progress: number,
|
progress: number,
|
||||||
phase: NotebookPhase,
|
phase: NotebookPhase,
|
||||||
activeTabIndex: number,
|
activeTabIndex: number,
|
||||||
|
tabCount: number,
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
if (phase === "cursor-in") {
|
if (phase === "cursor-in") {
|
||||||
const cursorProgress = (progress - 0.34) / 0.1;
|
const cursorProgress = (progress - 0.34) / 0.1;
|
||||||
@@ -94,8 +95,19 @@ function getCursorPoint(
|
|||||||
return { x: 0.06, y: 0.88 };
|
return { x: 0.06, y: 0.88 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
|
const target = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
|
||||||
return point;
|
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(
|
function isCursorClicking(
|
||||||
@@ -124,7 +136,7 @@ function isCursorClicking(
|
|||||||
const browseProgress =
|
const browseProgress =
|
||||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||||
const slotProgress = (browseProgress * tabCount) % 1;
|
const slotProgress = (browseProgress * tabCount) % 1;
|
||||||
return slotProgress < 0.12;
|
return slotProgress >= 0.36 && slotProgress < 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotebookFrame(
|
export function getNotebookFrame(
|
||||||
@@ -173,6 +185,7 @@ export function getNotebookFrame(
|
|||||||
normalizedProgress,
|
normalizedProgress,
|
||||||
phase,
|
phase,
|
||||||
activeTabIndex,
|
activeTabIndex,
|
||||||
|
normalizedTabCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+73
-8
@@ -835,7 +835,7 @@ textarea {
|
|||||||
|
|
||||||
.notebook-story-runway {
|
.notebook-story-runway {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 520vh;
|
min-height: var(--notebook-runway, 520vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notebook-story-sticky {
|
.notebook-story-sticky {
|
||||||
@@ -1023,11 +1023,12 @@ textarea {
|
|||||||
.notebook-tab {
|
.notebook-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
padding: 14px 10px;
|
padding: 10px;
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom-width: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(244, 242, 237, 0.48);
|
color: rgba(244, 242, 237, 0.48);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1104,20 +1105,39 @@ textarea {
|
|||||||
|
|
||||||
.notebook-panel-copy a {
|
.notebook-panel-copy a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
min-height: 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: var(--sp-5);
|
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-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notebook-panel-copy a:hover {
|
.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 {
|
.notebook-screenshot-wrap {
|
||||||
@@ -1132,7 +1152,49 @@ textarea {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.notebook-cursor {
|
||||||
@@ -1228,7 +1290,10 @@ textarea {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.notebook-tab,
|
.notebook-tab,
|
||||||
.notebook-cursor {
|
.notebook-cursor,
|
||||||
|
.notebook-screenshot-current,
|
||||||
|
.notebook-screenshot-outgoing {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user