feat: implement Research Project PageView (v1.8) with static SSG pre-render and E2E gates
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import { researchProjects } from "../../content/projects";
|
||||
import ProjectPageViewControls from "./ProjectPageViewControls";
|
||||
|
||||
export function ProjectPageView() {
|
||||
const slugs = researchProjects.map((p) => p.slug);
|
||||
const trackId = "research-projects-track";
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="h-px w-8 bg-vermillion"></span>
|
||||
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
|
||||
Key Initiatives
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
|
||||
주요 연구 프로젝트 (Research Projects)
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-ink-mute font-mono">
|
||||
총 {researchProjects.length}개 핵심 국제/국내 표준화 연구 과제
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
|
||||
<div
|
||||
id={trackId}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="연구 프로젝트 목록"
|
||||
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-vermillion/50 rounded"
|
||||
>
|
||||
{researchProjects.map((proj, idx) => (
|
||||
<article
|
||||
key={proj.slug}
|
||||
id={`project-${proj.slug}`}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${researchProjects.length}개 중 ${idx + 1}번째 프로젝트`}
|
||||
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-vermillion/60 transition-colors space-y-5 break-words [word-break:keep-all]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Header Info */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line/60 pb-3">
|
||||
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
|
||||
{proj.title}
|
||||
</h3>
|
||||
{proj.period && (
|
||||
<span className="font-mono text-xs text-vermillion bg-ivory border border-line px-2 py-0.5 rounded shrink-0">
|
||||
{proj.period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Abstract */}
|
||||
<p className="text-sm leading-relaxed text-ink/80">
|
||||
{proj.abstract}
|
||||
</p>
|
||||
|
||||
{/* Keywords */}
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{proj.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="font-mono text-[0.72rem] text-ink-mute bg-ivory border border-line px-2 py-0.5 rounded"
|
||||
>
|
||||
#{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Footer: Standards Track & Deliverables */}
|
||||
<div className="space-y-3 pt-3 border-t border-line/60 text-xs font-mono">
|
||||
{/* Standards Track (or Funder if present, never placeholder if undefined) */}
|
||||
{proj.funder ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Funder:</span>
|
||||
<span className="text-ink font-semibold">{proj.funder}</span>
|
||||
</div>
|
||||
) : proj.standardsTrack ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Standards Track:</span>
|
||||
<span className="text-cobalt font-bold">{proj.standardsTrack}</span>
|
||||
</div>
|
||||
) : proj.standardsOrg ? (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-ink-mute">Organization:</span>
|
||||
<span className="text-cobalt font-bold">{proj.standardsOrg}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Key Deliverables */}
|
||||
{proj.deliverables.length > 0 && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<span className="text-ink-mute block">Key Deliverables:</span>
|
||||
<ul className="space-y-1">
|
||||
{proj.deliverables.slice(0, 3).map((item, dIdx) => (
|
||||
<li
|
||||
key={dIdx}
|
||||
className="flex items-start gap-1.5 text-[0.75rem] text-ink/90"
|
||||
>
|
||||
<span className="text-vermillion font-bold">•</span>
|
||||
<span className="line-clamp-2 leading-tight">
|
||||
<strong className="font-semibold text-ink">{item.ref}</strong> — {item.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{proj.deliverables.length > 3 && (
|
||||
<li className="text-[0.7rem] text-ink-mute italic">
|
||||
+외 {proj.deliverables.length - 3}건의 규격 문서
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Interactive Controls (Hidden on desktop) */}
|
||||
<ProjectPageViewControls slugs={slugs} trackId={trackId} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectPageView;
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface ProjectPageViewControlsProps {
|
||||
slugs: string[];
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
export default function ProjectPageViewControls({
|
||||
slugs,
|
||||
trackId,
|
||||
}: ProjectPageViewControlsProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const track = document.getElementById(trackId);
|
||||
if (!track) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = track;
|
||||
setCanScrollLeft(scrollLeft > 5);
|
||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 5);
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id;
|
||||
const index = slugs.findIndex((s) => `project-${s}` === id);
|
||||
if (index !== -1) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: track, threshold: 0.6 }
|
||||
);
|
||||
|
||||
slugs.forEach((slug) => {
|
||||
const el = document.getElementById(`project-${slug}`);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
|
||||
track.addEventListener("scroll", checkScroll, { passive: true });
|
||||
checkScroll();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
track.removeEventListener("scroll", checkScroll);
|
||||
};
|
||||
}, [slugs, trackId]);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
const track = document.getElementById(trackId);
|
||||
if (!track) return;
|
||||
const cardWidth = track.firstElementChild?.clientWidth || 300;
|
||||
const delta = direction === "left" ? -(cardWidth + 24) : cardWidth + 24;
|
||||
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
track.scrollBy({
|
||||
left: delta,
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-line desktop:hidden">
|
||||
{/* Indicator dots using semantic anchor links */}
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="프로젝트 위치">
|
||||
{slugs.map((slug, idx) => (
|
||||
<a
|
||||
key={slug}
|
||||
href={`#project-${slug}`}
|
||||
role="tab"
|
||||
aria-selected={activeIndex === idx}
|
||||
aria-label={`${slugs.length}개 중 ${idx + 1}번째 프로젝트로 이동`}
|
||||
className={`h-2.5 rounded-full transition-all duration-300 ${
|
||||
activeIndex === idx
|
||||
? "w-8 bg-vermillion"
|
||||
: "w-2.5 bg-line hover:bg-ink-mute"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll("left")}
|
||||
disabled={!canScrollLeft}
|
||||
aria-label="이전 프로젝트"
|
||||
aria-controls={trackId}
|
||||
className="inline-flex items-center justify-center w-9 h-9 border border-line bg-paper text-ink transition hover:border-vermillion hover:text-vermillion disabled:opacity-30 disabled:pointer-events-none rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll("right")}
|
||||
disabled={!canScrollRight}
|
||||
aria-label="다음 프로젝트"
|
||||
aria-controls={trackId}
|
||||
className="inline-flex items-center justify-center w-9 h-9 border border-line bg-paper text-ink transition hover:border-vermillion hover:text-vermillion disabled:opacity-30 disabled:pointer-events-none rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import React from "react";
|
||||
import HeroComposition from "./_components/HeroComposition";
|
||||
import { DirectionsBlock } from "./_components/DirectionsBlock";
|
||||
import { ProjectPageView } from "./_components/ProjectPageView";
|
||||
import { publications, patents } from "../content/publications";
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
no: "01",
|
||||
en: "Agent Core Engineering",
|
||||
ko: "AI 에이전트 3대 핵심 엔지니어링",
|
||||
theme: "vermillion" as const,
|
||||
topics: [
|
||||
"프롬프트 엔지니어링 (System Prompt Optimization)",
|
||||
"맥락 엔지니어링 (Dynamic Context & Memory Packaging)",
|
||||
"하네스 엔지니어링 (Agent Control Loop & Sandbox Isolation)",
|
||||
],
|
||||
desc: "AI 에이전트의 구동 성능과 안전성을 좌우하는 프롬프트·맥락·하네스 3대 핵심 엔지니어링 기술을 연구합니다.",
|
||||
},
|
||||
{
|
||||
no: "02",
|
||||
en: "Extensibility & Protocol",
|
||||
ko: "AI Agent 기능 확장 프로토콜",
|
||||
theme: "vermillion" as const,
|
||||
theme: "cobalt" as const,
|
||||
topics: [
|
||||
"Model Context Protocol (MCP) 서버/클라이언트 아키텍처",
|
||||
"SKILL 도구 캡슐화 및 재사용 패키징",
|
||||
@@ -17,10 +30,10 @@ const PILLARS = [
|
||||
desc: "에이전트가 외부 도구 및 시스템과 표준화된 방식으로 연동할 수 있도록 MCP와 SKILL 패키징 기술을 개발합니다.",
|
||||
},
|
||||
{
|
||||
no: "02",
|
||||
no: "03",
|
||||
en: "Multi-Agent Systems",
|
||||
ko: "멀티 에이전트 협업 및 오케스트레이션",
|
||||
theme: "cobalt" as const,
|
||||
theme: "vermillion" as const,
|
||||
topics: [
|
||||
"HERDR 백그라운드 프로세스 컨테이너 격리 제어",
|
||||
"crewAI 기반 역할 분담 및 태스크 위임 체계",
|
||||
@@ -29,10 +42,10 @@ const PILLARS = [
|
||||
desc: "다중 에이전트 환경에서 독립적인 역할 분담과 선순환 오케스트레이션을 보장하는 자율 협업 플랫폼을 연구합니다.",
|
||||
},
|
||||
{
|
||||
no: "03",
|
||||
no: "04",
|
||||
en: "Interoperability Standards",
|
||||
ko: "에이전트 상호운용성 개방형 표준",
|
||||
theme: "vermillion" as const,
|
||||
theme: "cobalt" as const,
|
||||
topics: [
|
||||
"Agent-to-Agent (A2A) 프로토콜 규격 정의 및 검증",
|
||||
"Agent Communication Protocol (ACP) 메시지 교환 규격",
|
||||
@@ -41,10 +54,10 @@ const PILLARS = [
|
||||
desc: "서로 다른 프레임워크 기반의 에이전트 간의 상호운용성을 보장하는 국제 표준 개방형 통신 프로토콜을 구현합니다.",
|
||||
},
|
||||
{
|
||||
no: "04",
|
||||
no: "05",
|
||||
en: "High-Performance Backbone",
|
||||
ko: "AIoT & 고성능 통신 백본",
|
||||
theme: "cobalt" as const,
|
||||
theme: "vermillion" as const,
|
||||
topics: [
|
||||
"gRPC / QUIC 양방향 스트리밍 에이전트 백본 통신",
|
||||
"Protocol Buffers (Protobuf) 직렬화 통신 최적화",
|
||||
@@ -115,6 +128,9 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Research Project PageView Component */}
|
||||
<ProjectPageView />
|
||||
|
||||
{/* Core Research Areas Grid */}
|
||||
<section className="space-y-8 pt-8 border-t border-line">
|
||||
<div className="flex justify-between items-end">
|
||||
|
||||
Reference in New Issue
Block a user