디자인: 게임천국 느낌 아이콘, 홈 단순화(큰 버튼 3개 + 탭), 헤더 로그인/계정 메뉴, 로그인 페이지 분리

- 아이콘: 게임패드 + 천사 고리 + 반짝이, scripts/pwa-icons.ts가 favicon.svg와 PNG를 한 디자인에서 생성
- 홈: 방 만들기·코드로 참여(/j)·컴퓨터와 연습 버튼, [게임 고르기 | 지금 열린 방] 탭
- 헤더: 로그인 전 [로그인] → /login, 로그인 뒤 닉네임 → [설정][로그아웃] 메뉴
- /login: 로그인만, /me(설정)는 로그인 안 했으면 /login?next=/me로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-07 12:10:11 +09:00
parent 6390e3e64c
commit bdaaa23af6
13 changed files with 353 additions and 104 deletions

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c3aed"/><stop offset=".55" stop-color="#db2777"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#bg)"/><ellipse cx="32" cy="14.5" rx="11" ry="3.6" fill="none" stroke="#fde047" stroke-width="2.6"/><path d="M20 21h24a10 10 0 0 1 9.6 7.2l3.3 11.6a5.6 5.6 0 0 1-9.5 5.3L43 40H21l-4.4 5.1a5.6 5.6 0 0 1-9.5-5.3l3.3-11.6A10 10 0 0 1 20 21z" fill="#fff" stroke="#1e1b4b" stroke-width="2.2" stroke-linejoin="round"/><path d="M19.6 26.2h2.8v3h3v2.8h-3v3h-2.8v-3h-3v-2.8h3z" fill="#1e1b4b"/><circle cx="43" cy="26.6" r="2.3" fill="#facc15" stroke="#1e1b4b" stroke-width="1"/><circle cx="46.8" cy="30.6" r="2.3" fill="#ef4444" stroke="#1e1b4b" stroke-width="1"/><circle cx="43" cy="34.6" r="2.3" fill="#22c55e" stroke="#1e1b4b" stroke-width="1"/><circle cx="39.2" cy="30.6" r="2.3" fill="#3b82f6" stroke="#1e1b4b" stroke-width="1"/><path d="M53 6.4Q53 11 57.6 11Q53 11 53 15.6Q53 11 48.4 11Q53 11 53 6.4z" fill="#fde047"/><path d="M10.5 11Q10.5 14 13.5 14Q10.5 14 10.5 17Q10.5 14 7.5 14Q10.5 14 10.5 11z" fill="#fff"/><path d="M54 50Q54 53 57 53Q54 53 54 56Q54 53 51 53Q54 53 54 50z" fill="#fff"/><path d="M11 51.8Q11 54 13.2 54Q11 54 11 56.2Q11 54 8.8 54Q11 54 11 51.8z" fill="#fde047"/></svg>

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,11 +1,11 @@
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */ /** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router'; import { Link, NavLink, useNavigate } from 'react-router';
import { normalizeRoomCode } from '@bg/shared/lite'; import { normalizeRoomCode } from '@bg/shared/lite';
import { api } from '../net/api'; import { api } from '../net/api';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
import { toast } from '../store/toast'; import { toast } from '../store/toast';
import { Avatar } from './ui'; import { Avatar, ConfirmSheet } from './ui';
export function SiteHeader() { export function SiteHeader() {
const { me, siteName, role } = useSession(); const { me, siteName, role } = useSession();
@@ -41,11 +41,71 @@ export function SiteHeader() {
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" /> <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" />
<button className="btn btn-primary btn-sm">참여</button> <button className="btn btn-primary btn-sm">참여</button>
</form> </form>
<Link to="/me" className="header-user" aria-label="내 정보"> {me ? (
{me ? <Avatar user={me} size={36} /> : <span style={{ fontSize: 24 }}>👤</span>} <AccountMenu />
<span className="wide-only">{me ? me.nickname : '시작하기'}</span> ) : (
</Link> <Link to="/login" className="header-user header-login">
로그인
</Link>
)}
</div> </div>
</header> </header>
); );
} }
/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] [로그아웃] 메뉴. */
function AccountMenu() {
const me = useSession((s) => s.me)!;
const nav = useNavigate();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="account-menu" ref={ref}>
<button type="button" className="header-user" aria-haspopup="menu" aria-expanded={open} aria-label={`내 계정: ${me.nickname}`} onClick={() => setOpen((o) => !o)}>
<Avatar user={me} size={36} />
<span className="header-user-name">{me.nickname}</span>
<span aria-hidden className="small muted">
▾
</span>
</button>
{open && (
<div className="account-menu-list" role="menu" aria-label="내 계정">
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/me'))}>
⚙️ 설정
</button>
<button type="button" role="menuitem" onClick={() => (setOpen(false), setConfirm(true))}>
🚪 로그아웃
</button>
</div>
)}
{confirm && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {
await api.logout();
nav('/');
await useSession.getState().refresh();
}}
onClose={() => setConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage'));
const RoomPage = lazy(() => import('./pages/RoomPage')); const RoomPage = lazy(() => import('./pages/RoomPage'));
const GamePage = lazy(() => import('./pages/GamePage')); const GamePage = lazy(() => import('./pages/GamePage'));
const MePage = lazy(() => import('./pages/MePage')); const MePage = lazy(() => import('./pages/MePage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const ReplayPage = lazy(() => import('./pages/ReplayPage')); const ReplayPage = lazy(() => import('./pages/ReplayPage'));
const RulesPage = lazy(() => import('./pages/RulesPage')); const RulesPage = lazy(() => import('./pages/RulesPage'));
const NotFound = lazy(() => import('./pages/NotFound')); const NotFound = lazy(() => import('./pages/NotFound'));
@@ -68,6 +69,7 @@ function Shell() {
<Route path="/r/:code" element={<RoomPage />} /> <Route path="/r/:code" element={<RoomPage />} />
<Route path="/g/:gameId" element={<GamePage />} /> <Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} /> <Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/me" element={<MePage />} /> <Route path="/me" element={<MePage />} />
<Route path="/replay/:gamePk" element={<ReplayPage />} /> <Route path="/replay/:gamePk" element={<ReplayPage />} />
<Route path="/admin/*" element={<AdminApp />} /> <Route path="/admin/*" element={<AdminApp />} />

View File

@@ -1,10 +1,9 @@
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */ /** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + [게임 고르기 | 지금 열린 방] 탭. */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite'; import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui'; import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { CodeInput } from '../components/CodeInput';
import { GameGrid } from '../components/GameGrid'; import { GameGrid } from '../components/GameGrid';
import { InstallCard } from '../components/InstallCard'; import { InstallCard } from '../components/InstallCard';
import { gameName } from '../components/GameNotice'; import { gameName } from '../components/GameNotice';
@@ -21,7 +20,7 @@ export function Home() {
const [needName, setNeedName] = useState<null | (() => void)>(null); const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]); const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [codeKey, setCodeKey] = useState(0); const [tab, setTab] = useState<'games' | 'rooms'>('games');
const available = catalog.filter((g) => g.available); const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id)); const practiceGames = available.filter((g) => botsAllowed(g.id));
@@ -45,15 +44,6 @@ export function Home() {
setBusy(false); setBusy(false);
} }
}); });
const joinCode = async (code: string) => {
try {
await api.room(code);
nav(`/r/${code}`);
} catch (e) {
toast((e as Error).message, 'error');
setCodeKey((k) => k + 1);
}
};
return ( return (
<main className="page" style={{ gap: 28 }}> <main className="page" style={{ gap: 28 }}>
@@ -64,62 +54,68 @@ export function Home() {
</Link> </Link>
)} )}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 20 }}> <section className="home-actions" aria-label="바로 하기">
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}> <button className="home-action primary" aria-label="+ 방 만들기" disabled={busy} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}>
<h2>친구랑 같이 하기</h2> <span className="home-action-icon" aria-hidden>
<p className="muted">방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.</p> +
<button </span>
className="btn btn-primary btn-block" <span className="home-action-title">방 만들기</span>
style={{ minHeight: 72, fontSize: '1.3rem' }} <span className="home-action-sub">친구에게 링크 보내기</span>
disabled={busy} </button>
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))} <button className="home-action" aria-label="코드로 참여" onClick={() => nav('/j')}>
> <span className="home-action-icon" aria-hidden>
+ 방 만들기 🔢
</button> </span>
</div> <span className="home-action-title">코드로 참여</span>
<div className="card stack" style={{ padding: 24 }}> <span className="home-action-sub">받은 숫자 6자리</span>
<h2>코드로 참여하기</h2> </button>
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
</div>
{practiceGames.length > 0 && ( {practiceGames.length > 0 && (
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}> <button className="home-action" aria-label="컴퓨터와 연습" disabled={busy} onClick={() => setPicker('practice')}>
<h2>혼자 연습하기</h2> <span className="home-action-icon" aria-hidden>
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p> 🤖
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}> </span>
🤖 컴퓨터와 연습하기 <span className="home-action-title">컴퓨터와 연습</span>
</button> <span className="home-action-sub">기록에 안 남아요</span>
</div> </button>
)} )}
</section> </section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}> <section className="stack" style={{ gap: 16 }}>
<section className="stack" style={{ gap: 16 }}> <div className="home-tabs" role="tablist" aria-label="홈 보기">
<h2>게임 고르기</h2> <button role="tab" id="tab-games" aria-controls="panel-games" aria-selected={tab === 'games'} onClick={() => setTab('games')}>
<GameGrid games={available} /> 🎮 게임 고르기
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>} </button>
</section> <button role="tab" id="tab-rooms" aria-controls="panel-rooms" aria-selected={tab === 'rooms'} onClick={() => setTab('rooms')}>
<aside className="stack" style={{ gap: 12 }}> 🚪 지금 열린 방{rooms.length > 0 && <span className="badge">{rooms.length}</span>}
<h2>지금 열린 방</h2> </button>
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>} </div>
{rooms.map((r) => ( {tab === 'games' ? (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}> <div role="tabpanel" id="panel-games" aria-labelledby="tab-games" className="stack" style={{ gap: 16 }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden> <GameGrid games={available} />
{GAME_ICON[r.gameId] ?? '🎲'} {UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</span> </div>
<div style={{ flex: 1, minWidth: 0 }}> ) : (
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span> <div role="tabpanel" id="panel-rooms" aria-labelledby="tab-rooms" className="stack" style={{ gap: 12, maxWidth: 720 }}>
<div className="muted small"> {rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
{r.seated}/{r.max}명 · {formatRoomCode(r.code)} {rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
{GAME_ICON[r.gameId] ?? '🎲'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
<div className="muted small">
{r.seated}/{r.max}명 · {formatRoomCode(r.code)}
</div>
</div> </div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div> </div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}> ))}
참여 </div>
</button> )}
</div> </section>
))}
</aside>
</div>
<InstallCard /> <InstallCard />

