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 };