diff --git a/src/features/tech-log/presentation/studio/studio-shell.tsx b/src/features/tech-log/presentation/studio/studio-shell.tsx index d10a301..3001b1f 100644 --- a/src/features/tech-log/presentation/studio/studio-shell.tsx +++ b/src/features/tech-log/presentation/studio/studio-shell.tsx @@ -6,6 +6,7 @@ import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts import { StudioHeader } from "./components/studio-header.tsx"; import { StudioProvider } from "./studio-provider.tsx"; import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx"; +import { useSession } from "../../../../presentation/providers/session-provider.tsx"; import { useStudio } from "./use-studio.ts"; type StudioShellProps = Readonly<{ children: ReactNode }>; @@ -13,12 +14,20 @@ type StudioShellProps = Readonly<{ children: ReactNode }>; function StudioFrame({ children }: StudioShellProps) { const location = useLocation(); const { requestAnnouncement } = useStudio(); + const { sessionState } = useSession(); + // The router gates the page, not the chrome, so a signed-out visitor who + // typed /studio still got the Studio navigation — every workspace link, by + // name. No data leaks through an href, but the checklist item is that a + // signed-out visitor does not see the Studio screen, and the navigation is + // the Studio screen. `children` here is the router's sign-in surface, which + // is the whole of what such a visitor should get. + const signedIn = sessionState === "authenticated"; return ( <> 본문으로 건너뛰기 - + {signedIn ? : null}
{children}
diff --git a/src/features/tech-log/presentation/styles/globals.css b/src/features/tech-log/presentation/styles/globals.css index aa1885a..fd9c3c5 100644 --- a/src/features/tech-log/presentation/styles/globals.css +++ b/src/features/tech-log/presentation/styles/globals.css @@ -79,7 +79,39 @@ a { } .site-frame { - min-height: 100vh; + /* Flex column with the footer pushed down by `margin-top: auto` instead of + sitting wherever the flow leaves it. The route chunk loads after first + paint, so in flow the footer rendered just under the header and then jumped + when the content arrived — a single 0.192 layout shift, which is most of + what the page scored. Pinned to the bottom of the frame it starts where it + ends up. */ + display: flex; + flex-direction: column; + min-height: 100dvh; +} + +/* The route chunk loads after first paint, and while it does the shell renders + the Suspense fallback — a `section.ui-page`, not a `main`. Both slots need the + same reserved height: with only `main` covered the footer still sat at the + viewport bottom during loading and then dropped out of view when the content + arrived, which is the whole 0.192 the page scored. */ +.site-frame > main, +.site-frame > .ui-page { + /* Takes the slack so the footer stays put whether the route rendered a long + document or nothing yet. + + `min-height` is what actually removes the shift. The route chunk loads + after first paint; with only `flex` the footer sat at the bottom edge of + the viewport — visible — and then dropped to y≈2500 when the content + arrived, which is a visible element moving and so counts in full. Holding + the content area to a viewport tall puts the footer below the fold from the + first frame, and later growth only pushes it further out of sight. */ + flex: 1 0 auto; + min-height: 100dvh; +} + +.site-footer { + margin-top: auto; } .shell { diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index daff120..1d2e208 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -11,6 +11,7 @@ import { RouterProvider, } from "react-router-dom"; +import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts"; import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts"; import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts"; @@ -102,12 +103,29 @@ function compileTimeGroupedRouteContract() { } void compileTimeGroupedRouteContract; -function renderRouter() { +/** + * Studio routes are `session-required`, so a Studio assertion needs a session + * that says so — with the anonymous adapter the router correctly renders the + * sign-in surface instead of the page, which is what the gate is for. + */ +function createSignedInSessionAdapter() { + return createExternalAuthSessionAdapter({ + readState: () => "authenticated" as const, + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, + attachCredential: async () => ({ headers: {} }), + recoverSession: async () => "restored" as const, + notifyUnauthenticated: () => {}, + }); +} + +function renderRouter(session = createAnonymousSessionAdapter()) { const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT); return render( @@ -355,10 +373,27 @@ describe("generic application router", () => { expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument(); }); - it("keeps Studio routes inside the persistent Studio layout", async () => { + it("keeps a signed-out visitor out of the Studio layout entirely", async () => { window.history.pushState({}, "", "/studio"); renderRouter(); + // Not merely "no data": the Studio surface itself must not mount. Every + // TechLog route used to register as `access: "public"`, so a signed-out + // visitor who typed /studio got the shell, the navigation, and the page — + // and the page then issued Studio API calls. + await screen.findByRole("heading", { name: /세션|로그인/ }); + expect( + screen.queryByRole("heading", { name: "작업 흐름" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("navigation", { name: "Studio 주 탐색" }), + ).not.toBeInTheDocument(); + }); + + it("keeps Studio routes inside the persistent Studio layout", async () => { + window.history.pushState({}, "", "/studio"); + renderRouter(createSignedInSessionAdapter()); + expect( await screen.findByRole("heading", { name: "작업 흐름" }), ).toBeVisible(); @@ -371,7 +406,7 @@ describe("generic application router", () => { it("gives the Studio wildcard precedence over the Public not-found route", async () => { window.history.pushState({}, "", "/studio/missing"); - renderRouter(); + renderRouter(createSignedInSessionAdapter()); expect( await screen.findByRole("heading", { diff --git a/tests/features/tech-log/route-contract.test.ts b/tests/features/tech-log/route-contract.test.ts index 97c7539..47f3237 100644 --- a/tests/features/tech-log/route-contract.test.ts +++ b/tests/features/tech-log/route-contract.test.ts @@ -92,9 +92,14 @@ describe("TechLog route boundary contract", () => { definition.chunkId, ]), ).toEqual( - expectedRoutes.map(([routeId]) => [ + // Studio is the authenticated surface, Public is not. Derived here from + // the frozen inventory's own layoutGroup column rather than restated per + // route, so a Studio route added without a gate fails this table too — + // every TechLog route used to be registered "public", which made the + // router's access decision a no-op for Studio. + expectedRoutes.map(([routeId, , layoutGroup]) => [ routeId, - "public", + layoutGroup === "STUDIO" ? "session-required" : "public", routeId === "NOT_FOUND" ? "route-not-found" : `route-${routeId.toLowerCase().replaceAll("_", "-")}`, diff --git a/tests/features/tech-log/studio-shell-smoke.test.tsx b/tests/features/tech-log/studio-shell-smoke.test.tsx index e6febe9..8af8833 100644 --- a/tests/features/tech-log/studio-shell-smoke.test.tsx +++ b/tests/features/tech-log/studio-shell-smoke.test.tsx @@ -11,7 +11,9 @@ import type { StudioGateway } from "../../../src/features/tech-log/application/p import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx"; import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx"; import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx"; +import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts"; import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx"; +import { SessionProvider } from "../../../src/presentation/providers/session-provider.tsx"; import { createTestApplication } from "../../helpers/create-test-application.ts"; afterEach(() => vi.restoreAllMocks()); @@ -42,13 +44,28 @@ function renderStudio( ); const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const application = createTestApplication({ + // The Studio shell draws its chrome only for an authenticated session — a + // signed-out visitor gets the sign-in surface and nothing else, which is + // what these tests are not about. In the real app AppRouter supplies both + // the session state and the provider; here the harness does. + session: createExternalAuthSessionAdapter({ + readState: () => "authenticated" as const, + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, + attachCredential: async () => ({ headers: {} }), + recoverSession: async () => "restored" as const, + notifyUnauthenticated: () => {}, + }), featureInputs: { "tech-log": { ...installed, createStudioGateway }, }, }); const view = render( + + , ); return { ...view, router };