fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다

릴리즈 편집 맨 아래에서 저장과 공개가 서로 다른 높이에 서 있었다.
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는데
하단 푸터에서 primary 만 그 여백을 풀어 주고 있었다 — 그래서 저장은 위에, 공개와
공개에서 내리기는 24px 아래에 앉았다.

로그인은 화면이 없었다. `ui-page` 위에 제목 한 줄과 버튼 하나가 전부라, 보호된 주소를
열었을 때 페이지처럼 보이지 않았다. 카드 하나로 세우고 eyebrow·제목·설명·행동을 갖춘
뒤, 로그인하면 어디로 돌아오는지까지 적는다 — 그것이 이 화면이 답해야 할 질문이다.

오류 화면과 다른 모양이어야 한다. 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-24 00:56:16 +09:00
co-authored by Claude Opus 5
parent f9d20e8946
commit 6b9dc3f0a2
4 changed files with 149 additions and 30 deletions
@@ -100,6 +100,102 @@ a {
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. */
/*
로그인 관문.
이 화면은 공개 셸과 Studio 셸 양쪽에서 나온다 — 보호된 주소를 열면 그 주소가 속한 레이아웃 안에
이 자리가 대신 그려진다. 그래서 두 곳 모두에 있는 토큰만 쓴다.
카드로 세우는 이유는 이것이 "화면을 못 그렸다"가 아니라 "여기서 한 걸음 더 필요하다"이기
때문이다. 오류 화면과 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.
*/
.auth-gate {
display: grid;
place-items: center;
padding-block: clamp(64px, 12vh, 140px);
}
.auth-gate .auth-gate__card {
width: min(100%, 34rem);
padding: clamp(32px, 5vw, 52px);
border: 1px solid var(--line-strong);
border-radius: 10px;
background: var(--paper);
}
.auth-gate .page-header {
margin: 0;
}
.auth-gate .page-header__eyebrow {
margin: 0 0 14px;
color: var(--signal);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.auth-gate .page-header h1 {
margin: 0;
font-size: clamp(27px, 3.2vw, 33px);
font-weight: 660;
letter-spacing: -0.035em;
line-height: 1.25;
}
.auth-gate .page-header__description {
margin: 16px 0 0;
color: var(--ink-soft);
font-size: 16px;
line-height: 1.72;
}
.auth-gate .auth-gate__actions {
display: grid;
gap: 14px;
margin-top: 32px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
/* 이 버튼은 화면에서 유일한 행동이다. 폭을 채워 어디를 눌러야 하는지 묻지 않게 한다. */
.auth-gate .auth-gate__actions button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
width: 100%;
padding-inline: 20px;
border: 1px solid var(--signal);
border-radius: 6px;
background: var(--signal);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 650;
cursor: pointer;
}
.auth-gate .auth-gate__actions button:disabled {
opacity: 0.55;
cursor: wait;
}
.auth-gate .auth-gate__return {
margin: 0;
color: var(--faint);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-gate .ui-terminal-error {
margin: 20px 0 0;
color: var(--warning);
font-size: 14px;
}
.site-frame > main,
.site-frame > .ui-page {
/* Takes the slack so the footer stays put whether the route rendered a long
@@ -127,8 +127,14 @@
.studio-app .studio-type-list strong { font-size: 18px; }
.studio-app .studio-type-list span,
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
.studio-app .studio-create-footer { display: flex; align-items: center; gap: 20px; margin-top: 28px; }
.studio-app .studio-create-footer .studio-primary-button { margin: 0; }
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
/*
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는다. 여기서
풀어 주지 않으면 primary(저장)만 위로 붙고 secondary(공개·공개에서 내리기)는 24px 아래로
내려앉아, 같은 줄의 버튼들이 서로 다른 높이에 선다.
*/
.studio-app .studio-create-footer .studio-primary-button,
.studio-app .studio-create-footer .studio-secondary-button { margin: 0; }
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
+4
View File
@@ -87,6 +87,8 @@ const PLATFORM_KO_MESSAGES = {
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
"route.auth.required.title": "Studio에 로그인해 주세요.",
"route.auth.recovering.description":
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
@@ -274,6 +276,8 @@ const PLATFORM_EN_MESSAGES = {
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.",
"route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "You return to {path} after signing in.",
"route.auth.required.title": "Sign in to Studio.",
"route.auth.recovering.description":
"Checking whether the previous sign-in is still valid. Try again if this screen stays.",
+41 -28
View File
@@ -241,35 +241,48 @@ function ProtectedRoute({
);
}
const recovering = decision.action === "wait-for-session";
/*
Signing in is a screen, not a bare gate. This used to be a title and a button
dropped on `ui-page`, so opening a protected address showed no page at all —
just two stray elements. It is one card now, and it names the address the
visitor came for so they know where signing in returns them.
*/
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
<section className="ui-page auth-gate" data-surface="authentication-required">
<div className="auth-gate__card">
<PageHeader
eyebrow={message("route.auth.eyebrow")}
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<div className="auth-gate__actions">
<Button disabled={pending} onClick={() => void continueSession()}>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
<p className="auth-gate__return">
{message("route.auth.returnTo", {
path: `${location.pathname}${location.search}`,
})}
</p>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</div>
</section>
);
}