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:
@@ -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 |
@@ -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>
|
||||
)}
|
||||
{/* 상단 검색 입력 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 시머 텍스트 — "생각하는 중…" 등 대기 문구에 밝은 띠가 좌→우로 쓸고 지나감.
|
||||
|
||||
Reference in New Issue
Block a user