feat: complete ANL lab homepage with light/dark theme, responsive layout, and 100% SSG E2E test suite

This commit is contained in:
2026-07-30 20:15:40 +09:00
parent d833252217
commit 36a4cd851e
20 changed files with 1266 additions and 105 deletions
@@ -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"
+72 -7
View File
@@ -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;
+18 -33
View File
@@ -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>
+30
View File
@@ -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>
);
}
+53 -41
View File
@@ -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
View File
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
outputFileTracing: false,
async redirects() {
return [
{
+1
View File
@@ -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"
+188
View File
@@ -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()
+28 -18
View File
@@ -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()