From baeda39057a1a11606d02c2fb07db91d564760a7 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 23:52:34 +0900 Subject: [PATCH] feat: assemble responsive app shell navigation --- src/bootstrap/main.jsx | 13 +- src/contracts/routes.js | 63 +++- src/presentation/components/page-header.jsx | 27 ++ .../examples/auth-example-page.jsx | 80 +++++ .../examples/state-gallery-page.jsx | 24 ++ src/presentation/examples/ui-gallery-page.jsx | 20 ++ src/presentation/layouts/app-shell.jsx | 139 ++++++++ src/presentation/pages/home-page.jsx | 56 +++ src/presentation/pages/not-found-page.jsx | 20 ++ .../pages/sample-contract-page.jsx | 23 ++ .../providers/session-provider.jsx | 50 +++ src/presentation/routes/app-router.jsx | 276 ++++++++++++--- src/presentation/styles/theme.css | 326 +++++++++++++++++- tests/component/router.test.jsx | 60 +++- tests/e2e/accessibility.spec.js | 11 +- tests/e2e/app-shell.spec.js | 47 +++ tests/unit/navigation-policy.test.js | 63 +++- 17 files changed, 1238 insertions(+), 60 deletions(-) create mode 100644 src/presentation/components/page-header.jsx create mode 100644 src/presentation/examples/auth-example-page.jsx create mode 100644 src/presentation/examples/state-gallery-page.jsx create mode 100644 src/presentation/examples/ui-gallery-page.jsx create mode 100644 src/presentation/layouts/app-shell.jsx create mode 100644 src/presentation/pages/home-page.jsx create mode 100644 src/presentation/pages/not-found-page.jsx create mode 100644 src/presentation/pages/sample-contract-page.jsx create mode 100644 src/presentation/providers/session-provider.jsx diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index b8e5e38..deb5ddc 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,5 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx"; @@ -21,10 +22,14 @@ async function boot() { const composition = await createRuntimeComposition(); root.render( - + + + , ); } catch (error) { diff --git a/src/contracts/routes.js b/src/contracts/routes.js index e4ad773..32f4276 100644 --- a/src/contracts/routes.js +++ b/src/contracts/routes.js @@ -7,7 +7,10 @@ * access: "public" | "session-required" | "integration-defined", * loadingSurface: string, * errorSurface: string, - * chunkId: string + * chunkId: string, + * title: string, + * navigationLabel: string | null, + * navigationOrder: number | null * }} RouteDefinition */ @@ -24,6 +27,48 @@ export const ROUTE_REGISTRY = Object.freeze({ loadingSurface: "app-shell", errorSurface: "route-boundary", chunkId: "route-home", + title: "시작", + navigationLabel: "시작", + navigationOrder: 10, + }), + EXAMPLES_UI: route({ + routeId: "EXAMPLES_UI", + path: "/examples/ui", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-ui", + title: "UI 구성요소", + navigationLabel: "UI 구성요소", + navigationOrder: 20, + }), + EXAMPLES_STATES: route({ + routeId: "EXAMPLES_STATES", + path: "/examples/states", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-states", + title: "화면 상태", + navigationLabel: "화면 상태", + navigationOrder: 30, + }), + EXAMPLES_AUTH: route({ + routeId: "EXAMPLES_AUTH", + path: "/examples/auth", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-auth", + title: "인증 연동", + navigationLabel: "인증 연동", + navigationOrder: 40, }), SAMPLE_RESOURCE_LIST: route({ routeId: "SAMPLE_RESOURCE_LIST", @@ -34,6 +79,9 @@ export const ROUTE_REGISTRY = Object.freeze({ loadingSurface: "sample-resource-list", errorSurface: "feature-boundary", chunkId: "route-sample-resources", + title: "보호된 연동 지점", + navigationLabel: "보호된 연동 지점", + navigationOrder: 50, }), NOT_FOUND: route({ routeId: "NOT_FOUND", @@ -44,9 +92,22 @@ export const ROUTE_REGISTRY = Object.freeze({ loadingSurface: "none", errorSurface: "not-found", chunkId: "route-not-found", + title: "페이지를 찾을 수 없음", + navigationLabel: null, + navigationOrder: null, }), }); +export const NAVIGATION_ROUTES = Object.freeze( + Object.values(ROUTE_REGISTRY) + .filter((definition) => definition.navigationOrder !== null) + .sort( + (left, right) => + /** @type {number} */ (left.navigationOrder) - + /** @type {number} */ (right.navigationOrder), + ), +); + /** @param {string} routeId */ export function getRoute(routeId) { const registry = /** @type {Record>} */ ( diff --git a/src/presentation/components/page-header.jsx b/src/presentation/components/page-header.jsx new file mode 100644 index 0000000..c930d16 --- /dev/null +++ b/src/presentation/components/page-header.jsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; + +/** + * @param {{ + * title: string, + * description?: string, + * eyebrow?: string + * }} props + */ +export function PageHeader({ title, description, eyebrow }) { + const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null)); + + useEffect(() => { + document.title = `${title} · Frontend Skeleton`; + headingRef.current?.focus(); + }, [title]); + + return ( +
+ {eyebrow ?

{eyebrow}

: null} +

+ {title} +

+ {description ?

{description}

: null} +
+ ); +} diff --git a/src/presentation/examples/auth-example-page.jsx b/src/presentation/examples/auth-example-page.jsx new file mode 100644 index 0000000..a7eedfd --- /dev/null +++ b/src/presentation/examples/auth-example-page.jsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useLocation } from "react-router-dom"; + +import { PageHeader } from "../components/page-header.jsx"; +import { useSession } from "../providers/session-provider.jsx"; + +export default function AuthExamplePage() { + const location = useLocation(); + const { sessionState, beginSignIn, signOut, recover } = useSession(); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + + /** @param {() => Promise} action */ + async function execute(action) { + setPending(true); + setFailed(false); + try { + await action(); + } catch { + setFailed(true); + } finally { + setPending(false); + } + } + + return ( +
+ +
+
+

현재 세션 상태

+ + {sessionState} + +
+
+ + + +
+ {sessionState === "integration-failed" ? ( +

+ 외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증 + 계약을 연결하세요. +

+ ) : null} + {failed ?

인증 작업을 완료하지 못했습니다.

: null} +
+
+ ); +} diff --git a/src/presentation/examples/state-gallery-page.jsx b/src/presentation/examples/state-gallery-page.jsx new file mode 100644 index 0000000..560eb28 --- /dev/null +++ b/src/presentation/examples/state-gallery-page.jsx @@ -0,0 +1,24 @@ +import { PageHeader } from "../components/page-header.jsx"; + +export default function StateGalleryPage() { + return ( +
+ +
+
+
+

표시할 항목이 없습니다.

+

다음 행동이 있다면 이 위치에 명확한 안내를 제공합니다.

+
+
+

요청을 완료하지 못했습니다.

+

안전한 재시도 또는 지원 참조 정보를 제공합니다.

+
+
+
+ ); +} diff --git a/src/presentation/examples/ui-gallery-page.jsx b/src/presentation/examples/ui-gallery-page.jsx new file mode 100644 index 0000000..8b6480b --- /dev/null +++ b/src/presentation/examples/ui-gallery-page.jsx @@ -0,0 +1,20 @@ +import { PageHeader } from "../components/page-header.jsx"; + +export default function UiGalleryPage() { + return ( +
+ +
+ +

+ 버튼, 입력창, 카드, 알림, 모달의 상호작용과 디자인 토큰을 이 + 라우트에 조립합니다. +

+
+
+ ); +} diff --git a/src/presentation/layouts/app-shell.jsx b/src/presentation/layouts/app-shell.jsx new file mode 100644 index 0000000..35f70d6 --- /dev/null +++ b/src/presentation/layouts/app-shell.jsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; + +import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js"; +import { useSession } from "../providers/session-provider.jsx"; + +const SESSION_LABELS = Object.freeze({ + authenticated: "인증됨", + unauthenticated: "로그인 전", + "recovery-pending": "복구 대기", + "integration-failed": "연동 필요", +}); + +export function AppShell() { + const location = useLocation(); + const { sessionState, beginSignIn, signOut, recover } = useSession(); + const [navigationOpen, setNavigationOpen] = useState(false); + const [sessionActionPending, setSessionActionPending] = useState(false); + const [sessionActionFailed, setSessionActionFailed] = useState(false); + + useEffect(() => { + setNavigationOpen(false); + }, [location.pathname]); + + useEffect(() => { + if (!navigationOpen) return undefined; + /** @param {KeyboardEvent} event */ + const closeOnEscape = (event) => { + if (event.key === "Escape") setNavigationOpen(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [navigationOpen]); + + async function runSessionAction() { + setSessionActionPending(true); + setSessionActionFailed(false); + try { + if (sessionState === "authenticated") { + await signOut(); + } else if (sessionState === "recovery-pending") { + await recover(); + } else { + await beginSignIn( + `${location.pathname}${location.search}${location.hash}`, + ); + } + } catch { + setSessionActionFailed(true); + } finally { + setSessionActionPending(false); + } + } + + const sessionActionLabel = + sessionState === "authenticated" + ? "로그아웃" + : sessionState === "recovery-pending" + ? "세션 복구" + : "로그인"; + const integrationAvailable = sessionState !== "integration-failed"; + + return ( +
+ + 본문으로 건너뛰기 + +
+ + + Frontend Skeleton + +
+ + {SESSION_LABELS[sessionState]} + + {integrationAvailable ? ( + + ) : null} +
+ {sessionActionFailed ? ( +

+ 세션 작업을 완료하지 못했습니다. +

+ ) : null} +
+ + {navigationOpen ? ( +
+ ); +} diff --git a/src/presentation/pages/home-page.jsx b/src/presentation/pages/home-page.jsx new file mode 100644 index 0000000..f7c00a8 --- /dev/null +++ b/src/presentation/pages/home-page.jsx @@ -0,0 +1,56 @@ +import { Link } from "react-router-dom"; + +import { routePath } from "../../contracts/routes.js"; +import { PageHeader } from "../components/page-header.jsx"; + +const READINESS_ITEMS = Object.freeze([ + { + title: "실행 계약", + description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.", + }, + { + title: "교체 가능한 연동", + description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.", + }, + { + title: "접근 가능한 화면", + description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.", + }, +]); + +export default function HomePage() { + return ( +
+ +
+ {READINESS_ITEMS.map((item) => ( +
+

{item.title}

+

{item.description}

+
+ ))} +
+
+
+

준비된 화면 살펴보기

+

공통 구성요소와 비동기 화면 상태를 예제 라우트에서 확인하세요.

+
+
+ + UI 구성요소 보기 + + + 화면 상태 보기 + +
+
+
+ ); +} diff --git a/src/presentation/pages/not-found-page.jsx b/src/presentation/pages/not-found-page.jsx new file mode 100644 index 0000000..a1463b7 --- /dev/null +++ b/src/presentation/pages/not-found-page.jsx @@ -0,0 +1,20 @@ +import { Link } from "react-router-dom"; + +import { routePath } from "../../contracts/routes.js"; +import { PageHeader } from "../components/page-header.jsx"; + +export default function NotFoundPage() { + return ( +
+ +
+ + 홈으로 이동 + +
+
+ ); +} diff --git a/src/presentation/pages/sample-contract-page.jsx b/src/presentation/pages/sample-contract-page.jsx new file mode 100644 index 0000000..a8f8aab --- /dev/null +++ b/src/presentation/pages/sample-contract-page.jsx @@ -0,0 +1,23 @@ +import { PageHeader } from "../components/page-header.jsx"; +import { useSession } from "../providers/session-provider.jsx"; + +export default function SampleContractPage() { + const { sessionState } = useSession(); + + return ( +
+ +
+

라우트 접근 허용

+

+ 현재 세션 상태는 {sessionState}입니다. 서버의 + 권한 검증은 이 클라이언트 라우트 정책과 별도로 유지해야 합니다. +

+
+
+ ); +} diff --git a/src/presentation/providers/session-provider.jsx b/src/presentation/providers/session-provider.jsx new file mode 100644 index 0000000..770caca --- /dev/null +++ b/src/presentation/providers/session-provider.jsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; + +/** + * @typedef {{ + * sessionState: import("../../application/ports/auth-session-port.js").SessionState, + * beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"], + * signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"], + * recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"] + * }} SessionContextValue + */ + +const SessionContext = createContext( + /** @type {SessionContextValue | null} */ (null), +); + +/** + * @param {{ + * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, + * children: React.ReactNode + * }} props + */ +export function SessionProvider({ authSession, children }) { + const sessionState = useSyncExternalStore( + authSession.subscribe, + authSession.getState, + authSession.getState, + ); + const value = useMemo( + () => + Object.freeze({ + sessionState, + beginSignIn: authSession.beginSignIn, + signOut: authSession.signOut, + recover: authSession.recover, + }), + [authSession, sessionState], + ); + + return ( + {children} + ); +} + +export function useSession() { + const session = useContext(SessionContext); + if (!session) { + throw new Error("SessionProvider is required"); + } + return session; +} diff --git a/src/presentation/routes/app-router.jsx b/src/presentation/routes/app-router.jsx index 286b1b2..8cfc004 100644 --- a/src/presentation/routes/app-router.jsx +++ b/src/presentation/routes/app-router.jsx @@ -1,81 +1,267 @@ +import { lazy, Suspense, useState } from "react"; import { BrowserRouter, - Link, Route, Routes, + useLocation, } from "react-router-dom"; -import { routePath } from "../../contracts/routes.js"; +import { getRoute, routePath } from "../../contracts/routes.js"; +import { RouteBoundary } from "../boundaries/render-error-boundary.jsx"; +import { AppShell } from "../layouts/app-shell.jsx"; +import { PageHeader } from "../components/page-header.jsx"; +import { SessionProvider, useSession } from "../providers/session-provider.jsx"; import { decideRouteAccess } from "./navigation-policy.js"; -function HomePage() { +const HomePage = lazy(() => import("../pages/home-page.jsx")); +const UiGalleryPage = lazy(() => import("../examples/ui-gallery-page.jsx")); +const StateGalleryPage = lazy( + () => import("../examples/state-gallery-page.jsx"), +); +const AuthExamplePage = lazy( + () => import("../examples/auth-example-page.jsx"), +); +const SampleContractPage = lazy( + () => import("../pages/sample-contract-page.jsx"), +); +const NotFoundPage = lazy(() => import("../pages/not-found-page.jsx")); + +/** @param {{ routeId: string }} props */ +function RouteLoadingSurface({ routeId }) { + const definition = getRoute(routeId); return ( -
-

Clean Architecture Frontend

-

런타임 계약이 검증되었습니다.

- 샘플 리소스 -
+
+
); } -function SamplePlaceholder() { +function RouteFailureSurface() { return ( -
-

샘플 리소스

-

계약 fixture를 준비하고 있습니다.

-
- ); -} - -function NotFoundPage() { - return ( -
-

페이지를 찾을 수 없습니다.

- 홈으로 이동 -
+
+ +
); } /** * @param {{ - * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort + * routeId: string, + * buildId: string, + * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, + * children: React.ReactNode * }} props */ -function GuardedSampleRoute({ authSession }) { - const decision = decideRouteAccess( - "SAMPLE_RESOURCE_LIST", - authSession.getState(), +function RouteSurface({ routeId, buildId, telemetry, children }) { + return ( + } + > + }> + {children} + + ); - if (!decision.allowed) { +} + +/** + * @param {{ + * routeId: string, + * children: React.ReactNode + * }} props + */ +function ProtectedRoute({ routeId, children }) { + const location = useLocation(); + const { sessionState, beginSignIn, recover } = useSession(); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + const decision = decideRouteAccess(routeId, sessionState); + + async function continueSession() { + setPending(true); + setFailed(false); + try { + if (decision.action === "wait-for-session") { + await recover(); + } else { + await beginSignIn( + `${location.pathname}${location.search}${location.hash}`, + ); + } + } catch { + setFailed(true); + } finally { + setPending(false); + } + } + + if (decision.allowed) return children; + + if (sessionState === "integration-failed") { return ( -
-

세션이 필요합니다.

- -
+
+ +
); } - return ; + + const recovering = decision.action === "wait-for-session"; + return ( +
+ +
+ +
+ {failed ? ( +

+ 세션 작업을 완료하지 못했습니다. +

+ ) : null} +
+ ); +} + +/** + * @param {{ + * routeId: string, + * buildId: string, + * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, + * children: React.ReactNode + * }} props + */ +function PublicRoute({ routeId, buildId, telemetry, children }) { + return ( + + {children} + + ); } /** * @param {{ * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, - * basename?: string + * basename?: string, + * buildId?: string, + * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort * }} props */ -export function AppRouter({ authSession, basename = "/" }) { +export function AppRouter({ + authSession, + basename = "/", + buildId = "local-build", + telemetry, +}) { return ( - - } /> - } - /> - } /> - + + + }> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + ); } diff --git a/src/presentation/styles/theme.css b/src/presentation/styles/theme.css index ad74953..9018bce 100644 --- a/src/presentation/styles/theme.css +++ b/src/presentation/styles/theme.css @@ -24,6 +24,35 @@ body { margin: 0; + min-width: 20rem; + min-height: 100vh; + } + + button, + input, + textarea, + select { + font: inherit; + } + + a { + color: inherit; + } + + h1, + h2, + p { + margin-block-start: 0; + } + + h1 { + font-size: clamp(2rem, 5vw, 3.5rem); + line-height: 1.05; + letter-spacing: -0.04em; + } + + h2 { + line-height: 1.25; } :focus-visible { @@ -33,8 +62,165 @@ } @layer components { + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .skip-link { + position: fixed; + z-index: 100; + top: 0.75rem; + left: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-control); + color: white; + background: var(--color-content); + transform: translateY(-200%); + } + + .skip-link:focus { + transform: translateY(0); + } + + .app-shell { + display: grid; + min-height: 100vh; + grid-template-rows: 4.5rem 1fr; + grid-template-columns: 15rem minmax(0, 1fr); + grid-template-areas: + "header header" + "sidebar content"; + } + + .app-shell__header { + position: sticky; + z-index: 30; + top: 0; + grid-area: header; + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--color-surface-muted); + background: color-mix(in oklch, white 92%, var(--color-surface)); + box-shadow: 0 1px 8px color-mix(in oklch, var(--color-content) 8%, transparent); + } + + .app-shell__brand { + overflow: hidden; + font-size: 1.05rem; + font-weight: 800; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-shell__menu-button { + display: none; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--color-surface-muted); + border-radius: var(--radius-control); + padding: 0.55rem 0.75rem; + color: var(--color-content); + background: white; + cursor: pointer; + } + + .app-shell__session { + display: flex; + align-items: center; + gap: 0.75rem; + margin-inline-start: auto; + } + + .app-shell__session-error { + position: absolute; + top: calc(100% + 0.25rem); + right: 1rem; + padding: 0.5rem; + color: var(--color-danger); + background: white; + } + + .session-status { + display: inline-flex; + width: fit-content; + align-items: center; + border-radius: 999px; + padding: 0.3rem 0.65rem; + color: var(--color-content-muted); + background: var(--color-surface-muted); + font-size: 0.8rem; + font-weight: 700; + } + + .session-status[data-state="authenticated"] { + color: oklch(0.35 0.12 155); + background: oklch(0.93 0.05 155); + } + + .session-status[data-state="integration-failed"] { + color: var(--color-danger); + } + + .app-shell__sidebar { + position: sticky; + top: 4.5rem; + grid-area: sidebar; + height: calc(100vh - 4.5rem); + padding: 1.25rem 0.75rem; + border-right: 1px solid var(--color-surface-muted); + background: white; + } + + .app-navigation { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + } + + .app-navigation__link { + display: block; + border-radius: var(--radius-control); + padding: 0.75rem 0.9rem; + color: var(--color-content-muted); + font-weight: 650; + text-decoration: none; + } + + .app-navigation__link:hover { + color: var(--color-content); + background: var(--color-surface); + } + + .app-navigation__link.is-active { + color: var(--color-action-hover); + background: color-mix(in oklch, var(--color-action) 10%, white); + font-weight: 800; + } + + .app-shell__content { + grid-area: content; + min-width: 0; + } + .ui-page { - @apply mx-auto flex min-h-screen max-w-4xl flex-col gap-6 p-page; + @apply mx-auto flex max-w-6xl flex-col gap-6 p-page; + width: 100%; + box-sizing: border-box; } .ui-panel { @@ -43,12 +229,41 @@ .ui-button { @apply rounded-control bg-action px-4 py-2 font-semibold text-white; + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + border: 0; + text-decoration: none; + cursor: pointer; } .ui-button:hover { @apply bg-action-hover; } + .ui-button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .ui-button--secondary { + border: 1px solid var(--color-surface-muted); + color: var(--color-content); + background: white; + } + + .ui-button--secondary:hover { + color: var(--color-action); + background: var(--color-surface); + } + + .ui-button--compact { + min-height: 2.25rem; + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + } + .ui-skeleton { @apply h-24 animate-pulse rounded-surface bg-surface-muted; } @@ -57,6 +272,115 @@ .ui-terminal-error { @apply rounded-surface border border-surface-muted p-6; } + + .page-header { + max-width: 50rem; + padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem; + } + + .page-header__eyebrow { + margin-block-end: 0.75rem; + color: var(--color-action); + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .page-header__description { + max-width: 45rem; + color: var(--color-content-muted); + font-size: 1.05rem; + line-height: 1.7; + } + + .readiness-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + } + + .readiness-grid p, + .starter-actions p { + margin-block-end: 0; + color: var(--color-content-muted); + line-height: 1.65; + } + + .starter-actions, + .auth-example { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + } + + .button-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .route-loading { + padding-block-start: 4rem; + } + + .app-shell__scrim { + display: none; + } +} + +@media (max-width: 47.999rem) { + .app-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "header" + "content"; + } + + .app-shell__header { + padding-inline: 0.75rem; + } + + .app-shell__menu-button { + display: inline-flex; + } + + .app-shell__sidebar { + position: fixed; + z-index: 50; + top: 4.5rem; + bottom: 0; + left: 0; + display: none; + width: min(18rem, 85vw); + height: auto; + box-sizing: border-box; + box-shadow: 10px 0 30px color-mix(in oklch, var(--color-content) 15%, transparent); + } + + .app-shell__sidebar[data-open="true"] { + display: block; + } + + .app-shell__scrim { + position: fixed; + z-index: 40; + inset: 4.5rem 0 0; + display: block; + border: 0; + background: color-mix(in oklch, var(--color-content) 35%, transparent); + cursor: pointer; + } + + .app-shell__session .session-status { + display: none; + } + + .readiness-grid { + grid-template-columns: minmax(0, 1fr); + } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx index e253c63..fc7782d 100644 --- a/tests/component/router.test.jsx +++ b/tests/component/router.test.jsx @@ -1,24 +1,72 @@ // @vitest-environment jsdom import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; +import { + createAnonymousSessionAdapter, + createDemoSessionAdapter, +} from "../../src/adapters/auth/external-session-adapter.js"; import { AppRouter } from "../../src/presentation/routes/app-router.jsx"; describe("application router", () => { - it("renders not-found without making an API request", () => { + it("renders the app shell and not-found route without an API request", async () => { window.history.pushState({}, "", "/missing"); render(); + expect( - screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }), + await screen.findByRole("heading", { + name: "페이지를 찾을 수 없습니다.", + }), ).toBeVisible(); + expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); + expect(screen.getByRole("main")).toBeVisible(); }); - it("shows session-required UX without claiming authorization", () => { + it("navigates between registry-backed example routes", async () => { + const user = userEvent.setup(); + window.history.pushState({}, "", "/"); + render(); + + await user.click( + await screen.findByRole("link", { name: "UI 구성요소", exact: true }), + ); + + expect( + await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }), + ).toBeVisible(); + expect(window.location.pathname).toBe("/examples/ui"); + }); + + it("reacts to demo sign-in and opens the protected integration route", async () => { + const user = userEvent.setup(); + const authSession = createDemoSessionAdapter(); + window.history.pushState({}, "", "/sample/resources"); + render(); + + expect( + await screen.findByRole("heading", { name: "세션이 필요합니다." }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "로그인 시작" })); + + expect( + await screen.findByRole("heading", { name: "보호된 연동 지점" }), + ).toBeVisible(); + expect(screen.getByText("인증됨")).toBeVisible(); + }); + + it("fails closed when the auth integration does not change state", async () => { + const user = userEvent.setup(); window.history.pushState({}, "", "/sample/resources"); render(); - expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible(); - expect(screen.getByRole("button", { name: "로그인" })).toBeVisible(); + + await user.click( + await screen.findByRole("button", { name: "로그인 시작" }), + ); + + expect( + screen.getByRole("heading", { name: "세션이 필요합니다." }), + ).toBeVisible(); }); }); diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index ede6e1e..76a4bdb 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -1,7 +1,14 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -for (const route of ["/", "/sample/resources", "/not-found"]) { +for (const route of [ + "/", + "/examples/ui", + "/examples/states", + "/examples/auth", + "/sample/resources", + "/not-found", +]) { test(`@a11y ${route} has no critical or serious axe violations`, async ({ page, }) => { @@ -21,7 +28,7 @@ test("@a11y keyboard reaches the primary route action with visible focus", async page, }) => { await page.goto("/"); - const action = page.getByRole("link", { name: "샘플 리소스" }); + const action = page.getByRole("link", { name: "UI 구성요소 보기" }); await expect(action).toBeVisible(); await page.keyboard.press("Tab"); await expect(action).toBeFocused(); diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index f262105..f2bc6be 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -5,4 +5,51 @@ test("boots the public app shell", async ({ page }) => { await expect(page.getByRole("heading", { level: 1 })).toHaveText( "Clean Architecture Frontend", ); + await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); + await expect(page.getByRole("main")).toBeVisible(); +}); + +test("navigates to a registry-backed example without a page reload", async ({ + page, +}) => { + await page.goto("/"); + await page + .getByRole("navigation", { name: "주요 탐색" }) + .getByRole("link", { name: "화면 상태" }) + .click(); + + await expect(page).toHaveURL(/\/examples\/states$/); + await expect( + page.getByRole("heading", { level: 1, name: "화면 상태" }), + ).toBeFocused(); +}); + +test("opens the protected integration route through the local demo seam", async ({ + page, +}) => { + await page.goto("/sample/resources"); + await expect( + page.getByRole("heading", { name: "세션이 필요합니다." }), + ).toBeVisible(); + + await page.getByRole("button", { name: "로그인 시작" }).click(); + + await expect( + page.getByRole("heading", { name: "보호된 연동 지점" }), + ).toBeVisible(); + await expect(page.getByText("인증됨")).toBeVisible(); +}); + +test("provides an escape-dismissible mobile navigation", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + const menu = page.getByRole("button", { name: "메뉴", exact: true }); + + await menu.click(); + await expect(menu).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(menu).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden(); }); diff --git a/tests/unit/navigation-policy.test.js b/tests/unit/navigation-policy.test.js index fa61fae..4ea27a3 100644 --- a/tests/unit/navigation-policy.test.js +++ b/tests/unit/navigation-policy.test.js @@ -4,7 +4,10 @@ import { createRedirectLoopGuard, decideRouteAccess, } from "../../src/presentation/routes/navigation-policy.js"; -import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; +import { + NAVIGATION_ROUTES, + ROUTE_REGISTRY, +} from "../../src/contracts/routes.js"; describe("route registry", () => { it("matches the stable registry snapshot", () => { @@ -15,35 +18,93 @@ describe("route registry", () => { "chunkId": "route-home", "errorSurface": "route-boundary", "loadingSurface": "app-shell", + "navigationLabel": "시작", + "navigationOrder": 10, "paramsSchema": null, "path": "/", "routeId": "APP_HOME", "searchSchema": null, + "title": "시작", + }, + "EXAMPLES_AUTH": { + "access": "public", + "chunkId": "route-examples-auth", + "errorSurface": "route-boundary", + "loadingSurface": "example-page", + "navigationLabel": "인증 연동", + "navigationOrder": 40, + "paramsSchema": null, + "path": "/examples/auth", + "routeId": "EXAMPLES_AUTH", + "searchSchema": null, + "title": "인증 연동", + }, + "EXAMPLES_STATES": { + "access": "public", + "chunkId": "route-examples-states", + "errorSurface": "route-boundary", + "loadingSurface": "example-page", + "navigationLabel": "화면 상태", + "navigationOrder": 30, + "paramsSchema": null, + "path": "/examples/states", + "routeId": "EXAMPLES_STATES", + "searchSchema": null, + "title": "화면 상태", + }, + "EXAMPLES_UI": { + "access": "public", + "chunkId": "route-examples-ui", + "errorSurface": "route-boundary", + "loadingSurface": "example-page", + "navigationLabel": "UI 구성요소", + "navigationOrder": 20, + "paramsSchema": null, + "path": "/examples/ui", + "routeId": "EXAMPLES_UI", + "searchSchema": null, + "title": "UI 구성요소", }, "NOT_FOUND": { "access": "public", "chunkId": "route-not-found", "errorSurface": "not-found", "loadingSurface": "none", + "navigationLabel": null, + "navigationOrder": null, "paramsSchema": null, "path": "*", "routeId": "NOT_FOUND", "searchSchema": null, + "title": "페이지를 찾을 수 없음", }, "SAMPLE_RESOURCE_LIST": { "access": "integration-defined", "chunkId": "route-sample-resources", "errorSurface": "feature-boundary", "loadingSurface": "sample-resource-list", + "navigationLabel": "보호된 연동 지점", + "navigationOrder": 50, "paramsSchema": null, "path": "/sample/resources", "routeId": "SAMPLE_RESOURCE_LIST", "searchSchema": "SampleResourceListQuery", + "title": "보호된 연동 지점", }, } `); }); + it("derives visible navigation in explicit order", () => { + expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([ + "APP_HOME", + "EXAMPLES_UI", + "EXAMPLES_STATES", + "EXAMPLES_AUTH", + "SAMPLE_RESOURCE_LIST", + ]); + }); + it("treats client access as a UX hint, not authorization", () => { expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({ allowed: true,