View File

@@ -0,0 +1,26 @@
/** 로그인만 있는 페이지: 닉네임으로 시작 또는 디스코드 로그인. 설정은 로그인 뒤 헤더 계정 메뉴 → 설정(/me). */
import { Navigate, useNavigate, useSearchParams } from 'react-router';
import { TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { useSession } from '../store/session';
/** Only same-site paths, so ?next= can't send people to another site. */
const safeNext = (v: string | null) => (v && v.startsWith('/') && !v.startsWith('//') ? v : '/');
export default function LoginPage() {
const nav = useNavigate();
const me = useSession((s) => s.me);
const next = safeNext(useSearchParams()[0].get('next'));
if (me) return <Navigate to={next} replace />;
return (
<>
<TopBar title="로그인" onBack={() => nav('/')} />
<main className="page" style={{ maxWidth: 480 }}>
<section className="card stack" style={{ padding: 24 }}>
<h1>로그인</h1>
<NicknameForm next={next} onDone={() => nav(next, { replace: true })} />
</section>
</main>
</>
);
}

View File

@@ -1,9 +1,8 @@
/** 계정 페이지 (docs/05 §7). */ /** 설정(계정) 페이지 (docs/05 §7). 헤더 계정 메뉴 → 설정으로 들어와요. */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, Navigate, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite'; import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui'; import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { gameName } from '../components/GameNotice'; import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api'; import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
@@ -67,24 +66,12 @@ export default function MePage() {
</section> </section>
); );
if (!me) { // 로그인 안 했으면 로그인 페이지로 (로그인 페이지에는 로그인만 있어요).
return ( if (!me) return <Navigate to="/login?next=/me" replace />;
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack">
<h2>시작하기</h2>
<NicknameForm next="/me" />
</section>
{display}
</main>
</>
);
}
return ( return (
<> <>
<TopBar title="내 정보" onBack={() => nav('/')} /> <TopBar title="설정" onBack={() => nav('/')} />
<main className="page"> <main className="page">
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}> <section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
<Avatar user={me} size={88} /> <Avatar user={me} size={88} />
@@ -177,8 +164,8 @@ export default function MePage() {
danger={me.kind === 'guest'} danger={me.kind === 'guest'}
onConfirm={async () => { onConfirm={async () => {
await api.logout(); await api.logout();
await useSession.getState().refresh();
nav('/'); nav('/');
await useSession.getState().refresh();
}} }}
onClose={() => setConfirm(null)} onClose={() => setConfirm(null)}
/> />
@@ -189,8 +176,8 @@ export default function MePage() {
confirm="모두 로그아웃" confirm="모두 로그아웃"
onConfirm={async () => { onConfirm={async () => {
await api.logoutAll(); await api.logoutAll();
await useSession.getState().refresh();
nav('/'); nav('/');
await useSession.getState().refresh();
}} }}
onClose={() => setConfirm(null)} onClose={() => setConfirm(null)}
/> />
@@ -203,8 +190,8 @@ export default function MePage() {
danger danger
onConfirm={async () => { onConfirm={async () => {
await api.deleteMe(); await api.deleteMe();
await useSession.getState().refresh();
nav('/'); nav('/');
await useSession.getState().refresh();
}} }}
onClose={() => setConfirm(null)} onClose={() => setConfirm(null)}
/> />

View File

@@ -626,6 +626,60 @@ label.field {
.header-user:hover { .header-user:hover {
background: var(--surface-2); background: var(--surface-2);
} }
button.header-user {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.header-login {
padding: 0 18px;
min-height: 42px;
border: 2px solid var(--primary);
color: var(--primary);
}
.header-login:hover {
background: var(--primary-soft);
}
.header-user-name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-menu {
position: relative;
}
.account-menu-list {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 168px;
padding: 6px;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
z-index: 40;
}
.account-menu-list button {
min-height: var(--tap);
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.account-menu-list button:hover,
.account-menu-list button:focus-visible {
background: var(--surface-2);
}
@media (max-width: 1099px) { @media (max-width: 1099px) {
.site-nav { .site-nav {
display: none; display: none;
@@ -648,6 +702,9 @@ label.field {
.site-logo { .site-logo {
flex: 1; flex: 1;
} }
.header-user-name {
max-width: 96px;
}
} }
/* Room bar under the site header */ /* Room bar under the site header */
@@ -1034,3 +1091,97 @@ label.field {
border-top: 0; border-top: 0;
padding-top: 0; padding-top: 0;
} }
/* Home: 큰 버튼 3개 + 탭 (docs/07 §3.3) */
.home-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.home-action {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-height: 132px;
padding: 20px 22px;
border: 2px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
box-shadow: var(--shadow);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.home-action:disabled {
opacity: 0.6;
cursor: default;
}
.home-action.primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-text);
}
.home-action-icon {
font-size: 2.2rem;
line-height: 1;
margin-bottom: auto;
}
.home-action-title {
font-size: 1.3rem;
font-weight: 800;
}
.home-action-sub {
font-size: 0.95rem;
color: var(--text-sub);
}
.home-action.primary .home-action-sub {
color: inherit;
}
@media (hover: hover) {
.home-action:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
}
@media (max-width: 719px) {
.home-actions {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.home-action.primary {
grid-column: 1 / -1;
min-height: 96px;
}
.home-action {
min-height: 108px;
padding: 16px;
}
}
.home-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--border);
}
.home-tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
padding: 0 18px;
margin-bottom: -2px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: var(--text-sub);
font: inherit;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
}
.home-tabs button[aria-selected='true'] {
color: var(--primary);
border-bottom-color: var(--primary);
}

View File

@@ -57,17 +57,20 @@
└──────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────┘
``` ```
- 어느 화면에서든 코드를 바로 넣어 참여할 수 있게 상단에 코드 입력칸을 둔다. - 어느 화면에서든 코드를 바로 넣어 참여할 수 있게 상단에 코드 입력칸을 둔다.
- 오른쪽 끝: 로그인 전에는 [로그인](→ `/login`, 닉네임으로 시작·디스코드 로그인만 있음). 로그인 뒤에는 닉네임(디스코드로 가입했으면 디스코드 이름)을 보이고, 누르면 [⚙️ 설정](→ `/me`)·[🚪 로그아웃] 메뉴가 열린다. 화면·소리 설정은 `/me`에만 있다.
- 내용 폭은 최대 1280px, 가운데 정렬. 게임 화면만 화면 전체 폭을 쓴다. - 내용 폭은 최대 1280px, 가운데 정렬. 게임 화면만 화면 전체 폭을 쓴다.
### 3.3 홈 (PC) ### 3.3 홈 (PC)
``` ```
┌ 상단 메뉴 ───────────────────────────────────────────────────────────────┐ ┌ 상단 메뉴 ───────────────────────────────────────────────────────────────┐
│ [+ 방 만들기 (큰 버튼)] [코드로 참여: □□□ □□□ ] [▶ 하던 게임으로] │ │ [▶ 하던 게임으로 돌아가기] (있을 때만) │
├──────────────────────────────────────────────────────┬───────────────────┤ │ [+ 방 만들기 (파란 큰 버튼)] [🔢 코드로 참여 → /j] [🤖 컴퓨터와 연습] │
│ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) │ 지금 열린 공개 방 │ ├──────────────────────────────────────────────────────────────────────────┤
│ │ (게임·인원·참여) │ │ [🎮 게임 고르기] [🚪 지금 열린 방 (n)] ← 탭, 한 번에 하나만 보임 │
└──────────────────────────────────────────────────────┴───────────────────┘ │ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) 또는 공개 방 목록(게임·인원·참여) │
└──────────────────────────────────────────────────────────────────────────┘
``` ```
- 첫 화면에 다 넣지 않는다. 설명 글은 버튼 아래 한 줄만, 나머지는 탭이나 버튼을 눌러 들어간다.
### 3.4 대기실 (PC) ### 3.4 대기실 (PC)
``` ```
@@ -163,7 +166,7 @@
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인) - 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
## 11. PWA (홈 화면 추가·오프라인 안내) ## 11. PWA (홈 화면 추가·오프라인 안내)
- 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘 `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 판은 가운데 안전 영역 안), apple-touch-icon 180. 다시 만들기: `bun scripts/pwa-icons.ts`(Chrome으로 그림). - 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘은 "게임천국" 느낌의 게임패드 + 천사 고리 + 반짝이(보라→분홍→주황 배경). `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 그림은 가운데 안전 영역 안), apple-touch-icon 180. favicon.svg와 PNG 모두 `bun scripts/pwa-icons.ts`가 한 디자인에서 만든다(Chrome으로 그림).
- 서비스 워커: 손으로 쓴 `apps/web/sw.js`를 빌드 때 Vite 플러그인이 빌드 ID(파일 이름 해시 기준)를 넣어 `dist/sw.js`로 낸다. 배포 빌드에서만 등록한다(개발 서버에서는 등록 안 함). - 서비스 워커: 손으로 쓴 `apps/web/sw.js`를 빌드 때 Vite 플러그인이 빌드 ID(파일 이름 해시 기준)를 넣어 `dist/sw.js`로 낸다. 배포 빌드에서만 등록한다(개발 서버에서는 등록 안 함).
| 요청 | 처리 | | 요청 | 처리 |

View File

@@ -1,20 +1,44 @@
/** /**
* Renders the PWA icons (docs/07 §11) from the favicon design with headless Chrome. * Renders the site icon (docs/07 §11) with headless Chrome.
* bun scripts/pwa-icons.ts → apps/web/public/icons/*.png * bun scripts/pwa-icons.ts → apps/web/public/favicon.svg + apps/web/public/icons/*.png
* "any" icons keep the favicon's rounded corners; maskable/apple icons are full-bleed * One design (아케이드 게임기 + 천사 고리 + 반짝이) is the source of every icon:
* (the OS applies its own mask) with the board inside the 40% safe circle. * "any" icons keep rounded corners; maskable/apple icons are full-bleed
* (the OS applies its own mask) with the art shrunk into the safe circle.
*/ */
import { chromium } from 'playwright-core'; import { chromium } from 'playwright-core';
import { mkdirSync, readFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
const PUBLIC = join(import.meta.dir, '../apps/web/public'); const PUBLIC = join(import.meta.dir, '../apps/web/public');
const OUT = join(PUBLIC, 'icons'); const OUT = join(PUBLIC, 'icons');
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const rounded = readFileSync(join(PUBLIC, 'favicon.svg'), 'utf8'); const INK = '#1e1b4b';
// Same board as the favicon, shrunk to 80% so every stroke stays inside the safe circle. const DEFS = `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c3aed"/><stop offset=".55" stop-color="#db2777"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs>`;
const fullBleed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#E3B76B"/><g transform="translate(32 32) scale(0.8) translate(-32 -32)"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40" stroke="#7a5a22" stroke-width="2"/><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></g></svg>`; const star = (x: number, y: number, r: number, fill: string) =>
`<path d="M${x} ${y - r}Q${x} ${y} ${x + r} ${y}Q${x} ${y} ${x} ${y + r}Q${x} ${y} ${x - r} ${y}Q${x} ${y} ${x} ${y - r}z" fill="${fill}"/>`;
const ART = [
// 천사 고리: "게임천국"
`<ellipse cx="32" cy="14.5" rx="11" ry="3.6" fill="none" stroke="#fde047" stroke-width="2.6"/>`,
// 게임 패드
`<path d="M20 21h24a10 10 0 0 1 9.6 7.2l3.3 11.6a5.6 5.6 0 0 1-9.5 5.3L43 40H21l-4.4 5.1a5.6 5.6 0 0 1-9.5-5.3l3.3-11.6A10 10 0 0 1 20 21z" fill="#fff" stroke="${INK}" stroke-width="2.2" stroke-linejoin="round"/>`,
`<path d="M19.6 26.2h2.8v3h3v2.8h-3v3h-2.8v-3h-3v-2.8h3z" fill="${INK}"/>`,
`<circle cx="43" cy="26.6" r="2.3" fill="#facc15" stroke="${INK}" stroke-width="1"/>`,
`<circle cx="46.8" cy="30.6" r="2.3" fill="#ef4444" stroke="${INK}" stroke-width="1"/>`,
`<circle cx="43" cy="34.6" r="2.3" fill="#22c55e" stroke="${INK}" stroke-width="1"/>`,
`<circle cx="39.2" cy="30.6" r="2.3" fill="#3b82f6" stroke="${INK}" stroke-width="1"/>`,
// 반짝이
star(53, 11, 4.6, '#fde047'),
star(10.5, 14, 3, '#fff'),
star(54, 53, 3, '#fff'),
star(11, 54, 2.2, '#fde047'),
].join('');
const rounded = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${DEFS}<rect width="64" height="64" rx="14" fill="url(#bg)"/>${ART}</svg>\n`;
const fullBleed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${DEFS}<rect width="64" height="64" fill="url(#bg)"/><g transform="translate(32 32) scale(0.78) translate(-32 -32)">${ART}</g></svg>`;
writeFileSync(join(PUBLIC, 'favicon.svg'), rounded);
console.log('✓ favicon.svg');
const ICONS: [file: string, size: number, svg: string][] = [ const ICONS: [file: string, size: number, svg: string][] = [
['icon-192.png', 192, rounded], ['icon-192.png', 192, rounded],