feat(header): add smart sticky scroll animation and styling refinements
- Implement RAF-throttled smart sticky scroll interaction on app header - Add flow-root BFC isolation, vertical margin, and mobile menu scroll container - Update brand logo kicker hover color to primary accent (cobalt-dark) - Fix theme script hydration warning with suppressHydrationWarning - Add favicon.ico and icon.svg to resolve root /favicon.ico 404 - Add MAM orchestration guidelines, env generation script, and templates
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -172,6 +172,14 @@ body {
|
||||
.draw-underline:hover {
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
|
||||
/* Mobile menu scroll container for low-height / landscape viewports (F4) */
|
||||
.mobile-menu-panel {
|
||||
max-height: calc(100vh - 6rem); /* Fallback: browsers without dvh support */
|
||||
max-height: calc(100dvh - 6rem); /* Modern: dynamic viewport height */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* Prevent chaining scroll to main page */
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
@@ -217,6 +225,11 @@ body {
|
||||
[class*="animate-"] {
|
||||
animation: none !important;
|
||||
}
|
||||
/* 스마트 스티키: 모션 줄임 사용자에게는 항상 펼친 상태 유지 */
|
||||
header[data-smart-sticky] {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<defs>
|
||||
<linearGradient id="cobaltGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4E77FF"/>
|
||||
<stop offset="100%" stop-color="#2B4CDE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#cobaltGrad)"/>
|
||||
<!-- Network Agent Nodes & Graph -->
|
||||
<circle cx="16" cy="9" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="9" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="23" cy="22" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="16" cy="17" r="2" fill="#FFD666"/>
|
||||
|
||||
<path d="M16 9 L9 22 M16 9 L23 22 M9 22 L23 22 M16 9 L16 17 M9 22 L16 17 M23 22 L16 17"
|
||||
stroke="#FFFFFF" stroke-width="1.2" stroke-opacity="0.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
@@ -34,7 +34,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
{/* No-JS fallback for the .reveal scroll-reveal: without this, every
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
// CUSTOMIZATION HOOK — MOTION: 스마트 스티키 튜닝
|
||||
const SCROLL_DELTA = 6; // px — 방향 전환 인식 최소 이동량(지터 방지)
|
||||
const HIDE_AFTER = 140; // px — 이 지점 이전에는 숨기지 않음(실측 데스크톱 헤더 높이 122.8px + 여유)
|
||||
const NAV_SETTLE_MS = 300; // ms — 라우트 전환 직후 방향 판정 유예 구간
|
||||
|
||||
// CUSTOMIZATION HOOK: edit nav labels / routes here.
|
||||
const navItems = [
|
||||
{ href: "/", ko: "홈", en: "Home", no: "01" },
|
||||
@@ -16,8 +21,84 @@ const navItems = [
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const lastY = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
const navSettling = useRef(false);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
|
||||
useEffect(() => {
|
||||
// (A) 모션 줄임 사용자는 자동 숨김 자체를 비활성화 — DESIGN.md 접근성 원칙
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
lastY.current = window.scrollY;
|
||||
|
||||
const update = () => {
|
||||
ticking.current = false;
|
||||
const y = window.scrollY;
|
||||
|
||||
// (B) 최상단 + iOS 러버밴드(음수) 구간 → 항상 표시
|
||||
if (y <= 0) {
|
||||
setHidden(false);
|
||||
lastY.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// (F-2) 라우트 전환 정착 구간: 전환이 유발한 스크롤 점프는 방향 판정에서 제외.
|
||||
// 기준점만 따라가고 hidden은 건드리지 않는다.
|
||||
if (navSettling.current) {
|
||||
lastY.current = y;
|
||||
return;
|
||||
}
|
||||
|
||||
// (C) 모바일 메뉴가 열려 있으면 숨기지 않되, lastY는 계속 추적
|
||||
// (추적하지 않으면 메뉴를 닫는 순간 헤더가 튀며 사라짐)
|
||||
if (openRef.current) {
|
||||
setHidden(false);
|
||||
lastY.current = y;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = y - lastY.current;
|
||||
|
||||
// (D) 미세 이동 무시. 이때 lastY를 갱신하지 않고 보존
|
||||
if (Math.abs(delta) < SCROLL_DELTA) return;
|
||||
|
||||
if (delta > 0 && y > HIDE_AFTER) {
|
||||
setHidden(true); // 아래로 → 숨김
|
||||
} else if (delta < 0) {
|
||||
setHidden(false); // 위로 → 복귀
|
||||
}
|
||||
|
||||
lastY.current = y;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (ticking.current) return;
|
||||
ticking.current = true;
|
||||
requestAnimationFrame(update); // 프레임당 1회로 제한
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// (F) 라우트 전환 대응 — persistent layout이라 hidden이 페이지를 넘어 생존한다.
|
||||
// 스크롤 이벤트 발화 여부와 무관하게 표시 상태를 보장한다.
|
||||
useEffect(() => {
|
||||
setHidden(false);
|
||||
lastY.current = window.scrollY; // 현재 실제 위치로 동기화
|
||||
setOpen(false); // 뒤로/앞으로 이동 시 모바일 메뉴 잔존 방지
|
||||
navSettling.current = true;
|
||||
const id = window.setTimeout(() => {
|
||||
navSettling.current = false;
|
||||
}, NAV_SETTLE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [pathname]);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname
|
||||
? href === "/"
|
||||
@@ -26,7 +107,13 @@ export default function Header() {
|
||||
: false;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-ink bg-ivory/85 backdrop-blur">
|
||||
<header
|
||||
data-smart-sticky
|
||||
onFocusCapture={() => setHidden(false)}
|
||||
className={`flow-root sticky top-0 z-50 border-b border-ink bg-ivory/85 backdrop-blur transition-transform duration-300 ease-out ${
|
||||
hidden ? "-translate-y-full" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
{/* Journal issue strip — magazine masthead feel. */}
|
||||
<div className="hidden border-b border-line desktop:block">
|
||||
<div className="container-content flex items-center justify-between py-1.5">
|
||||
@@ -39,7 +126,7 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-content flex h-[4.25rem] items-center justify-between gap-4">
|
||||
<div className="container-content flex h-[4.25rem] items-center justify-between gap-4 my-2 sm:my-3">
|
||||
{/* Brand logo */}
|
||||
<Link
|
||||
href="/"
|
||||
@@ -49,7 +136,7 @@ export default function Header() {
|
||||
<span className="headline-ko text-lg text-ink sm:text-xl">
|
||||
AI 에이전트 네트워크 연구실
|
||||
</span>
|
||||
<span className="kicker mt-1 group-hover:text-vermillion">
|
||||
<span className="kicker mt-1 transition-colors group-hover:text-cobalt-dark">
|
||||
AI Agent Networking Lab (ANL)
|
||||
</span>
|
||||
</Link>
|
||||
@@ -112,7 +199,7 @@ export default function Header() {
|
||||
{open && (
|
||||
<nav
|
||||
id="mobile-menu"
|
||||
className="border-t border-ink bg-ivory desktop:hidden"
|
||||
className="mobile-menu-panel border-t border-ink bg-ivory desktop:hidden"
|
||||
aria-label="모바일 메뉴"
|
||||
>
|
||||
<ul className="container-content flex flex-col py-2">
|
||||
|
||||
Reference in New Issue
Block a user