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 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 viewport bottom during loading and then dropped out of view when the content
arrived, which is the whole 0.192 the page scored. */ 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 > main,
.site-frame > .ui-page { .site-frame > .ui-page {
/* Takes the slack so the footer stays put whether the route rendered a long /* 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 strong { font-size: 18px; }
.studio-app .studio-type-list span, .studio-app .studio-type-list span,
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; } .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 { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
.studio-app .studio-create-footer .studio-primary-button { margin: 0; } /*
`.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-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; } .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.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.", "외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.", "route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
"route.auth.required.title": "Studio에 로그인해 주세요.", "route.auth.required.title": "Studio에 로그인해 주세요.",
"route.auth.recovering.description": "route.auth.recovering.description":
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.", "이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
@@ -274,6 +276,8 @@ const PLATFORM_EN_MESSAGES = {
"route.auth.integration.description": "route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.", "This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.", "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.required.title": "Sign in to Studio.",
"route.auth.recovering.description": "route.auth.recovering.description":
"Checking whether the previous sign-in is still valid. Try again if this screen stays.", "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"; 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 ( return (
<section className="ui-page" data-surface="authentication-required"> <section className="ui-page auth-gate" data-surface="authentication-required">
<PageHeader <div className="auth-gate__card">
title={ <PageHeader
recovering eyebrow={message("route.auth.eyebrow")}
? message("route.auth.recovering.title") title={
: message("route.auth.required.title") recovering
} ? message("route.auth.recovering.title")
description={ : message("route.auth.required.title")
recovering }
? message("route.auth.recovering.description") description={
: message("route.auth.required.description") recovering
} ? message("route.auth.recovering.description")
/> : message("route.auth.required.description")
<Button }
disabled={pending} />
onClick={() => void continueSession()} <div className="auth-gate__actions">
> <Button disabled={pending} onClick={() => void continueSession()}>
{pending {pending
? message("common.processing") ? message("common.processing")
: recovering : recovering
? message("action.recoverSession") ? message("action.recoverSession")
: message("action.signIn")} : message("action.signIn")}
</Button> </Button>
{failed ? ( <p className="auth-gate__return">
<p className="ui-terminal-error" role="alert"> {message("route.auth.returnTo", {
{message("shell.session.actionFailed")} path: `${location.pathname}${location.search}`,
</p> })}
) : null} </p>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</div>
</section> </section>
); );
} }