style(frontend): ABAP_OPENCODE 디자인 시스템으로 맞춤 + 화면 재구성

DESIGN_SYSTEM.md 의 Dark Workspace 토큰을 shadcn 토큰으로 이식(다크 기본, radius 8px,
success/warning/shadow-float), 시스템 산세리프, PwC 로고(LogoMark). 목록·새 대화·대화·
로그인 화면을 가운데 760px 열로 통일하고, 떠 있던 테마·로그아웃 상자는 상단 바로,
말풍선은 다크 토큰(AI 답변은 상자 없이), ABAP 흰 에디터 룩은 라이트 모드에서만.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 21:24:17 +09:00
co-authored by Claude Fable 5.1
parent 506fbf601d
commit 9c6b28782d
32 changed files with 490 additions and 353 deletions
+4 -4
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Frontend Template</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<title>CodeAssist</title>
<script>
// FOUC 방지 — React 마운트 전 [data-theme] + .dark 미리 박음.
// 팔레트는 localStorage 'theme' 키, 다크모드는 'theme-store' (zustand persist).
@@ -21,10 +21,10 @@
}
document.documentElement.setAttribute("data-theme", pal)
var raw = localStorage.getItem("theme-store")
var mode = "light"
var mode = "dark" // 저장값 없으면 다크 (themeStore 기본과 동일)
if (raw) {
try {
mode = (JSON.parse(raw).state || {}).theme || "light"
mode = (JSON.parse(raw).state || {}).theme || "dark"
} catch (e) {}
}
var dark =
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<text x="50%" y="50%" dy="0.35em" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
font-weight="700" font-size="36" fill="#38bdf8">F</text>
</svg>

