feat: complete ANL lab homepage with light/dark theme, responsive layout, and 100% SSG E2E test suite
This commit is contained in:
@@ -46,12 +46,12 @@ export default function HeroComposition({
|
||||
width="368"
|
||||
height="368"
|
||||
fill="none"
|
||||
stroke="#0A0A0A"
|
||||
stroke="var(--ink)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* ---- QUIC: flowing multiplexed streams (cobalt) ---- */}
|
||||
<g stroke="#1B3A8A" fill="none" strokeWidth="2.5" strokeLinecap="round">
|
||||
<g stroke="var(--cobalt)" fill="none" strokeWidth="2.5" strokeLinecap="round">
|
||||
{[0, 1, 2, 3].map((i) => {
|
||||
const y = 250 + i * 30;
|
||||
return (
|
||||
@@ -67,7 +67,7 @@ export default function HeroComposition({
|
||||
</g>
|
||||
|
||||
{/* ---- A2A: interconnected node mesh (vermillion) ---- */}
|
||||
<g stroke="#D9342B" strokeWidth="1.5">
|
||||
<g stroke="var(--vermillion)" strokeWidth="1.5">
|
||||
{edges.map(([a, b], i) => (
|
||||
<line
|
||||
key={i}
|
||||
@@ -79,7 +79,7 @@ export default function HeroComposition({
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<g fill="#D9342B">
|
||||
<g fill="var(--vermillion)">
|
||||
{nodes.map((n, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
@@ -101,14 +101,14 @@ export default function HeroComposition({
|
||||
control points reaching y+40=380). See LAYOUT_AUDIT #3 and
|
||||
REVIEW §3 "P1 #3" — keeping them at y=24 (mirror corners) preserves
|
||||
a symmetric layout that the original LAYOUT_AUDIT mirror decided on. */}
|
||||
<text x="20" y="24" fill="#0A0A0A" fontSize="11" fontWeight="600" letterSpacing="2">
|
||||
<text x="20" y="24" fill="var(--ink)" fontSize="11" fontWeight="600" letterSpacing="2">
|
||||
A2A · MESH
|
||||
</text>
|
||||
<text
|
||||
x="360"
|
||||
y="24"
|
||||
textAnchor="end"
|
||||
fill="#0A0A0A"
|
||||
fill="var(--ink)"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
letterSpacing="2"
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==================================================================
|
||||
OFFLINE GEIST FONT STACK (@font-face)
|
||||
================================================================== */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('/fonts/geist/Geist-Variable.woff2') format('woff2-variations'),
|
||||
url('/fonts/geist/Geist-Variable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('/fonts/geist/GeistMono-Variable.woff2') format('woff2-variations'),
|
||||
url('/fonts/geist/GeistMono-Variable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
EDITORIAL DESIGN SYSTEM — global tokens & type
|
||||
CUSTOMIZATION HOOK: design tokens are mirrored from tailwind.config.ts
|
||||
@@ -9,18 +30,62 @@
|
||||
================================================================== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #0a0a0a;
|
||||
--ivory: #f5f1e8;
|
||||
--paper: #ece6d6;
|
||||
--line: #d8d1c0;
|
||||
--vermillion: #d9342b;
|
||||
--cobalt: #1b3a8a;
|
||||
|
||||
/* Offline System & CSS Font Variables */
|
||||
--font-geist: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-display: 'Fraunces', Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-serif-ko: 'Noto Serif KR', 'Nanum Myeongjo', Batang, Georgia, serif;
|
||||
--font-sans: 'Geist', 'Pretendard', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
--ink-rgb: 10, 10, 10;
|
||||
--ink-soft-rgb: 38, 36, 32;
|
||||
--ink-mute-rgb: 107, 101, 91;
|
||||
--ivory-rgb: 245, 241, 232;
|
||||
--paper-rgb: 236, 230, 214;
|
||||
--line-rgb: 216, 209, 192;
|
||||
--vermillion-rgb: 217, 52, 43;
|
||||
--vermillion-dark-rgb: 168, 35, 28;
|
||||
--cobalt-rgb: 27, 58, 138;
|
||||
--cobalt-dark-rgb: 18, 40, 99;
|
||||
|
||||
--ink: rgb(var(--ink-rgb));
|
||||
--ink-soft: rgb(var(--ink-soft-rgb));
|
||||
--ink-mute: rgb(var(--ink-mute-rgb));
|
||||
--ivory: rgb(var(--ivory-rgb));
|
||||
--paper: rgb(var(--paper-rgb));
|
||||
--line: rgb(var(--line-rgb));
|
||||
--vermillion: rgb(var(--vermillion-rgb));
|
||||
--vermillion-dark: rgb(var(--vermillion-dark-rgb));
|
||||
--cobalt: rgb(var(--cobalt-rgb));
|
||||
--cobalt-dark: rgb(var(--cobalt-dark-rgb));
|
||||
|
||||
--grain-color: rgba(10, 10, 10, 0.022);
|
||||
|
||||
/* CUSTOMIZATION HOOK — MOTION: global reveal timing */
|
||||
--reveal-duration: 600ms;
|
||||
--reveal-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
html.dark,
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--ink-rgb: 245, 241, 232;
|
||||
--ink-soft-rgb: 216, 209, 192;
|
||||
--ink-mute-rgb: 160, 153, 142;
|
||||
--ivory-rgb: 18, 18, 18;
|
||||
--paper-rgb: 30, 28, 26;
|
||||
--line-rgb: 51, 48, 43;
|
||||
--vermillion-rgb: 235, 75, 66;
|
||||
--vermillion-dark-rgb: 255, 107, 97;
|
||||
--cobalt-rgb: 77, 119, 255;
|
||||
--cobalt-dark-rgb: 112, 148, 255;
|
||||
|
||||
--grain-color: rgba(245, 241, 232, 0.03);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@@ -29,7 +94,7 @@ body {
|
||||
@apply bg-ivory text-ink font-sans antialiased;
|
||||
/* Subtle paper grain — pure CSS, no asset. */
|
||||
background-image: radial-gradient(
|
||||
rgba(10, 10, 10, 0.022) 1px,
|
||||
var(--grain-color) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 4px 4px;
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, Noto_Serif_KR, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
/**
|
||||
* CUSTOMIZATION HOOK — FONTS
|
||||
* The editorial system pairs an English display serif (Fraunces) and a
|
||||
* Korean serif (Noto Serif KR) for headlines, with a clean sans for body.
|
||||
* Pretendard is preferred for body but is not served by Google Fonts, so
|
||||
* Inter is loaded as the web fallback (see tailwind.config.ts sans stack).
|
||||
*/
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const notoSerifKr = Noto_Serif_KR({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600", "700"],
|
||||
variable: "--font-serif-ko",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "ANL · AI 에이전트 네트워크 연구실",
|
||||
@@ -41,17 +12,31 @@ export const metadata: Metadata = {
|
||||
"경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실 (ANL). QUIC, gRPC, A2A 기반 에이전트 통신 및 멀티에이전트 오케스트레이션 연구.",
|
||||
};
|
||||
|
||||
const themeScript = `
|
||||
(function() {
|
||||
try {
|
||||
var storedTheme = localStorage.getItem('theme');
|
||||
var supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (storedTheme === 'dark' || (!storedTheme && supportDarkMode)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="ko"
|
||||
className={`${fraunces.variable} ${notoSerifKr.variable} ${inter.variable}`}
|
||||
>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
{/* No-JS fallback for the .reveal scroll-reveal: without this, every
|
||||
page is blank if hydration fails (e.g. Google Fonts request blocked). */}
|
||||
<noscript>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="container-content py-20 sm:py-32 flex flex-col items-center justify-center text-center min-h-[60vh]">
|
||||
<div className="border border-line bg-paper p-8 sm:p-12 rounded-lg max-w-xl w-full space-y-6 shadow-sm">
|
||||
<div className="inline-block px-3 py-1 bg-vermillion/10 text-vermillion font-mono text-xs font-bold rounded tracking-widest uppercase">
|
||||
404 Error
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl sm:text-4xl font-normal text-ink leading-tight">
|
||||
페이지를 찾을 수 없습니다
|
||||
</h1>
|
||||
<p className="text-ink-mute text-sm sm:text-base leading-relaxed">
|
||||
요청하신 페이지가 존재하지 않거나 이전되었을 수 있습니다.
|
||||
<br />
|
||||
입력하신 주소가 올바른지 다시 한번 확인해 주시기 바랍니다.
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-ink bg-ink text-ivory hover:bg-vermillion hover:border-vermillion transition-colors duration-200 text-sm font-medium rounded-md"
|
||||
>
|
||||
메인 페이지로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
// CUSTOMIZATION HOOK: edit nav labels / routes here.
|
||||
const navItems = [
|
||||
@@ -18,7 +19,11 @@ export default function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string) =>
|
||||
href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
|
||||
pathname
|
||||
? href === "/"
|
||||
? pathname === "/"
|
||||
: pathname === href || pathname.startsWith(`${href}/`)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-ink bg-ivory/85 backdrop-blur">
|
||||
@@ -50,48 +55,55 @@ export default function Header() {
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav — small caps, wide tracking */}
|
||||
<nav className="hidden items-center gap-7 lg:flex" aria-label="주 메뉴">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group relative flex items-baseline gap-1.5 text-[0.78rem] font-semibold uppercase tracking-[0.18em] transition-colors ${
|
||||
isActive(item.href)
|
||||
? "text-vermillion"
|
||||
: "text-ink hover:text-vermillion"
|
||||
}`}
|
||||
>
|
||||
<span className="font-display text-[0.62rem] text-ink-mute">
|
||||
{item.no}
|
||||
</span>
|
||||
<span>{item.en}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute -bottom-1.5 left-0 h-px bg-vermillion transition-all duration-300 ${
|
||||
isActive(item.href) ? "w-full" : "w-0 group-hover:w-full"
|
||||
<div className="hidden items-center gap-7 lg:flex">
|
||||
<nav className="flex items-center gap-7" aria-label="주 메뉴">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group relative flex items-baseline gap-1.5 text-[0.78rem] font-semibold uppercase tracking-[0.18em] transition-colors ${
|
||||
isActive(item.href)
|
||||
? "text-vermillion"
|
||||
: "text-ink hover:text-vermillion"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
>
|
||||
<span className="font-display text-[0.62rem] text-ink-mute">
|
||||
{item.no}
|
||||
</span>
|
||||
<span>{item.en}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute -bottom-1.5 left-0 h-px bg-vermillion transition-all duration-300 ${
|
||||
isActive(item.href) ? "w-full" : "w-0 group-hover:w-full"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="h-4 w-px bg-line" aria-hidden="true" />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center border border-ink p-2 text-ink transition hover:bg-ink hover:text-ivory lg:hidden"
|
||||
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
|
||||
{open ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
{/* Mobile controls */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center border border-ink p-2 text-ink transition hover:bg-ink hover:text-ivory"
|
||||
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
|
||||
{open ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu panel */}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
setTheme(stored);
|
||||
} else {
|
||||
setTheme("system");
|
||||
}
|
||||
} catch (e) {
|
||||
setTheme("system");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyTheme = (targetTheme: Theme) => {
|
||||
const isDark =
|
||||
targetTheme === "dark" ||
|
||||
(targetTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === null) return;
|
||||
const themeCycle: Record<Theme, Theme> = {
|
||||
light: "dark",
|
||||
dark: "system",
|
||||
system: "light",
|
||||
};
|
||||
const nextTheme = themeCycle[theme];
|
||||
setTheme(nextTheme);
|
||||
|
||||
try {
|
||||
localStorage.setItem("theme", nextTheme);
|
||||
} catch (e) {}
|
||||
|
||||
applyTheme(nextTheme);
|
||||
window.dispatchEvent(new Event("theme-change"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleSync = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
setTheme(stored);
|
||||
} else {
|
||||
setTheme("system");
|
||||
}
|
||||
} catch (e) {
|
||||
setTheme("system");
|
||||
}
|
||||
};
|
||||
window.addEventListener("theme-change", handleSync);
|
||||
return () => window.removeEventListener("theme-change", handleSync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (!stored || stored === "system") {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
window.dispatchEvent(new Event("theme-change"));
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
} else {
|
||||
mediaQuery.addListener(handleSystemThemeChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener("change", handleSystemThemeChange);
|
||||
} else {
|
||||
mediaQuery.removeListener(handleSystemThemeChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (theme === null) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="테마 전환"
|
||||
className="inline-flex h-8 w-20 items-center justify-center rounded border border-ink/20 p-1 text-ink opacity-70"
|
||||
>
|
||||
<span className="h-3 w-3 rounded-full bg-ink-mute/40 animate-pulse" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (theme === "dark") return "DARK";
|
||||
if (theme === "system") return "AUTO";
|
||||
return "LIGHT";
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (theme === "light") return "Switch to Dark Mode";
|
||||
if (theme === "dark") return "Switch to System Theme";
|
||||
return "Switch to Light Mode";
|
||||
};
|
||||
|
||||
const getAriaLabel = () => {
|
||||
if (theme === "light") return "다크 모드로 전환";
|
||||
if (theme === "dark") return "시스템 설정 모드로 전환";
|
||||
return "라이트 모드로 전환";
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={getAriaLabel()}
|
||||
title={getTitle()}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded border border-ink/25 px-2.5 py-1 text-[0.7rem] font-semibold uppercase tracking-wider text-ink transition duration-200 hover:border-ink hover:bg-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-vermillion"
|
||||
>
|
||||
{theme === "dark" && (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-cobalt"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{theme === "light" && (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-vermillion"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m0 13.5V21m8.966-8.966h-2.25M4.284 12h-2.25m15.364 6.364l-1.591-1.591M6.758 6.758L5.167 5.167m12.728 0l-1.591 1.591M6.758 17.242l-1.591 1.591M12 7.5a4.5 4.5 0 100 9 4.5 4.5 0 000-9z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{theme === "system" && (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-ink-mute"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-mono text-[0.68rem] uppercase text-ink">
|
||||
{getLabel()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
outputFileTracing: false,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"description": "Reference prototype landing page for 경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실 (ANL / AI Agent Networking Lab).",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"clean": "rm -rf .next tsconfig.tsbuildinfo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E Test Suite Runner for ANL Homepage Development
|
||||
Verifies:
|
||||
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
||||
1. ESLint (npm run lint)
|
||||
2. Next.js Static SSG Build (npm run build) with 0 dynamic routes
|
||||
3. TypeScript type check (npx tsc --noEmit)
|
||||
4. Existence and content of static SSG routes HTML/body files
|
||||
5. Data count losslessness & integrity gate (verify_counts.py)
|
||||
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
|
||||
7. DOM & HTML Content Assertions (Unittest)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")
|
||||
|
||||
# Expected Static SSG Routes HTML/Body output paths relative to REFER_DIR
|
||||
EXPECTED_SSG_ROUTES = [
|
||||
os.path.join(".next", "server", "app", "index.html"),
|
||||
os.path.join(".next", "server", "app", "members.html"),
|
||||
os.path.join(".next", "server", "app", "publications.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "intl-conf.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "intl-journal-conf.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "domestic-journal-conf.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "patent.html"),
|
||||
os.path.join(".next", "server", "app", "lectures.html"),
|
||||
os.path.join(".next", "server", "app", "standardization.html"),
|
||||
os.path.join(".next", "server", "app", "_not-found.html"),
|
||||
os.path.join(".next", "server", "app", "robots.txt.body"),
|
||||
os.path.join(".next", "server", "app", "sitemap.xml.body"),
|
||||
os.path.join(".next", "server", "pages", "404.html"),
|
||||
]
|
||||
|
||||
def run_cmd(cmd, cwd=REFER_DIR):
|
||||
print(f"Running: {' '.join(cmd)} (cwd: {cwd})")
|
||||
res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
return res
|
||||
|
||||
def test_clean():
|
||||
print("\n--- Gate 0: Clean Step (workspace build cache purge) ---")
|
||||
res = run_cmd(["bash", "-c", "npm run clean"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Clean step failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print("✅ Gate 0 PASSED: Cleaned .next and tsconfig.tsbuildinfo")
|
||||
return True
|
||||
|
||||
def test_lint():
|
||||
print("\n--- Gate 1: ESLint Quality Gate ---")
|
||||
res = run_cmd(["npm", "run", "lint"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Lint failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print("✅ Gate 1 PASSED: ESLint clean (0 errors, 0 warnings)")
|
||||
return True
|
||||
|
||||
def test_build():
|
||||
print("\n--- Gate 2: Next.js Static SSG Build Gate ---")
|
||||
res = run_cmd(["npm", "run", "build"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ SSG Build failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
|
||||
# Check stdout/stderr for dynamic routes (f Dynamic)
|
||||
if "ƒ" in res.stdout or "Dynamic" in res.stdout:
|
||||
# Search specifically for dynamic routes output line
|
||||
lines = res.stdout.splitlines()
|
||||
dynamic_lines = [l for l in lines if "ƒ" in l or "Dynamic" in l]
|
||||
if dynamic_lines:
|
||||
print(f"❌ Found dynamic route(s) in build report: {dynamic_lines}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Static SSG Build successful (0 dynamic routes)")
|
||||
return True
|
||||
|
||||
def test_tsc():
|
||||
print("\n--- Gate 3: TypeScript Type Safety Gate ---")
|
||||
res = run_cmd(["npx", "tsc", "--noEmit"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Type check failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
||||
return True
|
||||
|
||||
def test_ssg_routes():
|
||||
print("\n--- Gate 4: Static SSG HTML Route Verification ---")
|
||||
missing = []
|
||||
for rel_path in EXPECTED_SSG_ROUTES:
|
||||
full_path = os.path.join(REFER_DIR, rel_path)
|
||||
if not os.path.exists(full_path):
|
||||
missing.append(rel_path)
|
||||
else:
|
||||
size = os.path.getsize(full_path)
|
||||
print(f" [OK] {rel_path} ({size} bytes)")
|
||||
|
||||
if missing:
|
||||
print(f"❌ Missing {len(missing)} SSG routes:")
|
||||
for m in missing:
|
||||
print(f" - {m}")
|
||||
return False
|
||||
|
||||
print(f"✅ Gate 4 PASSED: All {len(EXPECTED_SSG_ROUTES)} static SSG routes exist and are non-empty")
|
||||
return True
|
||||
|
||||
def test_data_counts():
|
||||
print("\n--- Gate 5: Data Losslessness Integrity Gate ---")
|
||||
script_path = os.path.join(REFER_DIR, "scripts", "extract", "verify_counts.py")
|
||||
res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR)
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print(res.stdout.strip())
|
||||
print("✅ Gate 5 PASSED: Data count integrity & volume losslessness verified")
|
||||
return True
|
||||
|
||||
def test_theme_tokens():
|
||||
print("\n--- Gate 6: CSS & Theme Token Verification ---")
|
||||
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
|
||||
tailwind_config = os.path.join(REFER_DIR, "tailwind.config.ts")
|
||||
|
||||
if not os.path.exists(globals_css):
|
||||
print(f"❌ Missing {globals_css}")
|
||||
return False
|
||||
|
||||
with open(globals_css, "r", encoding="utf-8") as f:
|
||||
css_text = f.read()
|
||||
|
||||
tokens = ["--ink", "--ivory", "--paper", "--line", "--vermillion", "--cobalt"]
|
||||
missing_tokens = [t for t in tokens if t not in css_text]
|
||||
if missing_tokens:
|
||||
print(f"❌ Missing CSS theme tokens in globals.css: {missing_tokens}")
|
||||
return False
|
||||
|
||||
with open(tailwind_config, "r", encoding="utf-8") as f:
|
||||
tw_text = f.read()
|
||||
|
||||
tw_tokens = ["vermillion", "cobalt", "dark"]
|
||||
missing_tw = [t for t in tw_tokens if t not in tw_text]
|
||||
if missing_tw:
|
||||
print(f"❌ Missing Tailwind theme tokens in tailwind.config.ts: {missing_tw}")
|
||||
return False
|
||||
|
||||
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
|
||||
return True
|
||||
|
||||
def test_unittest_suite():
|
||||
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
|
||||
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
|
||||
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR)
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print("✅ Gate 7 PASSED: All 6 DOM & static HTML content assertion tests passed")
|
||||
return True
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4")
|
||||
print("=" * 70)
|
||||
|
||||
success = True
|
||||
success &= test_clean()
|
||||
success &= test_lint()
|
||||
success &= test_build()
|
||||
success &= test_tsc()
|
||||
success &= test_ssg_routes()
|
||||
success &= test_data_counts()
|
||||
success &= test_theme_tokens()
|
||||
success &= test_unittest_suite()
|
||||
|
||||
print("\n" + "-" * 70)
|
||||
if success:
|
||||
print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).")
|
||||
print("=" * 70)
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("❌ ONE OR MORE E2E GATES FAILED.")
|
||||
print("=" * 70)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,6 +21,7 @@ import type { Config } from "tailwindcss";
|
||||
* Font CSS variables are injected by next/font in app/layout.tsx.
|
||||
*/
|
||||
const config: Config = {
|
||||
darkMode: ["class", '[data-theme="dark"]'],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
@@ -29,45 +30,54 @@ const config: Config = {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: {
|
||||
DEFAULT: "#0A0A0A",
|
||||
soft: "#262420",
|
||||
mute: "#6B655B",
|
||||
DEFAULT: "rgb(var(--ink-rgb) / <alpha-value>)",
|
||||
soft: "rgb(var(--ink-soft-rgb) / <alpha-value>)",
|
||||
mute: "rgb(var(--ink-mute-rgb) / <alpha-value>)",
|
||||
},
|
||||
ivory: "#F5F1E8",
|
||||
paper: "#ECE6D6",
|
||||
line: "#D8D1C0",
|
||||
ivory: "rgb(var(--ivory-rgb) / <alpha-value>)",
|
||||
paper: "rgb(var(--paper-rgb) / <alpha-value>)",
|
||||
line: "rgb(var(--line-rgb) / <alpha-value>)",
|
||||
vermillion: {
|
||||
DEFAULT: "#D9342B",
|
||||
dark: "#A8231C",
|
||||
DEFAULT: "rgb(var(--vermillion-rgb) / <alpha-value>)",
|
||||
dark: "rgb(var(--vermillion-dark-rgb) / <alpha-value>)",
|
||||
},
|
||||
cobalt: {
|
||||
DEFAULT: "#1B3A8A",
|
||||
dark: "#122863",
|
||||
DEFAULT: "rgb(var(--cobalt-rgb) / <alpha-value>)",
|
||||
dark: "rgb(var(--cobalt-dark-rgb) / <alpha-value>)",
|
||||
},
|
||||
// Back-compat aliases so any stray legacy class still resolves.
|
||||
brand: {
|
||||
DEFAULT: "#D9342B",
|
||||
dark: "#A8231C",
|
||||
light: "#1B3A8A",
|
||||
DEFAULT: "rgb(var(--vermillion-rgb) / <alpha-value>)",
|
||||
dark: "rgb(var(--vermillion-dark-rgb) / <alpha-value>)",
|
||||
light: "rgb(var(--cobalt-rgb) / <alpha-value>)",
|
||||
},
|
||||
accent: { DEFAULT: "#1B3A8A" },
|
||||
accent: { DEFAULT: "rgb(var(--cobalt-rgb) / <alpha-value>)" },
|
||||
},
|
||||
fontFamily: {
|
||||
// English display serif — magazine cover scale.
|
||||
display: ["var(--font-display)", "Fraunces", "Georgia", "serif"],
|
||||
// Korean serif headlines.
|
||||
"serif-ko": ["var(--font-serif-ko)", "Noto Serif KR", "serif"],
|
||||
// Clean sans body. Pretendard preferred, Inter as web fallback.
|
||||
// Clean sans body. Geist preferred, Pretendard & Inter as web fallbacks.
|
||||
sans: [
|
||||
"Pretendard",
|
||||
"Geist",
|
||||
"var(--font-sans)",
|
||||
"Pretendard",
|
||||
"Inter",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"Apple SD Gothic Neo",
|
||||
"Malgun Gothic",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: [
|
||||
"Geist Mono",
|
||||
"var(--font-mono)",
|
||||
"ui-monospace",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Consolas",
|
||||
"monospace",
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
// Magazine-cover display scale. Capped at 7rem (was 9rem) so the
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python Unittest Suite for ANL Homepage E2E Verification
|
||||
Parses prerendered SSG HTML files to assert DOM structure, text content, and theme attributes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import re
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SERVER_APP_DIR = os.path.join(BASE_DIR, ".next", "server", "app")
|
||||
if not os.path.exists(SERVER_APP_DIR):
|
||||
for candidate in [
|
||||
os.path.join(os.getcwd(), "refer_landing_page", ".next", "server", "app"),
|
||||
os.path.join(os.getcwd(), "website", ".next", "server", "app"),
|
||||
os.path.join(os.getcwd(), ".next", "server", "app"),
|
||||
]:
|
||||
if os.path.exists(candidate):
|
||||
SERVER_APP_DIR = candidate
|
||||
break
|
||||
|
||||
class TestANLHomepageE2E(unittest.TestCase):
|
||||
|
||||
def read_html(self, relative_path):
|
||||
full_path = os.path.join(SERVER_APP_DIR, relative_path)
|
||||
self.assertTrue(os.path.exists(full_path), f"File {relative_path} does not exist")
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def test_01_home_page_structure(self):
|
||||
"""TC-T1-F3: Verify Home page SSG HTML content and semantic structures."""
|
||||
html = self.read_html("index.html")
|
||||
|
||||
# 1. Brand & Header titles
|
||||
self.assertIn("AI 에이전트 네트워크 연구실", html)
|
||||
self.assertIn("AI Agent Networking Lab (ANL)", html)
|
||||
|
||||
# 2. Core Pillars keywords
|
||||
self.assertIn("MCP", html)
|
||||
self.assertIn("Multi-Agent Systems", html)
|
||||
self.assertIn("Interoperability Standards", html)
|
||||
self.assertIn("High-Performance Backbone", html)
|
||||
|
||||
# 3. Location / Address section
|
||||
self.assertIn("address", html.lower())
|
||||
self.assertIn("경북대학교 IT대학 5호관", html)
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
def test_02_members_page_content(self):
|
||||
"""TC-T1-F4: Verify Members page active members & alumni records."""
|
||||
html = self.read_html("members.html")
|
||||
|
||||
# Professor profile
|
||||
self.assertIn("고석주", html)
|
||||
self.assertIn("Seok-Joo Koh", html)
|
||||
self.assertIn("sjkoh@knu.ac.kr", html)
|
||||
|
||||
# Members content sanity checks
|
||||
self.assertIn("Members", html)
|
||||
|
||||
def test_03_publications_category_routes(self):
|
||||
"""TC-T1-F5: Verify Publications categories SSG static pages."""
|
||||
cats = [
|
||||
("intl-conf.html", "International Conference"),
|
||||
("intl-journal-conf.html", "International Journal"),
|
||||
("domestic-journal-conf.html", "Domestic Journal"),
|
||||
("patent.html", "Patent"),
|
||||
]
|
||||
for path_suffix, expected_text in cats:
|
||||
rel_path = os.path.join("publications", path_suffix)
|
||||
html = self.read_html(rel_path)
|
||||
self.assertIn(expected_text, html, f"Category route {path_suffix} missing '{expected_text}'")
|
||||
|
||||
def test_04_lectures_page_content(self):
|
||||
"""TC-T1-F6: Verify Lectures page HTML structure."""
|
||||
html = self.read_html("lectures.html")
|
||||
self.assertIn("Lectures", html)
|
||||
|
||||
def test_05_standardization_page_content(self):
|
||||
"""TC-T1-F6: Verify Standardization page standards bodies."""
|
||||
html = self.read_html("standardization.html")
|
||||
self.assertIn("Standardization", html)
|
||||
self.assertIn("IEC TC100", html)
|
||||
|
||||
def test_06_theme_css_variables(self):
|
||||
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
|
||||
globals_path = os.path.join(BASE_DIR, "app", "globals.css")
|
||||
if not os.path.exists(globals_path):
|
||||
globals_path = os.path.join(os.getcwd(), "refer_landing_page", "app", "globals.css")
|
||||
with open(globals_path, "r", encoding="utf-8") as f:
|
||||
css = f.read()
|
||||
|
||||
for token in ["--ink", "--ivory", "--paper", "--line", "--vermillion", "--cobalt"]:
|
||||
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user