Before

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -37,7 +37,7 @@ describe("LoginForm 회귀", () => {
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비"), "secret")
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
expect(mutate).toHaveBeenCalledOnce()
@@ -47,7 +47,7 @@ describe("LoginForm 회귀", () => {
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
const { container } = renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
await userEvent.type(screen.getByLabelText("비"), "secret")
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
const form = container.querySelector("form")!
@@ -68,7 +68,7 @@ describe("LoginForm 회귀", () => {
it("from=/snap/new + 성공 → navigate('/snap/new')", async () => {
renderForm("?from=/snap/new")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비"), "secret")
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
@@ -79,7 +79,7 @@ describe("LoginForm 회귀", () => {
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 가드)", async () => {
renderForm("?from=//evil.com")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비"), "secret")
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
@@ -36,9 +36,9 @@ describe("LoginPage", () => {
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("h1 '로그인' 표시", () => {
it("h1 'CodeAssist' 브랜드 제목 표시", () => {
renderPage()
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 1, name: "CodeAssist" })).toBeInTheDocument()
})
it("로그인 입력 영역을 화면 가운데 정렬", () => {
@@ -1,13 +1,24 @@
import { LogoMark } from "@/shared/components/LogoMark"
import LoginForm from "../components/LoginForm"
import EntraLoginSection from "../components/EntraLoginSection"
/** 로그인 — ABAP_OPENCODE 로그인 카드와 같은 구성(로고 · 제목 · 부제 · 폼 · 안내). */
export default function LoginPage() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-semibold"></h1>
<div className="flex flex-1 items-center justify-center px-4 py-6">
<div className="bg-card border-border flex w-full max-w-[414px] flex-col gap-3.5 rounded-2xl border px-8 pt-9 pb-7 shadow-[var(--shadow-float)]">
<div className="mb-1 flex justify-center">
<LogoMark width={52} />
</div>
<h1 className="text-center text-[19px] font-semibold">CodeAssist</h1>
<p className="text-muted-foreground mb-3 text-center text-[13px]">
SAP GUI · Eclipse ABAP
</p>
<EntraLoginSection />
<LoginForm />
<p className="text-muted-foreground mt-1 text-center text-xs">
.
</p>
</div>
</div>
)
@@ -19,20 +19,20 @@ export function ChatHeader({ session, onBack }: Props) {
// 게이지 색 — 90%↑ 빨강, 70%↑ 주황, 그 외 기본.
const barColor = ratio >= 0.9 ? "bg-red-500" : ratio >= 0.7 ? "bg-amber-500" : "bg-primary"
return (
<div className="border-border bg-card flex flex-none items-center gap-2 border-b px-3 py-2.5">
<div className="border-border flex flex-none items-center gap-2 border-b px-3 py-2">
<button
type="button"
onClick={onBack}
title="목록으로 (Esc)"
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ChevronLeft className="size-3" />
<ChevronLeft className="size-3.5" aria-hidden="true" />
<Kbd>Esc</Kbd>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
<div className="text-muted-foreground font-mono text-[9.5px] tracking-wide uppercase">
<div className="truncate text-[15px] font-semibold">{title}</div>
<div className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase">
{(session.tag ?? "SESSION").toUpperCase()} ·
</div>
</div>
@@ -41,7 +41,7 @@ export function ChatHeader({ session, onBack }: Props) {
className="flex flex-none flex-col items-end gap-1"
title={`${used.toLocaleString()} / ${limit.toLocaleString()} tokens`}
>
<span className="text-muted-foreground font-mono text-[10px] tabular-nums">
<span className="text-muted-foreground text-[10.5px] tabular-nums">
{fmtTokens(used)}
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
</span>
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
import { Check, ClipboardPaste, Copy } from "lucide-react"
import { toast } from "sonner"
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import { useThemeStore } from "@/shared/store/themeStore"
import hljs from "highlight.js/lib/core"
import sql from "highlight.js/lib/languages/sql"
import json from "highlight.js/lib/languages/json"
@@ -47,10 +48,11 @@ interface Props {
export function CodeBlock({ code, lang, index, plain = false }: Props) {
const key = (lang ?? "").toLowerCase()
const resolved = ALIAS[key] ?? key
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
// ABAP 은 SAP 에디터처럼 밝은 배경으로 — 단, 앱이 라이트 모드일 때만. 다크 캔버스 위 흰 상자는 튐.
const isAbap = resolved === "abap"
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
const isLight = isAbap || resolved === ""
const appLight = useThemeStore((s) => s.resolved) === "light"
// 언어 없는 펜스(ASCII 다이어그램 등)도 같은 규칙.
const isLight = appLight && (isAbap || resolved === "")
const html = useMemo(() => {
if (resolved && hljs.getLanguage(resolved)) {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
@@ -83,7 +85,7 @@ export function CodeBlock({ code, lang, index, plain = false }: Props) {
<span className="flex gap-1">
<span className="size-2 rounded-full bg-rose-400/70" />
<span className="size-2 rounded-full bg-amber-400/70" />
<span className="size-2 rounded-full bg-emerald-400/70" />
<span className="bg-success/70 size-2 rounded-full" />
</span>
<span
className={
@@ -114,7 +116,7 @@ export function CodeBlock({ code, lang, index, plain = false }: Props) {
</div>
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
<code
className={isAbap ? "hljs-abap-light" : undefined}
className={isAbap && isLight ? "hljs-abap-light" : undefined}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
@@ -157,8 +157,9 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
}
}
return (
<div className="border-border bg-card flex-none border-t p-3">
<div className="border-border bg-background focus-within:border-ring focus-within:ring-ring/20 rounded-lg border p-2 focus-within:ring-2">
// 목록·새 대화와 같은 가운데 열(760px). 테두리 상자 하나에 입력 + 도구 줄.
<div className="flex-none px-5 pt-2 pb-3">
<div className="border-border bg-card focus-within:border-ring mx-auto w-full max-w-[760px] rounded-lg border px-3 pt-2.5 pb-2 transition-colors">
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
@@ -192,7 +193,7 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
key={i}
className="border-border bg-muted/60 text-muted-foreground flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
>
<ClipboardPaste className="size-3.5 flex-none text-emerald-500" />
<ClipboardPaste className="text-success size-3.5 flex-none" />
<span className="min-w-0 truncate font-mono">
{a.replace(/\s+/g, " ").slice(0, 40)}
</span>
@@ -237,17 +238,17 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
rows={1}
disabled={busy}
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
className="placeholder:text-muted-foreground field-sizing-content max-h-32 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
className="placeholder:text-muted-foreground/70 field-sizing-content max-h-40 min-h-9 w-full resize-none bg-transparent text-[13.5px] leading-relaxed outline-none"
/>
<div className="mt-2 flex items-center gap-2">
<div className="mt-1.5 flex items-center gap-1">
<button
type="button"
onClick={pasteFromClipboard}
disabled={busy || !clipboardSupported}
title={clipboardSupported ? "클립보드에서 가져오기" : "이 환경은 클립보드 읽기 미지원"}
className="border-border text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors disabled:opacity-50"
className="text-muted-foreground hover:bg-accent hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors disabled:opacity-50"
>
<ClipboardPaste className="size-3" />
<ClipboardPaste className="size-3.5" aria-hidden="true" />
</button>
<button
@@ -256,16 +257,16 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
aria-pressed={explain}
title={explain ? "설명 모드 — 배경·원리까지" : "간결 모드 — 답만"}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors",
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors",
explain
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
? "bg-primary/12 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<BookOpen className="size-3" />
<BookOpen className="size-3.5" aria-hidden="true" />
</button>
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
<span className="text-muted-foreground/70 ml-1 hidden text-[10.5px] sm:inline">
Enter · Shift+Enter
</span>
{busy ? (
@@ -273,17 +274,17 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
type="button"
size="sm"
variant="destructive"
className="ml-auto"
className="ml-auto h-7 px-2.5 text-[12px]"
onClick={onStop}
>
<Square className="size-3.5 fill-current" />
<Square className="size-3 fill-current" />
</Button>
) : (
<Button
type="button"
size="sm"
className="ml-auto"
className="ml-auto h-7 px-2.5 text-[12px]"
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
onClick={submit}
>
@@ -1,17 +1,41 @@
export function Hero() {
import { LogoMark } from "@/shared/components/LogoMark"
interface Props {
/** 예시 질문 칩을 누르면 그 문장으로 바로 대화 시작. */
onPick?: (text: string) => void
disabled?: boolean
}
// 실제로 자주 묻는 것들 — 누르면 바로 답이 오는 걸 보여주는 게 빈 화면 설명보다 낫다.
const EXAMPLES = [
"MARA 에서 자재번호로 자재유형(MTART) 읽는 SELECT SINGLE",
"셀렉션 화면 있는 ALV 리포트 뼈대 (CL_SALV_TABLE)",
"내부 테이블 LOOP 에서 그룹 소계 구하는 관용구",
]
export function Hero({ onPick, disabled }: Props) {
return (
<section className="flex flex-col items-center px-6 py-10 text-center">
<div className="bg-primary text-primary-foreground mb-4 grid size-14 place-items-center rounded-xl font-serif text-2xl font-bold italic">
S
</div>
<span className="text-muted-foreground mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] tracking-widest uppercase">
<span className="size-1.5 rounded-full bg-emerald-500" />
New Session
</span>
<h1 className="mb-2 font-serif text-2xl font-semibold italic"> ?</h1>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
ABAP · CDS · HANA · .
<section className="flex w-full max-w-[560px] flex-col items-center py-6 text-center">
<LogoMark width={56} className="mb-5" />
<h1 className="mb-1.5 text-[21px] font-semibold text-balance"> ?</h1>
<p className="text-muted-foreground mb-6 text-[13px] leading-relaxed">
ABAP · CDS · HANA · . .
</p>
{onPick && (
<div className="flex flex-wrap justify-center gap-2">
{EXAMPLES.map((text) => (
<button
key={text}
type="button"
disabled={disabled}
onClick={() => onPick(text)}
className="border-border bg-card text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
{text}
</button>
))}
</div>
)}
</section>
)
}
@@ -23,8 +23,8 @@ describe("Message 코드펜스 렌더", () => {
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
const block = container.querySelector("[data-code-block]")
expect(block).not.toBeNull()
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
expect(block?.className).toContain("bg-white")
// 코드 상자 색은 앱 테마를 따름 — 기본(다크)에선 어두운 코드 상자, 라이트 모드면 흰 에디터 룩
expect(block?.className).toContain("bg-[#0d1117]")
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
expect(container.querySelector("code.rounded")).toBeNull()
})
@@ -65,12 +65,12 @@ export function Message({ role, content, totalTokens, elapsedMs }: Props) {
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
<div className={cn("flex items-center gap-1.5", !isUser && "w-full")}>
{!isUser && (
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded font-serif text-[9px] leading-none font-bold italic">
S
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded text-[8px] leading-none font-semibold">
AI
</span>
)}
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
{isUser ? "나 · YOU" : "SNAP MATE"}
{isUser ? "나 · YOU" : "CODEASSIST"}
</span>
{!isUser && (
<DropdownMenu>
@@ -99,9 +99,10 @@ export function Message({ role, content, totalTokens, elapsedMs }: Props) {
<div
className={cn(
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
// 질문 = 오른쪽 연한 칩, 답변 = 상자 없이 본문 글자(ABAP_OPENCODE 대화 톤). 코드블럭은 자기 상자를 가짐.
isUser
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
: "border-border bg-card text-card-foreground w-full border"
: "text-foreground w-full px-0 py-0"
)}
>
{isUser ? (
@@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
<ClipboardHistory />
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav
</span>
@@ -79,7 +79,7 @@ export function NavRail({ blocks, containerRef }: Props) {
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
>
{copiedIdx === b.index ? (
<Check className="size-3 text-emerald-500" />
<Check className="size-3 text-success" />
) : (
<Copy className="size-3" />
)}
@@ -1,7 +1,8 @@
import { CornerDownRight, MessageSquare } from "lucide-react"
import { MessageSquare } from "lucide-react"
import type { ReactNode } from "react"
import type { SnapMessage } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
import { cn } from "@/lib/utils/cn"
interface Props {
hit: SnapMessage
@@ -11,8 +12,17 @@ interface Props {
selected?: boolean
}
// 마크다운 코드펜스 기호는 빼고 한 줄로 — 목록에서 ```abap 같은 게 보이면 안 되니까.
function plain(text: string): string {
return text
.replace(/```[a-z0-9_-]*/gi, "")
.replace(/\s+/g, " ")
.trim()
}
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
function snippet(text: string, query: string): ReactNode {
function snippet(raw: string, query: string): ReactNode {
const text = plain(raw)
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx < 0) return text.slice(0, 120)
const start = Math.max(0, idx - 40)
@@ -30,29 +40,35 @@ function snippet(text: string, query: string): ReactNode {
)
}
/** 검색 결과 한 행 — SessionCard 와 같은 행 규칙(블록이 테두리, 행은 배경만). */
export function SearchHitCard({ hit, query, onOpen, selected }: Props) {
return (
<button
type="button"
onClick={() => onOpen(hit.sessionId)}
data-snap-selected={selected ? "true" : undefined}
className={`focus-visible:ring-ring flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
className={cn(
"focus-visible:bg-accent flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"
)}
>
<MessageSquare className="text-muted-foreground mt-0.5 size-4 flex-none" />
<span className="flex min-w-0 flex-col">
<span className="line-clamp-2 text-xs leading-relaxed break-words whitespace-pre-wrap">
<MessageSquare
className={cn(
"mt-0.5 size-3.5 flex-none",
selected ? "text-primary" : "text-muted-foreground"
)}
aria-hidden="true"
/>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="line-clamp-2 text-[12.5px] leading-relaxed break-words whitespace-pre-wrap">
{snippet(hit.content, query)}
</span>
<span className="text-muted-foreground mt-1.5 flex items-center gap-2 font-mono text-[9.5px]">
<span className="tracking-wide uppercase">
{hit.role === "user" ? "나" : "SNAP MATE"}
<span className="text-muted-foreground flex items-center gap-1.5 text-[10.5px]">
<span className="font-semibold tracking-wide uppercase">
{hit.role === "user" ? "나" : "CodeAssist"}
</span>
<span>·</span>
<span>{formatRelativeKo(hit.createdAt)}</span>
<CornerDownRight className="size-3" />
<span> </span>
<span aria-hidden="true">·</span>
<span className="tabular-nums">{formatRelativeKo(hit.createdAt)}</span>
</span>
</span>
</button>
@@ -1,6 +1,7 @@
import { MessageSquare, ChevronRight } from "lucide-react"
import { MessageSquare } from "lucide-react"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
import { cn } from "@/lib/utils/cn"
interface Props {
session: SnapSession
@@ -9,35 +10,39 @@ interface Props {
selected?: boolean
}
/** 목록 한 행. 테두리는 목록 블록이 갖고, 행은 배경 한 단계(호버)·선택색(sel-bg)만 바뀜. */
export function SessionCard({ session, onOpen, selected }: Props) {
const title = session.titleLlm ?? session.title ?? "제목 없음"
return (
// 컴팩트 1행 — 세로를 1/3로. 제목·태그·시간·상태를 한 줄에, 스니펫은 생략(제목 title 로 노출).
<button
type="button"
onClick={() => onOpen(session.id)}
data-snap-selected={selected ? "true" : undefined}
title={session.snippet ?? title}
className={`focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
className={cn(
"focus-visible:bg-accent flex h-10 w-full items-center gap-2.5 px-3 text-left transition-colors focus-visible:outline-none",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"
)}
>
<span className="bg-muted text-muted-foreground grid size-8 flex-none place-items-center rounded-lg">
<MessageSquare className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
<MessageSquare
className={cn("size-3.5 flex-none", selected ? "text-primary" : "text-muted-foreground")}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{title}</span>
{session.tag && (
<span className="border-border text-muted-foreground flex-none rounded border px-1.5 font-mono text-[9.5px] tracking-wide uppercase">
<span className="bg-secondary text-muted-foreground flex-none rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase">
{session.tag}
</span>
)}
{session.isGenerating && (
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
<span className="text-success flex items-center gap-1 text-[10.5px]" title="생성 중">
<span className="bg-success size-1.5 rounded-full" />
</span>
)}
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
<span className="text-muted-foreground flex-none text-[11px] tabular-nums">
{formatRelativeKo(session.updatedAt)}
</span>
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
</button>
)
}
@@ -6,6 +6,7 @@ interface Props {
onChange: (v: string) => void
}
/** 런처 검색 입력. 테두리·포커스 표시는 부모 검색 행이 맡고(focus-within), 여긴 입력만. */
export function SessionSearch({ value, onChange }: Props) {
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
@@ -18,8 +19,8 @@ export function SessionSearch({ value, onChange }: Props) {
}, [])
return (
<div className="relative flex min-w-0 flex-1 items-center">
<Search className="text-muted-foreground pointer-events-none absolute left-0 size-5" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<Search className="text-muted-foreground size-4 flex-none" aria-hidden="true" />
<input
ref={ref}
type="text"
@@ -27,19 +28,19 @@ export function SessionSearch({ value, onChange }: Props) {
onChange={(e) => onChange(e.target.value)}
aria-label="대화 내용 검색"
placeholder="대화 내용 검색…"
className="placeholder:text-muted-foreground/75 focus-visible:ring-ring/40 w-full rounded-md bg-transparent py-3 pr-9 pl-9 text-lg outline-none focus-visible:ring-2"
className="placeholder:text-muted-foreground/70 h-full min-w-0 flex-1 bg-transparent text-[14px] outline-none"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring absolute right-1 rounded p-1 focus-visible:ring-2"
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring flex size-6 flex-none items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
onChange("")
ref.current?.focus()
}}
>
<X className="size-4" />
<X className="size-3.5" />
</button>
)}
</div>
@@ -4,8 +4,10 @@ import { Outlet, useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
import { SnapUserControls } from "./SnapUserControls"
import { LogoMark } from "@/shared/components/LogoMark"
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
/** Snap 셸. 웹에선 얇은 브랜드 바(드래그 영역)에 테마·로그아웃까지 넣고,
* Tauri 에선 창 제목줄(DesktopWindowFrame)이 같은 역할을 하므로 여기선 안 그림. */
export function SnapLayout() {
const navigate = useNavigate()
@@ -24,19 +26,20 @@ export function SnapLayout() {
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 헤더 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Chat Everywhere
</span>
<header className="border-border flex h-9 flex-none items-center gap-2 border-b pr-2 pl-3 select-none">
{/* 로고 쪽 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
<div
role="presentation"
onMouseDown={startWindowDrag}
className="flex h-full min-w-0 flex-1 items-center gap-2"
>
<LogoMark width={26} />
<span className="text-[11px] font-semibold tracking-wide">CodeAssist</span>
</div>
<SnapUserControls />
</header>
)}
<SnapUserControls />
<div className="min-h-0 flex-1">
<Outlet />
</div>
@@ -5,7 +5,7 @@ import { useLogout } from "@/features/auth/hooks/useLogout"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { Button } from "@/shared/ui/button"
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
/** 테마 변경 + 로그아웃. 상단 바(웹: SnapLayout 헤더, Tauri: 창 제목줄) 오른쪽에 인라인으로 들어감. */
export function SnapUserControls() {
const navigate = useNavigate()
const logout = useLogout()
@@ -17,18 +17,19 @@ export function SnapUserControls() {
}
return (
<div className="border-border bg-background/90 fixed top-11 right-3 z-40 flex items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur">
<div className="flex items-center gap-0.5">
<ThemeToggle />
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-7"
aria-label="로그아웃"
title="로그아웃"
disabled={logout.isPending}
onClick={handleLogout}
>
<LogOut className="size-4" aria-hidden="true" />
<LogOut className="size-3.5" aria-hidden="true" />
</Button>
</div>
)
@@ -1,19 +1,19 @@
import { useRef, useState } from "react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft } from "lucide-react"
import { PATHS } from "@/config/routes"
import { Kbd } from "@/shared/components/Kbd"
import { useCreateSession } from "../api/snap.api"
import { Hero } from "../components/Hero"
import { NavRail } from "../components/NavRail"
import { Composer } from "../components/Composer"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput } from "../contract/types"
/** 새 대화 — 목록과 같은 가운데 열(760px). 코드블럭 레일은 대화가 생긴 뒤(SessionChatPage)부터. */
export default function NewChatPage() {
const navigate = useNavigate()
const createSession = useCreateSession()
const [pending, setPending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// 단계적 Esc: 새 대화 화면에선 목록으로.
useEscapeKey(() => navigate(PATHS.SNAP))
@@ -26,31 +26,26 @@ export default function NewChatPage() {
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text, firstImages: images } })
}
// 새 대화도 좌측 레일 유지 — 클립보드 이력은 바로 쓰고, Source Nav 는 코드블럭 없음 상태로 표시.
return (
<div className="flex h-full flex-col">
<div className="border-border bg-card flex flex-none items-center border-b px-3 py-2.5">
<div className="mx-auto flex w-full max-w-[760px] flex-none items-center px-5 pt-3">
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring -ml-2 inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ChevronLeft className="size-3" />
<ChevronLeft className="size-3.5" aria-hidden="true" />
<Kbd>Esc</Kbd>
</button>
</div>
<div className="flex min-h-0 flex-1">
<NavRail blocks={[]} containerRef={scrollRef} />
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl">
<Hero />
</div>
</div>
<div className="grid min-h-0 flex-1 place-items-center overflow-y-auto px-5">
<Hero onPick={(text) => void start(text, [])} disabled={pending} />
</div>
<Composer
onSend={start}
busy={pending}
placeholder="새 대화를 시작하세요 — 질문을 입력하거나 코드를 붙여넣어봐…"
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
acceptCapture
/>
</div>
@@ -146,83 +146,86 @@ export default function SessionChatPage() {
<ChatHeader session={session} onBack={() => navigate(PATHS.SNAP)} />
<div className="flex min-h-0 flex-1">
<NavRail blocks={navBlocks} containerRef={scrollRef} />
<div
ref={scrollRef}
onWheel={onWheel}
onTouchMove={restick}
className="min-w-0 flex-1 overflow-y-auto"
>
<div className="theme-light mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.map((m, i) => {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
{m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span>
) : (
<StreamingText
text={m.content}
isStreaming={isStreaming}
cps={{ baseCps: 200, maxCps: 200, startEmpty: m.reveal }}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
)}
{/* 본문 열 — 메시지와 입력창이 같은 열(같은 폭·같은 왼쪽 끝)에 놓이게 여기서 묶음 */}
<div className="flex min-w-0 flex-1 flex-col">
<div
ref={scrollRef}
onWheel={onWheel}
onTouchMove={restick}
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
>
<div className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 py-4">
{messages.map((m, i) => {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
CODEASSIST
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
{m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span>
) : (
<StreamingText
text={m.content}
isStreaming={isStreaming}
cps={{ baseCps: 200, maxCps: 200, startEmpty: m.reveal }}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
)}
</div>
</div>
</div>
)
}
if (m.frozen && m.role === "assistant") {
)
}
if (m.frozen && m.role === "assistant") {
return (
<div key={m.id} className="flex flex-col gap-2">
<Message
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
)
}
return (
<div key={m.id} className="flex flex-col gap-2">
<Message
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
<Message
key={m.id}
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
)
}
return (
<Message
key={m.id}
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
)
})}
{generatingRemotely && (
<div className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
<span className="shimmer-text text-sm"> </span>
})}
{generatingRemotely && (
<div className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
CODEASSIST
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
<span className="shimmer-text text-sm"> </span>
</div>
</div>
</div>
)}
)}
</div>
</div>
<Composer
onSend={(t, images) => {
stickRef.current = true // 전송 시 바닥 고정 재개 → 새 질문/답변 따라감
void send(t, images)
}}
busy={busy || generatingRemotely}
onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
/>
</div>
</div>
<Composer
onSend={(t, images) => {
stickRef.current = true // 전송 시 바닥 고정 재개 → 새 질문/답변 따라감
void send(t, images)
}}
busy={busy || generatingRemotely}
onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
/>
</div>
)
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { cn } from "@/lib/utils/cn"
import { PATHS } from "@/config/routes"
import { useDebounce } from "@/lib/hooks/useDebounce"
import { hideWindow } from "@/lib/bridge/webviewBridge"
@@ -83,53 +84,51 @@ export default function SessionListPage() {
}, [navigate])
return (
<div className="scrollbar-hide mx-auto flex h-full max-w-2xl flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center gap-3">
<div className="bg-primary text-primary-foreground grid size-9 flex-none place-items-center rounded-md font-serif text-lg font-bold italic">
S
</div>
<div className="flex flex-col">
<span className="font-serif text-base font-semibold italic"> </span>
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
Snap Mate · Workspace
</span>
</div>
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-3 px-5 pt-6 pb-5">
{/* 검색 행 = 런처의 명령줄. 오른쪽에 새 대화. */}
<div className="border-border bg-card focus-within:border-ring flex h-11 flex-none items-center gap-2 rounded-lg border pr-1.5 pl-3 transition-colors">
<SessionSearch value={q} onChange={setQ} />
<span className="bg-border h-5 w-px flex-none" aria-hidden="true" />
<button
type="button"
onClick={() => navigate(PATHS.SNAP_NEW)}
title="새 대화 (Ctrl+N)"
className="bg-primary text-primary-foreground ml-auto grid size-9 flex-none place-items-center rounded-md transition-opacity hover:opacity-90"
className="bg-primary text-primary-foreground focus-visible:ring-ring flex h-7 flex-none items-center gap-1 rounded-md px-2.5 text-[12px] font-semibold transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none"
>
<Plus className="size-4" />
<Plus className="size-3.5" aria-hidden="true" />
</button>
</div>
<SessionSearch value={q} onChange={setQ} />
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-[10px] tracking-wide uppercase">
{searching ? "검색 결과 (본문)" : "최근 진행 대화"}
</span>
<span className="border-border text-muted-foreground rounded-full border px-2 font-mono text-[10px]">
{total}
<div className="flex items-center justify-between px-0.5">
<span className="text-muted-foreground text-[11px] font-semibold tracking-wide">
{searching ? "검색 결과" : "최근 대화"}
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">{total}</span>
</div>
<div ref={listRef} className="flex flex-col gap-2">
{/* 목록 블록 — 테두리는 여기 한 번, 행 사이는 옅은 구분선(ABAP_OPENCODE 콜랩스 블록 규칙) */}
<div
ref={listRef}
className="border-border bg-card divide-border/70 scrollbar-hide min-h-0 flex-none divide-y overflow-y-auto rounded-lg border"
>
{active.isLoading && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
</div>
<div className="text-muted-foreground py-10 text-center text-xs"> </div>
)}
{!active.isLoading && searching && hits.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
· NO MATCH
<div className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
<span>"{debouncedQ.trim()}" </span>
<span className="text-muted-foreground/70">
Esc
</span>
</div>
)}
{!active.isLoading && !searching && sessions.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
<div className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
<span> </span>
<span className="text-muted-foreground/70">
(Ctrl+N)
</span>
</div>
)}
@@ -153,15 +152,18 @@ export default function SessionListPage() {
))}
{searching && meta && meta.totalPages > 1 && (
<div className="text-muted-foreground mt-1 flex items-center justify-center gap-3 font-mono text-xs">
<div className="text-muted-foreground flex items-center justify-center gap-3 py-2 text-[11px]">
<button
type="button"
onClick={() => setPage((p) => p - 1)}
disabled={!meta.hasPreviousPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
className={cn(
"hover:bg-accent hover:text-foreground grid size-6 place-items-center rounded-md transition-colors",
"disabled:opacity-40 disabled:hover:bg-transparent"
)}
title="이전"
>
<ChevronLeft className="size-4" />
<ChevronLeft className="size-3.5" />
</button>
<span className="tabular-nums">
{page} / {meta.totalPages}
@@ -170,16 +172,20 @@ export default function SessionListPage() {
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={!meta.hasNextPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
className={cn(
"hover:bg-accent hover:text-foreground grid size-6 place-items-center rounded-md transition-colors",
"disabled:opacity-40 disabled:hover:bg-transparent"
)}
title="다음"
>
<ChevronRight className="size-4" />
<ChevronRight className="size-3.5" />
</button>
</div>
)}
</div>
<div className="text-muted-foreground border-border bg-background/95 sticky bottom-0 -mx-6 mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-6 pt-2 font-mono text-[10px] backdrop-blur">
{/* 키 안내 — 한 줄, 조용하게 */}
<div className="text-muted-foreground mt-auto flex flex-none flex-wrap items-center gap-x-4 gap-y-1 px-0.5 pt-3 text-[10.5px]">
<span className="flex items-center gap-1">
<Kbd></Kbd>
<Kbd></Kbd>
@@ -195,7 +201,7 @@ export default function SessionListPage() {
</span>
<span className="flex items-center gap-1">
<Kbd>Esc</Kbd>
{q ? "검색 지우기" : "닫기"}
</span>
</div>
</div>
@@ -1,4 +1,5 @@
import { hostKind, send } from "@/lib/bridge/transport"
import { LogoMark } from "@/shared/components/LogoMark"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Search } from "lucide-react"
@@ -287,9 +288,8 @@ export default function SnippetPalettePage() {
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Snippet Everywhere
</span>
<LogoMark width={28} />
<span className="text-[11px] font-semibold tracking-wide">CodeAssist · Snippet</span>
</header>
)}
{/* 상단 검색 입력 */}
+1 -2
View File
@@ -6,10 +6,9 @@ import { Toaster } from "sonner"
import App from "./App"
import { queryClient } from "./lib/query/queryClient"
import { ALL_THEMES_CSS } from "@/shared/theme/derive"
import "@fontsource-variable/geist"
import "./styles/globals.css"
// 24 팔레트 토큰을 <head>에 한 번 주입.
// 팔레트 토큰을 <head>에 한 번 주입. 서체는 globals.css 의 시스템 산세리프(ABAP_OPENCODE 와 동일).
// index.html 인라인 스크립트가 [data-theme] 을 미리 박아둠 → 첫 페인트부터 색 적용.
const styleEl = document.createElement("style")
styleEl.id = "palette-tokens"
@@ -4,8 +4,11 @@ import { invoke } from "@tauri-apps/api/core"
import { X } from "lucide-react"
import { toast } from "sonner"
import { useLocation } from "react-router-dom"
import { LogoMark } from "@/shared/components/LogoMark"
import { SnapUserControls } from "@/features/snap/components/SnapUserControls"
/** Tauri 프레임리스 창의 공통 제목줄. 닫기는 트레이로 숨김. */
/** Tauri 프레임리스 창의 공통 제목줄. 닫기는 트레이로 숨김.
* 로그인 뒤 화면(snap·snippet)에선 테마·로그아웃도 여기 오른쪽에 — 따로 떠 있는 상자 없이. */
export function DesktopWindowFrame({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const palette = pathname === "/snippet" || pathname === "/snap" || pathname.startsWith("/snap/")
@@ -18,29 +21,33 @@ export function DesktopWindowFrame({ children }: { children: ReactNode }) {
return (
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
<header
className={`flex shrink-0 items-center select-none ${palette ? "h-7" : "border-border h-9 border-b"}`}
className={`border-border flex h-8 shrink-0 items-center border-b select-none ${palette ? "" : "h-9"}`}
>
<div
className="text-muted-foreground flex h-full min-w-0 flex-1 items-center px-4 text-[10px] font-medium"
className="text-muted-foreground flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-[10.5px] font-semibold tracking-wide"
onMouseDown={(event) => {
if (event.button === 0) run("window_drag")
}}
// 창 이동은 포인터 전용 동작이며 버튼과 영역을 분리함.
role="presentation"
>
<LogoMark width={22} />
CodeAssist
</div>
{palette && <SnapUserControls />}
<button
type="button"
aria-label="창 닫기"
title="닫기 (트레이로 숨기기)"
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring mr-1 flex h-full w-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring mr-1 ml-0.5 flex size-7 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
onClick={() => run("window_hide")}
>
<X className="size-4" aria-hidden="true" />
<X className="size-3.5" aria-hidden="true" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-auto [&_.h-screen]:h-full">{children}</div>
<div className="min-h-0 flex-1 overflow-auto [&_.h-screen]:h-full [&_.min-h-screen]:min-h-full">
{children}
</div>
</div>
)
}
+16 -9
View File
@@ -1,19 +1,26 @@
import { Link, Outlet } from "react-router-dom"
import { hostKind } from "@/lib/bridge/transport"
import { PATHS } from "@/config/routes"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { LogoMark } from "@/shared/components/LogoMark"
/** 로그인 등 공개 화면 셸. Tauri 에선 창 제목줄(DesktopWindowFrame)이 로고·테마를 이미 보여주므로 헤더를 안 그림. */
export default function Layout() {
const tauri = hostKind() === "tauri"
return (
<div className="flex min-h-screen flex-col">
<header className="border-b">
<div className="container flex h-14 items-center justify-between">
<Link to={PATHS.HOME} className="font-semibold">
Frontend Template
</Link>
<ThemeToggle />
</div>
</header>
<main className="container flex min-h-0 flex-1 flex-col py-8">
{!tauri && (
<header className="border-border border-b">
<div className="mx-auto flex h-9 w-full max-w-5xl items-center justify-between pr-2 pl-3">
<Link to={PATHS.HOME} className="flex items-center gap-2 text-[11px] font-semibold">
<LogoMark width={26} />
CodeAssist
</Link>
<ThemeToggle />
</div>
</header>
)}
<main className="mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col px-4 py-8">
<Outlet />
</main>
</div>
@@ -0,0 +1,19 @@
import logo from "@/assets/logo.png"
import logoDark from "@/assets/logo-dark.png"
import { cn } from "@/lib/utils/cn"
interface LogoMarkProps {
/** 로고 가로 폭(px) — 원본 비율(약 2.06:1) 유지 */
width?: number
className?: string
}
/** 브랜드 마크 — ABAP_OPENCODE 와 같은 PwC 로고. 다크에선 밝은 글자 변형. */
export function LogoMark({ width = 38, className }: LogoMarkProps) {
return (
<span className={cn("inline-flex shrink-0", className)} style={{ width }}>
<img src={logo} alt="PwC" className="block h-auto w-full dark:hidden" />
<img src={logoDark} alt="PwC" className="hidden h-auto w-full dark:block" />
</span>
)
}
+2 -2
View File
@@ -35,8 +35,8 @@ const ORDER: Theme[] = ["light", "dark", "system"]
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: "light",
resolved: resolve("light"),
theme: "dark", // ABAP_OPENCODE 와 같이 다크가 기본
resolved: resolve("dark"),
setTheme: (theme) => {
const resolved = resolve(theme)
applyToDocument(resolved)
+24 -23
View File
@@ -83,34 +83,35 @@ export type DerivedTokens = {
// 의미색 — 모든 테마 공통
const DESTRUCTIVE = "oklch(0.577 0.245 27.325)"
// 사용자가 선택한 목업 색을 그대로 유지. 명도 정렬로 primary가 차콜로 바뀌지 않게 함.
// 기본 팔레트(id 는 하위호환으로 clean-blue 유지) — ABAP_OPENCODE DESIGN_SYSTEM.md §7 라이트 값.
// globals.css :root 와 같은 값. 명도 정렬(derive)을 안 타서 블루가 차콜로 안 바뀜.
const CLEAN_BLUE_TOKENS: DerivedTokens = {
background: "#FFFFFF",
foreground: "#242C39",
card: "#F7F9FC",
cardForeground: "#242C39",
background: "#FAFBFC",
foreground: "#1A1D22",
card: "#FFFFFF",
cardForeground: "#1A1D22",
popover: "#FFFFFF",
popoverForeground: "#242C39",
primary: "#034EA2",
popoverForeground: "#1A1D22",
primary: "#3563E9",
primaryForeground: "#FFFFFF",
secondary: "#F0F3F8",
secondaryForeground: "#242C39",
muted: "#F7F9FC",
mutedForeground: "#606D7F",
accent: "#EDF3FC",
accentForeground: "#034EA2",
secondary: "#EFF1F4",
secondaryForeground: "#1A1D22",
muted: "#F2F3F5",
mutedForeground: "#494F59",
accent: "#E4E8EF",
accentForeground: "#1A1D22",
destructive: DESTRUCTIVE,
border: "#E1E6EE",
input: "#BBC7D8",
ring: "#034EA2",
sidebar: "#F7F9FC",
sidebarForeground: "#242C39",
sidebarPrimary: "#034EA2",
border: "#E1E3E8",
input: "#E1E3E8",
ring: "#3563E9",
sidebar: "#F2F3F5",
sidebarForeground: "#1A1D22",
sidebarPrimary: "#3563E9",
sidebarPrimaryForeground: "#FFFFFF",
sidebarAccent: "#EDF3FC",
sidebarAccentForeground: "#034EA2",
sidebarBorder: "#E1E6EE",
sidebarRing: "#034EA2",
sidebarAccent: "#E9EBEF",
sidebarAccentForeground: "#1A1D22",
sidebarBorder: "#E9EBEF",
sidebarRing: "#3563E9",
}
export function derivePaletteTokens(
+3 -3
View File
@@ -10,9 +10,9 @@ export type RawPalette = {
export const PALETTES: RawPalette[] = [
{
id: "clean-blue",
name: "클린 블루",
swatch: ["#FFFFFF", "#F7F9FC", "#034EA2", "#242C39"],
id: "clean-blue", // id 는 저장된 값 호환 위해 유지 — 실제 토큰은 derive.ts 워크스페이스(ABAP_OPENCODE) 값
name: "워크스페이스",
swatch: ["#FAFBFC", "#F2F3F5", "#3563E9", "#1A1D22"],
},
// Sheet 1
{ id: "dark-teal", name: "Dark Teal", swatch: ["#1A222B", "#2D3540", "#009E9F", "#ECECEC"] },
+102 -61
View File
@@ -15,12 +15,14 @@
/* shadcn/ui 토큰 → Tailwind 색상·반경 매핑 */
@theme inline {
/* ABAP_OPENCODE DESIGN_SYSTEM.md 와 같은 시스템 산세리프. 메타 라벨(font-mono 클래스)도 같은 서체 —
그쪽 UI 가 10~11px 산세리프 메타를 쓰기 때문. 코드블럭은 CodeBlock 이 자체 monospace 지정. */
--font-sans:
"Geist Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans KR", sans-serif;
-apple-system, "Segoe UI", "Inter", Roboto, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
--font-mono:
"Geist Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans KR", sans-serif;
-apple-system, "Segoe UI", "Inter", Roboto, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
--color-success: var(--success);
--color-warning: var(--warning);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -55,76 +57,80 @@
}
/* ────────────────────────────────────────────────────────────────────────────
팔레트 시스템 — palettes.ts 의 DEFAULT_THEME(=clean-blue) 폴백.
런타임 CSS는 main.tsx 가 ALL_THEMES_CSS 를 <style> 로 주입.
index.html 인라인 스크립트가 첫 페인트 전 [data-theme]/.dark 박음 → FOUC 없음.
의미색(destructive, chart-1..5)은 모든 팔레트 공통이므로 :root 에만 둠.
팔레트 — 기본은 ABAP_OPENCODE 의 "Dark Workspace" 테마(DESIGN_SYSTEM.md §1/§7)를
shadcn 토큰으로 옮긴 것. 계층(사이드바 < 패널 < 캔버스 < 카드)으로 깊이를 내고,
카드엔 항상 border, 악센트는 블루(선택·포커스)/그린(상태)/오렌지(경고) 셋만.
palettes.ts 의 DEFAULT_THEME(=clean-blue, 이름 "워크스페이스") 가 라이트 값을 덮고,
.dark 는 여기 값이 그대로 쓰임. index.html 이 첫 페인트 전 [data-theme]/.dark 박음.
──────────────────────────────────────────────────────────────────────────── */
:root {
/* 클린 블루 기본 — derive.ts의 고정 토큰과 동일 */
--background: #ffffff;
--foreground: #242c39;
--card: #f7f9fc;
--card-foreground: #242c39;
/* 라이트 — 회색 층 → 흰 카드 */
--background: #fafbfc; /* canvas */
--foreground: #1a1d22; /* text-hi */
--card: #ffffff;
--card-foreground: #1a1d22;
--popover: #ffffff;
--popover-foreground: #242c39;
--primary: #034ea2;
--popover-foreground: #1a1d22;
--primary: #3563e9; /* blue (흰 배경 대비용으로 진하게) */
--primary-foreground: #ffffff;
--secondary: #f0f3f8;
--secondary-foreground: #242c39;
--muted: #f7f9fc;
--muted-foreground: #606d7f;
--accent: #edf3fc;
--accent-foreground: #034ea2;
--secondary: #eff1f4; /* chip */
--secondary-foreground: #1a1d22;
--muted: #f2f3f5; /* sidebar */
--muted-foreground: #494f59; /* text-md */
--accent: #e4e8ef; /* sel-bg */
--accent-foreground: #1a1d22;
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: #e1e6ee;
--input: #bbc7d8;
--ring: #034ea2;
--radius: 0.625rem;
--success: #22a85c; /* green — 상태·진행 */
--warning: #d07e44; /* orange — 보조·경고 */
--border: #e1e3e8;
--input: #e1e3e8;
--ring: #3563e9;
--radius: 0.5rem; /* 카드 8px, 소형 6px */
--shadow-float: 0 4px 14px rgba(23, 26, 31, 0.08), 0 1px 3px rgba(23, 26, 31, 0.06);
/* 사이드바 */
--sidebar: #f7f9fc;
--sidebar-foreground: #242c39;
--sidebar-primary: #034ea2;
--sidebar: #f2f3f5;
--sidebar-foreground: #1a1d22;
--sidebar-primary: #3563e9;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #edf3fc;
--sidebar-accent-foreground: #034ea2;
--sidebar-border: #e1e6ee;
--sidebar-ring: #034ea2;
--sidebar-accent: #e9ebef;
--sidebar-accent-foreground: #1a1d22;
--sidebar-border: #e9ebef;
--sidebar-ring: #3563e9;
}
/*
* 다크모드 — themeStore (light/dark/system) 가 .dark 클래스 토글.
* 24 팔레트는 라이트 톤 기준이라 다크는 grayscale 베이스로 통일.
*/
/* 다크(기본 모드) — 어두운 층 → 밝은 카드. themeStore 가 .dark 토글. */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: #15171c; /* canvas */
--foreground: #e8eaed; /* text-hi (순백 금지) */
--card: #1a1d22;
--card-foreground: #e8eaed;
--popover: #1b1e24; /* float */
--popover-foreground: #e8eaed;
--primary: #4c7dff;
--primary-foreground: #ffffff;
--secondary: #1d2127; /* chip */
--secondary-foreground: #c9cdd3;
--muted: #191c21; /* item */
--muted-foreground: #9aa0a8; /* text-md */
--accent: #22262c; /* hover */
--accent-foreground: #e8eaed;
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--success: #35c96e;
--warning: #e0915a;
--border: #24272d;
--input: #24272d;
--ring: #4c7dff;
--shadow-float: 0 6px 18px rgba(0, 0, 0, 0.45);
--sidebar: #0c0d0f;
--sidebar-foreground: #c9cdd3;
--sidebar-primary: #4c7dff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #20242c; /* sel-bg */
--sidebar-accent-foreground: #e8eaed;
--sidebar-border: #1e2126;
--sidebar-ring: #4c7dff;
}
@layer base {
@@ -140,6 +146,41 @@
"rlig" 1,
"calt" 1;
}
/* 스크롤바·선택·캐럿 — ABAP_OPENCODE 와 같은 얇은 오버레이 스타일 */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
::selection {
background: color-mix(in srgb, var(--primary) 28%, transparent);
}
:root {
caret-color: var(--primary);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* 시머 텍스트 — "생각하는 중…" 등 대기 문구에 밝은 띠가 좌→우로 쓸고 지나감.