feat(ui): Midnight Glass v2 전면 리디자인 — 기반+셸+대시보드
- theme.ts 재작성: glass/gradient/glow 토큰군, 기본 dark=Midnight(블루), 6종 테마 변형 전부 파생 토큰 유지 (테마 시스템 보존) - DS 리스킨(API 호환): 글래스 카드/버튼, Pretendard 타이포, 소프트 LED - 신규: GradientWave(canvas 스펙트럼 음파), StatRing(컬러 링 타일) - 보더리스: frame:false + 커스텀 TitleBar, 네이티브 스크롤바 제거(폭 불변) + OverlayScrollbars 오버레이 스크롤 - AppLayout 와이드 사이드바(lucide, 그라디언트 액티브 필), 대시보드 재구축, StatusBar(브랜드/로컬시간), Pretendard 번들, i18n +20키(ko/en)
This commit is contained in:
parent
f3ca0eafd4
commit
a8a1d6e546
25 changed files with 1638 additions and 960 deletions
|
|
@ -43,7 +43,6 @@
|
|||
"@d3ro/i18n": "*",
|
||||
"@d3ro/ui": "*",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
|
|
@ -51,6 +50,7 @@
|
|||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"docx": "^9.6.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
|
@ -59,7 +59,10 @@
|
|||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"lucide-react": "^1.25.0",
|
||||
"node-record-lpcm16": "^1.0.1",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export function createMainWindow(): BrowserWindow {
|
|||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// v2 보더리스 UI — 렌더러 TitleBar가 드래그/컨트롤 담당
|
||||
frame: false,
|
||||
// 첫 페인트 전 플래시 방지 (렌더러 테마 로드 전 임시 배경 — Midnight bg.app)
|
||||
backgroundColor: '#0a0e1c',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
|
|
|
|||
BIN
apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2
Normal file
BIN
apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2
Normal file
Binary file not shown.
|
|
@ -1,16 +1,20 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||
// v2 "Midnight Glass": 보더리스 셸 — 와이드 사이드바(그라디언트 액티브 필) +
|
||||
// 앰비언트 배경 + 오버레이 스크롤(레이아웃 폭 불변) + 커스텀 타이틀바
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Alert, Box, Snackbar, Typography, Tooltip } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
|
||||
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
||||
import GroupsIcon from '@mui/icons-material/Groups'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { Alert, Box, Snackbar, Typography } from '@mui/material'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
History as HistoryIcon,
|
||||
BookOpen,
|
||||
SquareTerminal,
|
||||
MessagesSquare,
|
||||
Library,
|
||||
Users,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { DashboardPage } from '../pages/DashboardPage'
|
||||
import { HistoryPage } from '../pages/HistoryPage'
|
||||
|
|
@ -23,7 +27,8 @@ import { SettingsModal } from './SettingsModal'
|
|||
import { LicenseModal } from './LicenseModal'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { TitleBar } from './TitleBar'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { TranslationKey } from '@d3ro/i18n'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
|
|
@ -33,18 +38,19 @@ type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation
|
|||
interface NavItem {
|
||||
route: Route
|
||||
labelKey: TranslationKey
|
||||
abbr: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
const ICON_SIZE = 18
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'dashboard', labelKey: 'nav.dashboard', icon: <LayoutDashboard size={ICON_SIZE} /> },
|
||||
{ route: 'history', labelKey: 'nav.history', icon: <HistoryIcon size={ICON_SIZE} /> },
|
||||
{ route: 'dictionary', labelKey: 'nav.dictionary', icon: <BookOpen size={ICON_SIZE} /> },
|
||||
{ route: 'commands', labelKey: 'nav.commands', icon: <SquareTerminal size={ICON_SIZE} /> },
|
||||
{ route: 'conversation', labelKey: 'nav.conversation', icon: <MessagesSquare size={ICON_SIZE} /> },
|
||||
{ route: 'knowledge', labelKey: 'nav.knowledge', icon: <Library size={ICON_SIZE} /> },
|
||||
{ route: 'meeting', labelKey: 'nav.meeting', icon: <Users size={ICON_SIZE} /> },
|
||||
]
|
||||
|
||||
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
|
||||
|
|
@ -91,6 +97,10 @@ export function AppLayout(): React.ReactElement {
|
|||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
|
||||
// 대시보드 CTA(키 설정/시스템 설정)에서 설정 모달 열기
|
||||
const handleOpenSettings = () => setSettingsOpen(true)
|
||||
window.addEventListener('d3ro:open-settings', handleOpenSettings)
|
||||
|
||||
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
|
||||
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
|
||||
setFallbackMsg(e.reason)
|
||||
|
|
@ -105,88 +115,108 @@ export function AppLayout(): React.ReactElement {
|
|||
unsubFallback()
|
||||
unsubUpgradeReq()
|
||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||
<TitleBar />
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
||||
{/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 72,
|
||||
width: 168,
|
||||
flexShrink: 0,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRight: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
py: 2,
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
pt: 3,
|
||||
pb: 2,
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* 로고 LED — reflects license tier */}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
|
||||
{/* 로고 — 그라디언트 브랜드 텍스트 */}
|
||||
<Box sx={{ px: 1.5, mb: 3, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={8} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '20px',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.02em',
|
||||
backgroundImage: d3roPalette.gradient.logo,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.micro.size,
|
||||
fontFamily: d3roFontMono,
|
||||
letterSpacing: d3roTypo.micro.spacing,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.12em',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
fontWeight: d3roTypo.micro.weight,
|
||||
pl: '16px',
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
Voice
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 네비게이션 버튼 */}
|
||||
{/* 네비게이션 */}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = currentRoute === item.route
|
||||
return (
|
||||
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
<Box
|
||||
key={item.route}
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
cursor: 'pointer',
|
||||
color: isActive ? d3roPalette.text.primary : d3roPalette.text.inactive,
|
||||
// 액티브: 글래스 필 + 그라디언트 헤어라인 보더 + 소프트 글로우
|
||||
border: '1px solid transparent',
|
||||
background: isActive
|
||||
? `linear-gradient(${d3roPalette.glass.raised}, ${d3roPalette.glass.raised}) padding-box, ${d3roPalette.gradient.accent} border-box`
|
||||
: 'transparent',
|
||||
boxShadow: isActive ? d3roShadow.glowSoft : 'none',
|
||||
transition: 'color 0.15s ease, background-color 0.15s ease',
|
||||
'&:hover': {
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: isActive ? undefined : d3roPalette.glass.raised,
|
||||
},
|
||||
'& svg': {
|
||||
color: isActive ? d3roPalette.accent.light : 'inherit',
|
||||
flexShrink: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<Typography
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
||||
boxShadow: isActive
|
||||
? d3roShadow.buttonPressed
|
||||
: d3roShadow.buttonRaised,
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
transition: 'all 0.05s linear',
|
||||
transform: isActive ? 'translateY(1px)' : 'none',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': {
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
||||
},
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.nano.size,
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: d3roTypo.nano.weight,
|
||||
letterSpacing: d3roTypo.nano.spacing,
|
||||
}}
|
||||
>
|
||||
{item.abbr}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{t(item.labelKey)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
|
|
@ -194,50 +224,64 @@ export function AppLayout(): React.ReactElement {
|
|||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip title={t('nav.settings')} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
boxShadow: d3roShadow.buttonRaised,
|
||||
transition: 'all 0.05s linear',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': { color: d3roPalette.text.hover },
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
transition: 'color 0.15s ease, background-color 0.15s ease',
|
||||
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
|
||||
}}
|
||||
>
|
||||
<Settings size={ICON_SIZE} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, fontWeight: 500, lineHeight: 1 }}>
|
||||
{t('nav.settings')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── 콘텐츠 영역 ────────────────────────── */}
|
||||
{/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
minWidth: 0,
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
// 미묘한 방사형 비네팅 (시안 A 배경)
|
||||
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
|
||||
// 앰비언트 그라디언트 오브 (좌상단 액센트 / 우하단 퍼플 힌트)
|
||||
background: `
|
||||
radial-gradient(ellipse 55% 40% at 12% -5%, ${d3roPalette.accent.dim} 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 35% at 95% 105%, ${d3roPalette.tag.purpleBg} 0%, transparent 60%),
|
||||
${d3roPalette.bg.app}
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
||||
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
||||
{currentRoute === 'meeting' && <MeetingModePage />}
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
options={{
|
||||
scrollbars: {
|
||||
theme: 'os-theme-dark',
|
||||
autoHide: 'move',
|
||||
autoHideDelay: 800,
|
||||
clickScroll: true,
|
||||
},
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
||||
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
||||
{currentRoute === 'meeting' && <MeetingModePage />}
|
||||
</OverlayScrollbarsComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
<StatusBar />
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
// src/renderer/components/StatusBar.tsx
|
||||
// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징
|
||||
// 타이포 토큰 적용 (d3roTypo SSOT)
|
||||
// v2 "Midnight Glass" 하단 상태바 — 좌: 브랜드+버전+백엔드 상태, 우: 로컬 시간.
|
||||
// Ollama 오프라인 넛징 버블 유지.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Fade, IconButton } from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { X } from 'lucide-react'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { OllamaGuideModal } from './OllamaGuideModal'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { LLMStatus } from '@d3ro/core/types'
|
||||
|
||||
function useClock(): string {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
}
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
const [showNudge, setShowNudge] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [captionActive, setCaptionActive] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const clock = useClock()
|
||||
|
||||
// 자막 상태 구독
|
||||
useEffect(() => {
|
||||
|
|
@ -30,6 +42,9 @@ export function StatusBar(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.system.getVersion().then((r) => {
|
||||
if (r.success) setVersion(r.data)
|
||||
})
|
||||
// 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신)
|
||||
window.electronAPI.llm.getStatus().then((r) => {
|
||||
if (r.success) setLlmStatus(r.data)
|
||||
|
|
@ -60,9 +75,9 @@ export function StatusBar(): React.ReactElement {
|
|||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
minHeight: 28,
|
||||
minHeight: 30,
|
||||
}}
|
||||
>
|
||||
{/* 넛징 버블 */}
|
||||
|
|
@ -70,11 +85,11 @@ export function StatusBar(): React.ReactElement {
|
|||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
bottom: 34,
|
||||
left: 8,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
borderRadius: d3roRadius.inner,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
|
|
@ -88,7 +103,7 @@ export function StatusBar(): React.ReactElement {
|
|||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
borderTop: 'none',
|
||||
borderLeft: 'none',
|
||||
transform: 'rotate(45deg)',
|
||||
|
|
@ -100,7 +115,7 @@ export function StatusBar(): React.ReactElement {
|
|||
{t('status.nudge.title')}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, mb: 1 }}>
|
||||
|
|
@ -110,7 +125,7 @@ export function StatusBar(): React.ReactElement {
|
|||
onClick={() => { setGuideOpen(true); setShowNudge(false) }}
|
||||
sx={{
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
color: d3roPalette.accent.light,
|
||||
fontWeight: d3roTypo.label.weight,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
|
|
@ -121,62 +136,66 @@ export function StatusBar(): React.ReactElement {
|
|||
</Box>
|
||||
</Fade>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color={connected ? 'green' : 'red'} size={6} />
|
||||
{/* 좌: 브랜드 + 버전 */}
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: connected ? 'default' : 'pointer' }}
|
||||
onClick={() => !connected && setGuideOpen(true)}
|
||||
>
|
||||
<Led color={connected ? 'green' : 'red'} size={7} />
|
||||
<Typography
|
||||
onClick={() => !connected && setGuideOpen(true)}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
cursor: connected ? 'default' : 'pointer',
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
fontWeight: 600,
|
||||
color: connected ? d3roPalette.text.secondary : d3roPalette.tag.red,
|
||||
'&:hover': connected ? {} : { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{connected ? t('status.ollama') : t('status.offline')}
|
||||
D3RO Voice
|
||||
</Typography>
|
||||
{version && (
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
|
||||
v{version}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{llmStatus?.activeModel && (
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
}}>
|
||||
{llmStatus.activeModel.toUpperCase()}
|
||||
{llmStatus.activeModel}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 자막 상태 표시 */}
|
||||
{captionActive && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color="amber" pulse size={6} />
|
||||
<Led color="red" pulse size={6} />
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.tag.red,
|
||||
}}>
|
||||
CAPTION
|
||||
{t('dashboard.caption')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.text.muted,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
}}>
|
||||
PRECISION DATA LINK
|
||||
</Typography>
|
||||
{/* 우: 로컬 시간 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
|
||||
{t('status.localTime')}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{clock}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<OllamaGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
|
||||
|
|
|
|||
97
apps/desktop/src/renderer/components/TitleBar.tsx
Normal file
97
apps/desktop/src/renderer/components/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// src/renderer/components/TitleBar.tsx
|
||||
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
||||
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
|
||||
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { Minus, Square, Copy, X } from 'lucide-react'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
|
||||
const BAR_HEIGHT = 40
|
||||
|
||||
export function TitleBar(): React.ReactElement {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
|
||||
}, [])
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
window.electronAPI.window.maximize()
|
||||
// 토글 후 상태 재조회 (이벤트 채널이 없어 폴링 1회)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
|
||||
}, 120)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: BAR_HEIGHT,
|
||||
zIndex: (theme) => theme.zIndex.drawer + 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'flex-start',
|
||||
WebkitAppRegion: 'drag',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
m: '6px 8px 0 0',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
|
||||
<Minus size={14} />
|
||||
</ControlButton>
|
||||
<ControlButton onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
|
||||
</ControlButton>
|
||||
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
|
||||
<X size={14} />
|
||||
</ControlButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
children,
|
||||
danger = false,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
danger?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
transition: 'background-color 0.12s ease, color 0.12s ease',
|
||||
'&:hover': {
|
||||
bgcolor: danger ? d3roPalette.tag.redBg : d3roPalette.glass.raised,
|
||||
color: danger ? d3roPalette.tag.red : d3roPalette.text.primary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'overlayscrollbars/overlayscrollbars.css'
|
||||
import './styles/global.css'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
|
|
|||
|
|
@ -1,34 +1,131 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
|
||||
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
|
||||
// v2 "Midnight Glass" 대시보드 — 레퍼런스 시안 재현:
|
||||
// 히어로(웨이브+CTA) / 컬러 링 스탯 4타일 / 백엔드·요금제 / 사용량·실시간 자막 /
|
||||
// 시스템 상태(웨이브+레벨미터) / 파일 전사 / 최근 히스토리
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import {
|
||||
Mic,
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
Flame,
|
||||
Settings2,
|
||||
KeyRound,
|
||||
BarChart3,
|
||||
Star,
|
||||
Check,
|
||||
Cpu,
|
||||
} from 'lucide-react'
|
||||
import { GradientWave, Led, MetalCard, PhosphorText, PhysicalButton, StatRing } from '@d3ro/ui/components/ds'
|
||||
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
|
||||
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
|
||||
import { formatHotkeyLabel } from '../utils/format-hotkey'
|
||||
import { FileDropZone } from '../components/FileDropZone'
|
||||
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
|
||||
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
|
||||
|
||||
// ── 로컬 프리미티브 ───────────────────────────────────
|
||||
|
||||
/** 옅은 헤어라인 필 (레퍼런스의 "TUE" / "로컬 연결" 칩) */
|
||||
function Pill({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
tone?: 'neutral' | 'accent' | 'green'
|
||||
}): React.ReactElement {
|
||||
const color =
|
||||
tone === 'accent' ? d3roPalette.accent.light : tone === 'green' ? d3roPalette.tag.green : d3roPalette.text.secondary
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: d3roRadius.pill,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
bgcolor: d3roPalette.glass.raised,
|
||||
color,
|
||||
...typoSx('meta'),
|
||||
fontFamily: d3roFontSans,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
|
||||
function CardHeader({
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
title: string
|
||||
right?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1.5,
|
||||
mb: 2,
|
||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="heading">{title}</PhosphorText>
|
||||
{right}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** 그라디언트 프로그레스 바 */
|
||||
function GradientBar({
|
||||
ratio,
|
||||
alt = false,
|
||||
}: {
|
||||
/** 0~1 (무제한이면 1) */
|
||||
ratio: number
|
||||
alt?: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ height: 5, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, Math.max(4, ratio * 100))}%`,
|
||||
backgroundImage: alt ? d3roPalette.gradient.barAlt : d3roPalette.gradient.bar,
|
||||
borderRadius: d3roRadius.pill,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const { t, formatTime, formatRelativeDate } = useI18n()
|
||||
const { t, formatRelativeDate } = useI18n()
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||
const [llmModel, setLlmModel] = useState<string | null>(null)
|
||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
|
||||
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [licenseTier, setLicenseTier] = useState<LicenseTier>('free')
|
||||
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
||||
const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.stats.getSummary().then((r) => {
|
||||
|
|
@ -37,6 +134,9 @@ export function DashboardPage(): React.ReactElement {
|
|||
window.electronAPI.llm.getStatus().then((r) => {
|
||||
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
|
||||
})
|
||||
window.electronAPI.llm.getActiveModel().then((r) => {
|
||||
if (r.success) setLlmModel(r.data)
|
||||
})
|
||||
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
|
||||
if (r.success) setHistory(r.data.entries)
|
||||
})
|
||||
|
|
@ -103,311 +203,256 @@ export function DashboardPage(): React.ReactElement {
|
|||
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
|
||||
], [t, ollamaConnected, premiumStatus])
|
||||
|
||||
const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm'))
|
||||
const now = new Date()
|
||||
const weekday = now.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
window.dispatchEvent(new Event('d3ro:open-settings'))
|
||||
}, [])
|
||||
|
||||
const statTiles = [
|
||||
{ ring: 'blue' as const, icon: <Mic size={20} />, label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
|
||||
{ ring: 'purple' as const, icon: <BookOpen size={20} />, label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
|
||||
{ ring: 'green' as const, icon: <CalendarDays size={20} />, label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
|
||||
{ ring: 'orange' as const, icon: <Flame size={20} />, label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
|
||||
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
|
||||
<InstrumentPanel
|
||||
engravingLeft="D3RO-VOICE"
|
||||
engravingRight="v1.0.0"
|
||||
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 180px',
|
||||
gap: 2.5,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
|
||||
<ScreenPanel height={200}>
|
||||
{/* 상단 라벨 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<PhosphorText variant="label">
|
||||
{activeView === 'stats'
|
||||
? t('dashboard.sessionOverview').toUpperCase()
|
||||
: t('dashboard.systemStatus').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{/* 중앙: 큰 수치 or 서비스 상태 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
{activeView === 'stats' ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<PhosphorText variant="hero">
|
||||
{stats?.todaySessionCount ?? 0}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.sessionsToday').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhosphorText variant="label" sx={{ mt: 0.5, color: d3roPalette.text.dimLabel }}>
|
||||
{dictationBinding
|
||||
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() }).toUpperCase()
|
||||
: t('dashboard.hotkeyNotSet').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{services.map((svc) => (
|
||||
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
||||
>
|
||||
{svc.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
|
||||
{activeView === 'stats' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('dashboard.words').toUpperCase()}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<PhosphorText variant="value">
|
||||
{formatNumber(stats?.totalWordCount ?? 0)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.total').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<PhosphorText variant="label">{t('dashboard.streak').toUpperCase()}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||
<PhosphorText variant="value">
|
||||
{stats?.streakDays ?? 0}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.days').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 우측: 컨트롤 패널 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
{/* LED 상태 클러스터 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
|
||||
<Led color={ollamaConnected ? 'green' : 'red'} size={8} />
|
||||
<Led color="amber" pulse size={8} />
|
||||
</Box>
|
||||
|
||||
{/* 버튼 그룹 */}
|
||||
<ButtonGroup>
|
||||
<PhysicalButton
|
||||
selected={activeView === 'stats'}
|
||||
onClick={() => setActiveView('stats')}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('dashboard.stat').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton
|
||||
selected={activeView === 'status'}
|
||||
onClick={() => setActiveView('status')}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('dashboard.sys').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
</ButtonGroup>
|
||||
<Box sx={{ maxWidth: 980, mx: 'auto', p: 4, pt: 5, pb: 8 }}>
|
||||
{/* ── 1. 히어로 카드 ─────────────────────────── */}
|
||||
<MetalCard sx={{ p: 3.5 }}>
|
||||
{/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="amber" pulse size={7} />
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontFamily: d3roFontSans }}>
|
||||
{t('dashboard.sessionsToday')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{now.toLocaleDateString()}
|
||||
</PhosphorText>
|
||||
<Pill>{weekday}</Pill>
|
||||
<Pill tone={allOk ? 'green' : 'neutral'}>
|
||||
<Led color={allOk ? 'green' : 'orange'} size={6} />
|
||||
{allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
|
||||
</Pill>
|
||||
</Box>
|
||||
</Box>
|
||||
</InstrumentPanel>
|
||||
|
||||
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) },
|
||||
{ label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() },
|
||||
{ label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() },
|
||||
{ label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() },
|
||||
].map((card) => (
|
||||
<MetalCard key={card.label}>
|
||||
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{card.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
|
||||
<PhosphorText variant="value" sx={{ fontSize: d3roTypo.title.size, fontWeight: 300 }}>
|
||||
{card.value}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 3, alignItems: 'start' }}>
|
||||
{/* 좌: 헤드라인 + 힌트 + 키 설정 */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<PhosphorText variant="hero" sx={{ mb: 1 }}>
|
||||
{t('dashboard.heroIdle')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ mb: 2 }}>
|
||||
{dictationBinding
|
||||
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
|
||||
: t('dashboard.hotkeyNotSet')}
|
||||
</PhosphorText>
|
||||
<PhysicalButton onClick={openSettings} sx={{ height: 34, px: 1.5, gap: 0.75 }}>
|
||||
<KeyRound size={13} />
|
||||
{t('dashboard.keySetup')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
{/* 중: 수치 2종 */}
|
||||
<Box sx={{ display: 'flex', gap: 4, pt: 0.5 }}>
|
||||
{[
|
||||
{ label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
|
||||
{ label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
|
||||
].map((item) => (
|
||||
<Box key={item.label} sx={{ textAlign: 'left' }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.5, display: 'block' }}>
|
||||
{item.label}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
||||
{card.unit}
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '34px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.unit && (
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{item.unit}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 우: CTA 버튼 스택 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 150 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<BarChart3 size={15} />}
|
||||
onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
sx={{ py: 1.25 }}
|
||||
>
|
||||
{t('dashboard.viewStats')}
|
||||
</Button>
|
||||
<PhysicalButton onClick={openSettings} sx={{ gap: 0.75 }}>
|
||||
<Settings2 size={14} />
|
||||
{t('dashboard.systemSettings')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 하단: 그라디언트 웨이브 */}
|
||||
<Box sx={{ mt: 2.5, height: 56 }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={0.3} barWidth={3} barGap={3} />
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
|
||||
<Box ref={statsRef} sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, mt: 2.5 }}>
|
||||
{statTiles.map((tile) => (
|
||||
<MetalCard key={tile.label} sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<StatRing color={tile.ring} size={52}>
|
||||
{tile.icon}
|
||||
</StatRing>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}>
|
||||
{tile.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '26px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{tile.value}
|
||||
</Typography>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{tile.unit}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2 }}>
|
||||
{/* 현재 백엔드 카드 */}
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('dashboard.currentBackend').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led
|
||||
color={premiumStatus?.backend === 'premium' ? 'green' : 'amber'}
|
||||
size={8}
|
||||
pulse={premiumStatus?.backend === 'premium'}
|
||||
/>
|
||||
<PhosphorText variant="value">
|
||||
{premiumStatus?.backend === 'premium'
|
||||
? t('dashboard.backendPremium').toUpperCase()
|
||||
: t('dashboard.backendLocal').toUpperCase()}
|
||||
{/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
|
||||
{/* 백엔드 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader
|
||||
title={t('dashboard.currentBackend')}
|
||||
right={
|
||||
<Pill tone="accent">
|
||||
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
|
||||
</Pill>
|
||||
}
|
||||
/>
|
||||
<MetalCard inset sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
<StatRing color="accent" size={46} thickness={2}>
|
||||
<Cpu size={18} />
|
||||
</StatRing>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 0.5 }}>
|
||||
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.modelLabel')}: {llmModel ?? '—'}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
|
||||
</PhosphorText>
|
||||
<Led color={ollamaConnected ? 'green' : 'red'} size={7} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</MetalCard>
|
||||
|
||||
{/* 티어 카드 */}
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('license.currentTier').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led
|
||||
color={licenseTier === 'free' ? 'amber' : 'green'}
|
||||
size={8}
|
||||
pulse={licenseTier !== 'free'}
|
||||
/>
|
||||
<PhosphorText variant="value">
|
||||
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
|
||||
</PhosphorText>
|
||||
{/* 요금제 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader title={t('license.currentTier')} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<StatRing color="blue" size={40} thickness={2}>
|
||||
<Star size={15} />
|
||||
</StatRing>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '30px', fontWeight: 800, color: d3roPalette.text.primary, lineHeight: 1 }}>
|
||||
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
{licenseTier === 'free' && <Pill>{t('dashboard.planFree')}</Pill>}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, pl: 2, borderLeft: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
{[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
|
||||
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}><Check size={14} /></Box>
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{feat}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
||||
{/* 사용량 바 — 모든 티어에서 표시 */}
|
||||
{usageQuotas.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('license.usageToday').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{usageQuotas.map((q) => (
|
||||
<Box key={q.feature} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
||||
<PhosphorText variant="small">
|
||||
{t(`license.feature.${q.feature}`)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
|
||||
{q.limit === -1
|
||||
? t('license.unlimited')
|
||||
: `${q.used}/${q.limit}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{q.limit > 0 && (
|
||||
<Box sx={{
|
||||
height: 3,
|
||||
borderRadius: '2px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, (q.used / q.limit) * 100)}%`,
|
||||
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</Box>
|
||||
)}
|
||||
{/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
|
||||
{/* 사용량 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader title={t('license.usageToday')} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{usageQuotas.length === 0 && (
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>—</PhosphorText>
|
||||
)}
|
||||
{usageQuotas.map((q, idx) => (
|
||||
<MetalCard inset key={q.feature} sx={{ p: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<StatRing color={idx % 2 === 0 ? 'blue' : 'purple'} size={32} thickness={2}>
|
||||
{idx % 2 === 0 ? <Mic size={13} /> : <Cpu size={13} />}
|
||||
</StatRing>
|
||||
<PhosphorText variant="compact" sx={{ flex: 1 }}>
|
||||
{t(`license.feature.${q.feature}`)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light }}>
|
||||
{q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
<GradientBar
|
||||
ratio={q.limit === -1 ? 1 : Math.min(1, q.used / Math.max(1, q.limit))}
|
||||
alt={idx % 2 === 1}
|
||||
/>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Premium 모델별 쿼터 표시 */}
|
||||
{premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ mt: 1.5, mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
|
||||
{t('license.premiumQuota').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
|
||||
<Box key={m.model} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
||||
<PhosphorText variant="small">
|
||||
{t(m.i18nKey)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
|
||||
{m.limit === -1
|
||||
? t('license.unlimited')
|
||||
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{m.limit > 0 && (
|
||||
<Box sx={{
|
||||
height: 3,
|
||||
borderRadius: '2px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
bgcolor: d3roPalette.tag.green,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 2.5. 실시간 자막 토글 ────────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led
|
||||
color={captionState === 'active' ? 'green' : captionState === 'starting' || captionState === 'stopping' ? 'amber' : 'off'}
|
||||
size={8}
|
||||
pulse={captionState === 'active'}
|
||||
/>
|
||||
<Box>
|
||||
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight }}>
|
||||
{t('dashboard.caption').toUpperCase()}
|
||||
{/* 실시간 자막 카드 */}
|
||||
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<CardHeader
|
||||
title={t('dashboard.caption')}
|
||||
right={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led
|
||||
color={captionState === 'active' ? 'red' : 'off'}
|
||||
size={7}
|
||||
pulse={captionState === 'active'}
|
||||
/>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
|
||||
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
|
||||
</PhosphorText>
|
||||
{captionState === 'active' && (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size, color: d3roPalette.tag.green }}>
|
||||
{t('dashboard.captionActive').toUpperCase()}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<PhysicalButton
|
||||
selected={captionState === 'active'}
|
||||
}
|
||||
/>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2 }}>
|
||||
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.captionPlaceholder')}
|
||||
</PhosphorText>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Mic size={15} />}
|
||||
onClick={async () => {
|
||||
if (captionState === 'active') {
|
||||
await window.electronAPI.caption.stop()
|
||||
|
|
@ -415,46 +460,105 @@ export function DashboardPage(): React.ReactElement {
|
|||
await window.electronAPI.caption.start()
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 100 }}
|
||||
sx={{ alignSelf: 'center', px: 4 }}
|
||||
>
|
||||
{captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
{captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
|
||||
</Button>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
||||
{/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<CrtDisplay amplitude={0.05} frequency={6} height={100} audioLevel={audioLevel}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, px: 1 }}>
|
||||
{/* ── 5. 시스템 상태 ──────────────────────────── */}
|
||||
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
|
||||
<CardHeader title={t('dashboard.systemStatus')} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 3, alignItems: 'center' }}>
|
||||
{/* 좌: 서비스 상태 리스트 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 220 }}>
|
||||
{services.map((svc) => (
|
||||
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={7} />
|
||||
<PhosphorText variant="small" sx={{ flex: 1, color: d3roPalette.text.secondary }}>
|
||||
{svc.name}
|
||||
</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
||||
variant="meta"
|
||||
sx={{ color: svc.ok ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
||||
>
|
||||
{svc.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CrtDisplay>
|
||||
</Box>
|
||||
|
||||
{/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
{t('fileTranscription.title').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{/* 중: 웨이브 + 레벨 미터 */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ height: 64, mb: 1.25 }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={0.35} barWidth={2.5} barGap={2.5} glow={6} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<PhosphorText variant="meta" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{t('dashboard.inputLevel')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', gap: '3px', flex: 1 }}>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
borderRadius: '2px',
|
||||
bgcolor: i < Math.round(audioLevel * 24) ? d3roPalette.tag.green : d3roPalette.bg.inset,
|
||||
transition: 'background-color 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.tag.green, minWidth: 34, textAlign: 'right' }}>
|
||||
{Math.round(audioLevel * 100)}%
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 우: 백엔드 요약 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 180 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('service.ollamaLlm')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
|
||||
{ollamaConnected ? t('service.ready') : t('service.offline')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.modelLabel')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, fontFamily: d3roFontMono }}>
|
||||
{llmModel ?? '—'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.statusLabel')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: allOk ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
|
||||
{allOk ? 'CONNECTED' : 'CHECK'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 6. 파일 전사 ────────────────────────────── */}
|
||||
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
|
||||
<CardHeader title={t('fileTranscription.title')} />
|
||||
<FileDropZone />
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 4. 최근 히스토리 ──────────────────────── */}
|
||||
{/* ── 7. 최근 히스토리 ──────────────────────── */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
{t('dashboard.recentTranscriptions').toUpperCase()}
|
||||
<PhosphorText variant="heading" sx={{ mb: 2, display: 'block' }}>
|
||||
{t('dashboard.recentTranscriptions')}
|
||||
</PhosphorText>
|
||||
|
||||
{history.length === 0 ? (
|
||||
|
|
@ -464,10 +568,10 @@ export function DashboardPage(): React.ReactElement {
|
|||
<Box key={group.label} sx={{ mb: 3 }}>
|
||||
{/* 날짜 구분자 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
||||
{group.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
|
||||
{t('dashboard.entries', { count: group.entries.length })}
|
||||
</PhosphorText>
|
||||
|
|
|
|||
47
apps/desktop/src/renderer/styles/global.css
Normal file
47
apps/desktop/src/renderer/styles/global.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* src/renderer/styles/global.css
|
||||
* v2 "Midnight Glass" 전역 스타일 — 폰트, 보더리스 스크롤, 선택 색상.
|
||||
* 색상은 전부 CSS 변수(테마 SSOT) 참조 — hex 하드코딩 금지. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pretendard Variable';
|
||||
font-weight: 45 920;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/PretendardVariable.woff2') format('woff2-variations');
|
||||
}
|
||||
|
||||
/* 보더리스 UI: 네이티브 스크롤바 완전 제거 —
|
||||
* 스크롤 유무와 무관하게 레이아웃 총 폭 불변.
|
||||
* 시각적 인디케이터는 OverlayScrollbars(고유 스타일)가 담당. */
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-primary);
|
||||
}
|
||||
|
||||
/* ── OverlayScrollbars 고유 스타일 ───────────────────────────
|
||||
* 스톡 os-theme-dark의 레이아웃을 그대로 쓰고 색/크기 변수만 D3RO 토큰으로 오버라이드
|
||||
* (커스텀 테마 클래스는 포지셔닝 CSS가 없어 스크롤바가 잘못된 위치에 그려짐) */
|
||||
.os-theme-dark {
|
||||
--os-size: 10px;
|
||||
--os-padding-perpendicular: 3px;
|
||||
--os-padding-axis: 6px;
|
||||
--os-track-bg: transparent;
|
||||
--os-track-bg-hover: transparent;
|
||||
--os-track-bg-active: transparent;
|
||||
--os-handle-border-radius: 999px;
|
||||
--os-handle-bg: var(--d3-glass-hairlineStrong);
|
||||
--os-handle-bg-hover: var(--d3-accent-dim);
|
||||
--os-handle-bg-active: var(--d3-accent-main);
|
||||
--os-handle-min-size: 36px;
|
||||
}
|
||||
|
|
@ -2,6 +2,28 @@
|
|||
|
||||
> 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩)
|
||||
|
||||
## UI 전면 리디자인 "Midnight Glass" v2 (2026-07-21) — 기반 완성 ✅
|
||||
|
||||
### 방향 (사용자 레퍼런스 이미지 기반, /goal)
|
||||
미드나이트 네이비 + 글래스모피즘 카드(옅은 헤어라인) + 시안→블루→퍼플→마젠타 그라디언트 + 보더리스 UI. **기존 테마 시스템(6종 변형) 유지가 필수 조건**.
|
||||
|
||||
### 구현
|
||||
- **theme.ts 전면 재작성**: RawTheme에 glass(surface/raised/hairline/sheen/blur)·gradient(accent/logo/bar/barAlt/wave1-4)·glow 토큰군 추가. 기본 dark = Midnight(#0a0e1c 베이스, 블루 #3b82f6 액센트). 6개 테마 전부 파생 토큰 보유(Nord로 실사용 검증됨 — 테마 전환 정상). d3roPalette에 glass/gradient/glow + accent.main 별칭(amber는 deprecated 별칭 유지 → 전 페이지 무수정 호환)
|
||||
- **DS 리스킨(동일 API)**: MetalCard→글래스 카드, PhysicalButton→글래스/그라디언트 버튼, PhosphorText→Pretendard 산세리프(메타류만 모노), Led→소프트 글로우(+blue/purple), ScreenPanel/ButtonGroup/InstrumentPanel 글래스화, **CrtDisplay를 WebGL 오실로스코프→그라디언트 스펙트럼 웨이브로 교체(API 호환)**
|
||||
- **신규 DS**: GradientWave(canvas 2D, CSS 변수로 테마 반응, 미러 스펙트럼+글로우+유휴 애니메이션 — "음파" 자체 개발), StatRing(컬러 그라디언트 링 타일)
|
||||
- **셸**: frame:false 보더리스 + TitleBar(드래그+min/max/close, lucide), AppLayout 와이드 사이드바(그라디언트 로고, 액티브=그라디언트 헤어라인 필+글로우, lucide 아이콘), 앰비언트 그라디언트 오브 배경
|
||||
- **스크롤**: 네이티브 스크롤바 width 0 전역 제거(레이아웃 폭 불변) + OverlayScrollbars(os-theme-dark 변수 오버라이드; 커스텀 테마 클래스는 포지셔닝 CSS가 없어 오동작 — 스톡 테마+변수 방식이 정답)
|
||||
- **폰트**: Pretendard Variable 로컬 번들(2MB woff2, 오프라인)
|
||||
- **대시보드 재구축**: 히어로(헤드라인+수치+CTA+웨이브), 링 타일 4종, 백엔드/요금제, 사용량(그라디언트 바)/실시간 자막, 시스템 상태(웨이브+세그먼트 레벨미터), 파일 전사, 히스토리. i18n 신규 19키(ko/en)
|
||||
- **StatusBar**: 브랜드+버전+모델 / 로컬 시간 시계
|
||||
- 검증: tsc 0, vitest 41/41, 실행 스크린샷으로 대시보드/히스토리/대화/회의/지식/설정 전 페이지 확인. 스크롤 시 폭 불변 확인. 상단 1px 잡티는 detached DevTools 겹침(앱 버그 아님)
|
||||
|
||||
### 남은 마감(진행 중)
|
||||
1. 내부 페이지들의 @mui/icons-material → lucide-react 통일
|
||||
2. Vanilla 팝업 5종(recording-tip 등) 스타일 v2 정합
|
||||
3. VoiceRecordingPanel(대화 몰입 패널) → GradientWave 교체
|
||||
4. 라이트 테마 시각 검수, v0.2.0-alpha 릴리스
|
||||
|
||||
## 모델 현대화 트랙 (2026-07-21) — B 완료, A 완료(코드), C 완료(코드)
|
||||
|
||||
### 방향 결정 (사용자 컨펌)
|
||||
|
|
|
|||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -132,7 +132,10 @@
|
|||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"lucide-react": "^1.25.0",
|
||||
"node-record-lpcm16": "^1.0.1",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -12134,6 +12137,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
|
||||
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -14352,6 +14364,22 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/overlayscrollbars": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz",
|
||||
"integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/overlayscrollbars-react": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
|
||||
"integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"overlayscrollbars": "^2.0.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,24 @@
|
|||
"dashboard.noHistory": "No history — press your hotkey to start recording",
|
||||
"dashboard.copy": "Copy",
|
||||
"dashboard.entries": "{{count}} entries",
|
||||
"dashboard.heroIdle": "Ready for voice recognition",
|
||||
"dashboard.viewStats": "View Stats",
|
||||
"dashboard.systemSettings": "System Settings",
|
||||
"dashboard.keySetup": "Key Setup",
|
||||
"dashboard.systemOk": "System OK",
|
||||
"dashboard.systemIssue": "Needs Attention",
|
||||
"dashboard.captionPlaceholder": "Recognized text will appear here in real time.",
|
||||
"dashboard.captionStandby": "Standby",
|
||||
"dashboard.inputLevel": "Input Level",
|
||||
"dashboard.recordingTime": "Recording Time",
|
||||
"dashboard.recognizedWords": "Words Recognized",
|
||||
"dashboard.streakUse": "Streak",
|
||||
"dashboard.modelLabel": "Model",
|
||||
"dashboard.statusLabel": "Status",
|
||||
"dashboard.localConnected": "Local",
|
||||
"dashboard.planFree": "Free Plan",
|
||||
"dashboard.planFeatureLocal": "Unlimited local backend",
|
||||
"dashboard.planFeatureDaily": "Unlimited daily transcription",
|
||||
"dashboard.stat": "Stats",
|
||||
"dashboard.sys": "System",
|
||||
"dashboard.currentBackend": "Current Backend",
|
||||
|
|
@ -155,6 +173,7 @@
|
|||
"settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)",
|
||||
"settings.about.description": "A fully local AI voice assistant built on Speakly reverse-engineering insights. Runs entirely offline with no cloud dependencies.",
|
||||
"settings.about.restartOnboarding": "Restart Setup Wizard",
|
||||
"status.localTime": "Local Time",
|
||||
"status.ollama": "OLLAMA",
|
||||
"status.offline": "OFFLINE",
|
||||
"status.nudge.title": "Ollama is not running",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,24 @@
|
|||
"dashboard.noHistory": "히스토리 없음 — 핫키를 눌러 녹음을 시작하세요",
|
||||
"dashboard.copy": "복사",
|
||||
"dashboard.entries": "{{count}}건",
|
||||
"dashboard.heroIdle": "실시간 음성 인식 대기 중",
|
||||
"dashboard.viewStats": "통계 보기",
|
||||
"dashboard.systemSettings": "시스템 설정",
|
||||
"dashboard.keySetup": "키 설정",
|
||||
"dashboard.systemOk": "시스템 정상",
|
||||
"dashboard.systemIssue": "점검 필요",
|
||||
"dashboard.captionPlaceholder": "여기에 실시간 인식된 텍스트가 표시됩니다.",
|
||||
"dashboard.captionStandby": "대기 중",
|
||||
"dashboard.inputLevel": "입력 레벨",
|
||||
"dashboard.recordingTime": "녹음 시간",
|
||||
"dashboard.recognizedWords": "인식 단어 수",
|
||||
"dashboard.streakUse": "연속 사용",
|
||||
"dashboard.modelLabel": "모델",
|
||||
"dashboard.statusLabel": "상태",
|
||||
"dashboard.localConnected": "로컬 연결",
|
||||
"dashboard.planFree": "무료 플랜",
|
||||
"dashboard.planFeatureLocal": "로컬 백엔드 무제한",
|
||||
"dashboard.planFeatureDaily": "일일 전사 무제한",
|
||||
"dashboard.stat": "통계",
|
||||
"dashboard.sys": "시스템",
|
||||
"dashboard.currentBackend": "현재 백엔드",
|
||||
|
|
@ -156,6 +174,7 @@
|
|||
"settings.about.voiceEngineValue": "STT: faster-whisper (로컬) / LLM: Ollama (로컬)",
|
||||
"settings.about.description": "Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다.",
|
||||
"settings.about.restartOnboarding": "초기 설정 안내 다시 보기",
|
||||
"status.localTime": "로컬 시간",
|
||||
"status.ollama": "OLLAMA",
|
||||
"status.offline": "OFFLINE",
|
||||
"status.nudge.title": "Ollama가 실행되지 않고 있어요",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/ButtonGroup.tsx
|
||||
// 시안 A: 인셋 버튼 클러스터 — 레퍼런스의 .button-group 패턴
|
||||
// 물리 버튼들을 인셋 패널 안에 배치하여 그룹화
|
||||
// v2 "Midnight Glass": 세그먼트 글래스 그룹 — 반투명 표면에 버튼 클러스터
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { d3roPalette, d3roRadius } from '../../theme'
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -20,7 +19,7 @@ export function ButtonGroup({ children, horizontal = false }: ButtonGroupProps):
|
|||
bgcolor: d3roPalette.bg.inset,
|
||||
p: '6px',
|
||||
borderRadius: d3roRadius.inner,
|
||||
boxShadow: d3roShadow.inset,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
display: 'flex',
|
||||
flexDirection: horizontal ? 'row' : 'column',
|
||||
gap: '6px',
|
||||
|
|
|
|||
|
|
@ -1,284 +1,98 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/CrtDisplay.tsx
|
||||
// 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형)
|
||||
// v2 "Midnight Glass": 히어로 디스플레이 — 딥 글래스 패널 + 그라디언트 스펙트럼 웨이브.
|
||||
// (v1 WebGL 오실로스코프를 대체. props API는 v1과 호환 유지.)
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme'
|
||||
|
||||
// ── WebGL 유틸 ─────────────────────────────────────────
|
||||
|
||||
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource)
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource)
|
||||
if (!vs || !fs) return null
|
||||
const prog = gl.createProgram()
|
||||
if (!prog) return null
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
return prog
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
vUv = position * 0.5 + 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec2 vUv;
|
||||
uniform float u_time;
|
||||
uniform float u_glitch;
|
||||
uniform float u_amp;
|
||||
uniform float u_freq;
|
||||
|
||||
float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); }
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Glitch displacement
|
||||
if (u_glitch > 0.0) {
|
||||
float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1;
|
||||
uv.x += gOffset;
|
||||
}
|
||||
|
||||
// Base phosphor background
|
||||
vec3 color = vec3(0.02, 0.015, 0.01);
|
||||
|
||||
// Background Grid
|
||||
float gridX = step(0.98, fract(uv.x * 15.0));
|
||||
float gridY = step(0.98, fract(uv.y * 10.0));
|
||||
color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch);
|
||||
|
||||
// Oscilloscope Waveform
|
||||
float t = u_time * 0.5 + u_glitch * random(uv) * 0.1;
|
||||
float waveY = sin((uv.x * u_freq) + t) * u_amp;
|
||||
float waveDist = abs((uv.y - 0.5) - waveY);
|
||||
float lineThick = 0.005 + (u_glitch * 0.02);
|
||||
float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist);
|
||||
float waveCore = smoothstep(lineThick, 0.0, waveDist);
|
||||
|
||||
// Amber wave (#f25b29)
|
||||
vec3 waveColor = vec3(0.95, 0.35, 0.16);
|
||||
color += waveColor * waveCore;
|
||||
color += waveColor * 0.4 * waveGlow;
|
||||
|
||||
// Scanlines
|
||||
float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04;
|
||||
color -= scanline;
|
||||
|
||||
// Noise
|
||||
float noise = random(uv + u_time) * 0.08;
|
||||
color += noise;
|
||||
|
||||
// Vignette
|
||||
float dist = distance(vUv, vec2(0.5));
|
||||
float vig = smoothstep(0.8, 0.4, dist);
|
||||
color *= vig;
|
||||
|
||||
// Edge darkening
|
||||
color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x);
|
||||
color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||
|
||||
// ── Props ──────────────────────────────────────────────
|
||||
import { d3roPalette, d3roFontSans, d3roRadius } from '../../theme'
|
||||
import { GradientWave } from './GradientWave'
|
||||
|
||||
interface CrtDisplayProps {
|
||||
/** 파형 진폭 (0.0 ~ 1.0) */
|
||||
/** 유휴 파형 진폭 (0.0 ~ 1.0) */
|
||||
amplitude?: number
|
||||
/** 파형 주파수 */
|
||||
/** 파형 속도 배율 (v1 주파수 개념 호환 — 8 기준 1배) */
|
||||
frequency?: number
|
||||
/** 글리치 트리거 (변경 시 글리치 발생) */
|
||||
/** 글리치 트리거 (변경 시 플래시 발생) */
|
||||
glitchTrigger?: number
|
||||
/** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */
|
||||
audioLevel?: number
|
||||
/** 오버레이 콘텐츠 (인광 텍스트 등) */
|
||||
/** 오버레이 콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
/** 높이 (기본 280px) */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function CrtDisplay({
|
||||
amplitude = 0.1,
|
||||
amplitude = 0.25,
|
||||
frequency = 8.0,
|
||||
glitchTrigger = 0,
|
||||
audioLevel = 0,
|
||||
children,
|
||||
height = 280,
|
||||
}: CrtDisplayProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const glRef = useRef<{
|
||||
gl: WebGLRenderingContext
|
||||
uTime: WebGLUniformLocation | null
|
||||
uGlitch: WebGLUniformLocation | null
|
||||
uAmp: WebGLUniformLocation | null
|
||||
uFreq: WebGLUniformLocation | null
|
||||
} | null>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const startTimeRef = useRef(Date.now())
|
||||
const glitchRef = useRef(0)
|
||||
const ampRef = useRef(amplitude)
|
||||
const freqRef = useRef(frequency)
|
||||
const audioLevelRef = useRef(audioLevel)
|
||||
const currentAmpRef = useRef(amplitude)
|
||||
const currentFreqRef = useRef(frequency)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
// amplitude/frequency/audioLevel 변경 추적
|
||||
useEffect(() => {
|
||||
ampRef.current = amplitude
|
||||
freqRef.current = frequency
|
||||
}, [amplitude, frequency])
|
||||
|
||||
useEffect(() => {
|
||||
audioLevelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
// 글리치 트리거
|
||||
// v1 글리치 → v2 소프트 플래시
|
||||
useEffect(() => {
|
||||
if (glitchTrigger > 0) {
|
||||
glitchRef.current = 1.0
|
||||
setFlash(true)
|
||||
const timer = setTimeout(() => setFlash(false), 220)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [glitchTrigger])
|
||||
|
||||
const render = useCallback(() => {
|
||||
const ctx = glRef.current
|
||||
if (!ctx) return
|
||||
|
||||
const { gl, uTime, uGlitch, uAmp, uFreq } = ctx
|
||||
|
||||
// audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭
|
||||
const targetAmp = ampRef.current + audioLevelRef.current * 0.35
|
||||
|
||||
// Smoothing
|
||||
currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15
|
||||
currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1
|
||||
glitchRef.current *= 0.85
|
||||
|
||||
gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000)
|
||||
gl.uniform1f(uGlitch, glitchRef.current)
|
||||
gl.uniform1f(uAmp, currentAmpRef.current)
|
||||
gl.uniform1f(uFreq, currentFreqRef.current)
|
||||
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const gl = canvas.getContext('webgl', { alpha: true })
|
||||
if (!gl) return
|
||||
|
||||
canvas.width = canvas.clientWidth * 2
|
||||
canvas.height = canvas.clientHeight * 2
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
if (!prog) return
|
||||
|
||||
gl.useProgram(prog)
|
||||
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW)
|
||||
|
||||
const posLoc = gl.getAttribLocation(prog, 'position')
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
glRef.current = {
|
||||
gl,
|
||||
uTime: gl.getUniformLocation(prog, 'u_time'),
|
||||
uGlitch: gl.getUniformLocation(prog, 'u_glitch'),
|
||||
uAmp: gl.getUniformLocation(prog, 'u_amp'),
|
||||
uFreq: gl.getUniformLocation(prog, 'u_freq'),
|
||||
}
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
}
|
||||
}, [render])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height,
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '8px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Glass surface */}
|
||||
{/* 앰비언트 배경 글로우 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '6px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
overflow: 'hidden',
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
inset: 0,
|
||||
background: `radial-gradient(ellipse 70% 55% at 50% 100%, ${d3roPalette.accent.dim} 0%, transparent 70%)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 라이트 모드에서 WebGL 셰이더(다크 전용)를 반전하여 밝은 배경에 어울리게 조정
|
||||
...(isLight && { filter: 'invert(0.88) hue-rotate(180deg)', opacity: 0.9 }),
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
|
||||
{/* Glass reflection */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
{/* 그라디언트 스펙트럼 웨이브 — 하단 영역 */}
|
||||
<Box sx={{ position: 'absolute', left: 12, right: 12, bottom: 8, height: '38%' }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={amplitude} speed={frequency / 8} />
|
||||
</Box>
|
||||
|
||||
{/* Content overlay (phosphor text) */}
|
||||
{/* 상단 시인 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 플래시 오버레이 (글리치 호환) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: d3roPalette.accent.dim,
|
||||
opacity: flash ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 오버레이 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
|
@ -287,10 +101,9 @@ export function CrtDisplay({
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
color: d3roPalette.accent.amber,
|
||||
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
||||
color: d3roPalette.text.primary,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: d3roFontMono,
|
||||
fontFamily: d3roFontSans,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
165
packages/ui/src/components/ds/GradientWave.tsx
Normal file
165
packages/ui/src/components/ds/GradientWave.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/GradientWave.tsx
|
||||
// v2 시그니처 비주얼: 시안→블루→퍼플→마젠타 그라디언트 스펙트럼 웨이브.
|
||||
// canvas 2D — 테마 CSS 변수(--d3-gradient-wave1..4)를 읽어 테마 전환에 자동 반응.
|
||||
// 미러형(중앙선 기준 상하 대칭) 바 스펙트럼 + 소프트 글로우 + 유휴 애니메이션.
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
interface GradientWaveProps {
|
||||
/** 실시간 오디오 레벨 0~1 (없으면 유휴 애니메이션만) */
|
||||
audioLevel?: number
|
||||
/** 유휴 상태 기본 진폭 0~1 (기본 0.25) */
|
||||
idleAmplitude?: number
|
||||
/** 애니메이션 속도 배율 (기본 1) */
|
||||
speed?: number
|
||||
/** 바 폭 px (기본 3) */
|
||||
barWidth?: number
|
||||
/** 바 간격 px (기본 3) */
|
||||
barGap?: number
|
||||
/** 글로우 강도 px (기본 8, 0이면 비활성) */
|
||||
glow?: number
|
||||
/** 높이 (CSS 값, 기본 '100%') */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
/** :root에 주입된 웨이브 그라디언트 스톱을 읽는다 */
|
||||
function readWaveColors(): [string, string, string, string] {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const read = (name: string, fallback: string): string =>
|
||||
style.getPropertyValue(name).trim() || fallback
|
||||
return [
|
||||
read('--d3-gradient-wave1', '#22d3ee'),
|
||||
read('--d3-gradient-wave2', '#3b82f6'),
|
||||
read('--d3-gradient-wave3', '#8b5cf6'),
|
||||
read('--d3-gradient-wave4', '#e879f9'),
|
||||
]
|
||||
}
|
||||
|
||||
/** 결정적 의사난수 (바 인덱스별 고정 시드) */
|
||||
function seeded(i: number): number {
|
||||
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
export function GradientWave({
|
||||
audioLevel = 0,
|
||||
idleAmplitude = 0.25,
|
||||
speed = 1,
|
||||
barWidth = 3,
|
||||
barGap = 3,
|
||||
glow = 8,
|
||||
height = '100%',
|
||||
}: GradientWaveProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const levelRef = useRef(audioLevel)
|
||||
const smoothedRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
levelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let width = 0
|
||||
let heightPx = 0
|
||||
let colors = readWaveColors()
|
||||
let gradient: CanvasGradient | null = null
|
||||
let frame = 0
|
||||
const start = performance.now()
|
||||
|
||||
const rebuildGradient = (): void => {
|
||||
gradient = ctx.createLinearGradient(0, 0, width, 0)
|
||||
gradient.addColorStop(0, colors[0])
|
||||
gradient.addColorStop(0.38, colors[1])
|
||||
gradient.addColorStop(0.7, colors[2])
|
||||
gradient.addColorStop(1, colors[3])
|
||||
}
|
||||
|
||||
const resize = (): void => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
width = canvas.clientWidth
|
||||
heightPx = canvas.clientHeight
|
||||
canvas.width = Math.max(1, Math.round(width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(heightPx * dpr))
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
rebuildGradient()
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(canvas)
|
||||
resize()
|
||||
|
||||
const render = (): void => {
|
||||
frame += 1
|
||||
// 테마 전환 대응: 1초마다 색 재판독
|
||||
if (frame % 60 === 0) {
|
||||
const next = readWaveColors()
|
||||
if (next.join() !== colors.join()) {
|
||||
colors = next
|
||||
rebuildGradient()
|
||||
}
|
||||
}
|
||||
|
||||
// 레벨 스무딩 (상승 빠르게, 하강 느리게)
|
||||
const target = Math.min(1, levelRef.current)
|
||||
const k = target > smoothedRef.current ? 0.4 : 0.08
|
||||
smoothedRef.current += (target - smoothedRef.current) * k
|
||||
|
||||
ctx.clearRect(0, 0, width, heightPx)
|
||||
if (!gradient || width <= 0) {
|
||||
raf = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
const t = ((performance.now() - start) / 1000) * speed
|
||||
const step = barWidth + barGap
|
||||
const count = Math.max(1, Math.floor(width / step))
|
||||
const centerY = heightPx / 2
|
||||
const maxHalf = heightPx / 2 - 1
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
if (glow > 0) {
|
||||
ctx.shadowBlur = glow
|
||||
ctx.shadowColor = colors[1]
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = i * step
|
||||
const s = seeded(i)
|
||||
// 유휴: 느리게 흐르는 다중 사인 + 바별 시드 → 오가닉한 스펙트럼
|
||||
const idle =
|
||||
(Math.sin(t * 1.4 + i * 0.35) * 0.5 + 0.5) * 0.55 +
|
||||
(Math.sin(t * 0.7 + i * 0.11 + s * 6.28) * 0.5 + 0.5) * 0.45
|
||||
const envelope = idleAmplitude * (0.25 + idle * 0.75) + smoothedRef.current * (0.4 + s * 0.6)
|
||||
const half = Math.max(1.5, Math.min(1, envelope) * maxHalf)
|
||||
const radius = Math.min(barWidth / 2, half)
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, centerY - half, barWidth, half * 2, radius)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
raf = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [idleAmplitude, speed, barWidth, barGap, glow])
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height, minHeight: 0 }}>
|
||||
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/InstrumentPanel.tsx
|
||||
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
||||
// v2 "Midnight Glass": 히어로 글래스 패널 — 앰비언트 그라디언트 배경 + 헤어라인.
|
||||
// v1의 각인(engraving) props는 호환 유지하되 모서리 메타 라벨로 은은하게 렌더.
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface InstrumentPanelProps {
|
||||
children: React.ReactNode
|
||||
/** 상단 좌측 각인 */
|
||||
/** 상단 좌측 메타 라벨 */
|
||||
engravingLeft?: string
|
||||
/** 상단 우측 각인 */
|
||||
/** 상단 우측 메타 라벨 */
|
||||
engravingRight?: string
|
||||
/** 하단 중앙 각인 */
|
||||
/** 하단 중앙 메타 라벨 */
|
||||
engravingBottom?: string
|
||||
}
|
||||
|
||||
|
|
@ -27,30 +27,30 @@ export function InstrumentPanel({
|
|||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
bgcolor: d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
borderRadius: d3roRadius.outer,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
p: 3,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
// 메탈 노이즈는 CSS로 시뮬레이션
|
||||
boxShadow: d3roShadow.card,
|
||||
overflow: 'hidden',
|
||||
// 앰비언트 액센트 글로우 (좌상단 은은한 빛)
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: d3roRadius.outer,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
opacity: 0.04,
|
||||
mixBlendMode: 'overlay',
|
||||
borderRadius: 'inherit',
|
||||
background: `radial-gradient(ellipse 60% 40% at 18% 0%, ${d3roPalette.accent.dim} 0%, transparent 60%)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 각인 텍스트 */}
|
||||
{/* 모서리 메타 라벨 */}
|
||||
<Engraving sx={{ top: 12, left: 24 }}>{engravingLeft}</Engraving>
|
||||
<Engraving sx={{ top: 12, right: 24 }}>{engravingRight}</Engraving>
|
||||
<Engraving sx={{ bottom: 12, left: '50%', transform: 'translateX(-50%)' }}>{engravingBottom}</Engraving>
|
||||
|
||||
{/* 콘텐츠 (z-index 5로 노이즈 위) */}
|
||||
<Box sx={{ position: 'relative', zIndex: 5 }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
|
@ -59,20 +59,17 @@ export function InstrumentPanel({
|
|||
}
|
||||
|
||||
function Engraving({ children, sx }: { children: string; sx: Record<string, unknown> }): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
color: d3roPalette.text.engraving,
|
||||
textShadow: isLight
|
||||
? '0 -1px 0 rgba(0,0,0,0.1)'
|
||||
: '0 1px 0 rgba(255,255,255,0.08)',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
opacity: 0.7,
|
||||
fontWeight: 700,
|
||||
fontFamily: d3roFontMono,
|
||||
textTransform: 'uppercase',
|
||||
zIndex: 2,
|
||||
userSelect: 'none',
|
||||
...sx,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/Led.tsx
|
||||
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
||||
// v2 "Midnight Glass": 상태 도트 — 소프트 글로우, 활성 시 pulse
|
||||
// (레퍼런스의 "● READY" / "● 시스템 정상" 인디케이터)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
|
||||
|
||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
|
||||
// 'amber'는 v1 호환 별칭 — 현재 테마의 프라이머리 액센트 색으로 렌더
|
||||
amber: { bg: d3roPalette.accent.main, glow: d3roPalette.accent.glow },
|
||||
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
||||
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
||||
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
||||
blue: { bg: d3roPalette.tag.blue, glow: d3roPalette.tag.blueGlow },
|
||||
purple: { bg: d3roPalette.tag.purple, glow: d3roPalette.tag.purpleGlow },
|
||||
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
||||
}
|
||||
|
||||
|
|
@ -34,16 +38,14 @@ export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React
|
|||
borderRadius: '50%',
|
||||
bgcolor: c.bg,
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive
|
||||
? `inset 0 1px 2px rgba(255,255,255,0.5), 0 0 10px ${c.glow}`
|
||||
: 'inset 0 1px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.05)',
|
||||
transition: 'all 0.1s',
|
||||
boxShadow: isActive ? `0 0 ${size * 1.2}px ${c.glow}` : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(pulse && isActive
|
||||
? {
|
||||
animation: 'led-pulse 1.5s ease-in-out infinite',
|
||||
animation: 'led-pulse 1.6s ease-in-out infinite',
|
||||
'@keyframes led-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
'50%': { opacity: 0.45 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/MetalCard.tsx
|
||||
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
||||
// 토큰 적용: d3roShadow, d3roRadius
|
||||
// packages/ui/src/components/ds/MetalCard.tssx
|
||||
// v2 "Midnight Glass": 글래스모피즘 카드 — 반투명 표면 + 옅은 헤어라인 + 상단 시인
|
||||
// 토큰 적용: d3roPalette.glass, d3roShadow, d3roRadius
|
||||
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
|
||||
interface MetalCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** 카드 내부의 상승 표면 (카드 안의 카드) */
|
||||
inset?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -21,14 +22,27 @@ export function MetalCard({
|
|||
<Box
|
||||
{...boxProps}
|
||||
sx={{
|
||||
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
position: 'relative',
|
||||
bgcolor: inset ? d3roPalette.glass.raised : d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
||||
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: inset ? d3roShadow.inset : d3roShadow.card,
|
||||
p: inset ? '6px' : 3,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: inset ? 'none' : d3roShadow.card,
|
||||
p: inset ? 2 : 3,
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
|
||||
transition: 'border-color 0.2s ease',
|
||||
// 상단 시인(광택) 오버레이
|
||||
'&::before': inset
|
||||
? {}
|
||||
: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': inset ? {} : { borderColor: d3roPalette.glass.hairlineStrong },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhosphorText.tsx
|
||||
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
||||
// 확장: title/stat/body/compact/meta/engrave/micro/nano 변형 추가
|
||||
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
|
||||
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
|
||||
// (변형 API는 v1과 동일 — 전 페이지 호환)
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
|
||||
|
||||
type PhosphorVariant =
|
||||
| 'hero' | 'title' | 'value' | 'heading'
|
||||
|
|
@ -16,31 +17,27 @@ type PhosphorVariant =
|
|||
interface VariantDef {
|
||||
fontSize: string
|
||||
color: string
|
||||
glow: string
|
||||
fontWeight: number
|
||||
letterSpacing: string
|
||||
lineHeight: number
|
||||
textTransform?: 'uppercase' | 'none'
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
const amberGlowStrong = `0 0 8px ${d3roPalette.accent.amberGlow}`
|
||||
const amberGlowMedium = `0 0 6px rgba(242, 91, 41, 0.4)`
|
||||
const amberGlowSoft = `0 0 4px rgba(242, 91, 41, 0.3)`
|
||||
|
||||
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.accent.amber, glow: amberGlowStrong, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.accent.amber, glow: amberGlowMedium, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.accent.amber, glow: amberGlowSoft, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
||||
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase' },
|
||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
||||
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.engraving, glow: 'none', fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase' },
|
||||
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase' },
|
||||
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase' },
|
||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
||||
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase', mono: true },
|
||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
||||
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase', mono: true },
|
||||
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase', mono: true },
|
||||
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase', mono: true },
|
||||
}
|
||||
|
||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||
|
|
@ -54,11 +51,10 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
|
|||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
|
||||
fontSize: v.fontSize,
|
||||
fontWeight: v.fontWeight,
|
||||
color: v.color,
|
||||
textShadow: v.glow !== 'none' ? v.glow : 'none',
|
||||
letterSpacing: v.letterSpacing,
|
||||
lineHeight: v.lineHeight,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhysicalButton.tsx
|
||||
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
||||
// 토큰 적용: d3roShadow, d3roRadius, d3roTypo
|
||||
// v2 "Midnight Glass": 글래스 버튼 — 기본은 반투명 표면 + 헤어라인,
|
||||
// selected 시 액센트 그라디언트 + 글로우 (레퍼런스 프라이머리 버튼)
|
||||
|
||||
import { Button, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
selected?: boolean
|
||||
|
|
@ -16,27 +16,29 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
|
|||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
height: 44,
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
|
||||
border: 'none',
|
||||
borderRadius: d3roRadius.small,
|
||||
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontFamily: d3roFontMono,
|
||||
height: 42,
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.glass.raised,
|
||||
backgroundImage: selected ? d3roPalette.gradient.accent : 'none',
|
||||
border: `1px solid ${selected ? 'transparent' : d3roPalette.glass.hairline}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
color: selected ? '#fff' : d3roPalette.text.secondary,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.small.size,
|
||||
fontWeight: d3roTypo.small.weight,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised,
|
||||
transform: selected ? 'translateY(1px)' : 'none',
|
||||
transition: 'all 0.05s linear',
|
||||
boxShadow: selected ? d3roShadow.glowAccent : 'none',
|
||||
transition: 'filter 0.15s ease, border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonActive,
|
||||
filter: 'brightness(0.92)',
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.bg.cardHover,
|
||||
borderColor: selected ? 'transparent' : d3roPalette.glass.hairlineStrong,
|
||||
color: selected ? '#fff' : d3roPalette.text.primary,
|
||||
filter: selected ? 'brightness(1.08)' : 'none',
|
||||
},
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
textTransform: 'none',
|
||||
letterSpacing: d3roTypo.small.spacing,
|
||||
minWidth: 0,
|
||||
...sx,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/ScreenPanel.tsx
|
||||
// 시안 A: CRT 없는 순수 스크린 패널 — 인셋 베젤 + 글래스 반사 + 인광 텍스트용
|
||||
// 레퍼런스의 .display-module > .screen-glass 패턴
|
||||
// v2 "Midnight Glass": 딥 글래스 패널 — 카드 내부의 어두운 정보 표시 영역
|
||||
// (레퍼런스의 히어로 카드 내부 / "현재 백엔드" 내부 패널 패턴)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roShadow } from '../../theme'
|
||||
import { d3roPalette, d3roRadius } from '../../theme'
|
||||
|
||||
interface ScreenPanelProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -15,45 +14,26 @@ interface ScreenPanelProps {
|
|||
}
|
||||
|
||||
export function ScreenPanel({ children, height }: ScreenPanelProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '12px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
overflow: 'hidden',
|
||||
height,
|
||||
// 상단 시인
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 글래스 배경 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
// 상단 반사 (레퍼런스의 .screen-glass::after)
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 오버레이 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
|
|
|
|||
66
packages/ui/src/components/ds/StatRing.tsx
Normal file
66
packages/ui/src/components/ds/StatRing.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/StatRing.tsx
|
||||
// v2 신규: 컬러 그라디언트 링 아이콘 — 스탯 타일의 원형 링 (레퍼런스 4타일 패턴)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type RingColor = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'accent'
|
||||
|
||||
const RING_COLORS: Record<RingColor, { from: string; to: string; glow: string; icon: string }> = {
|
||||
blue: { from: '#22d3ee', to: '#3b82f6', glow: 'rgba(59,130,246,0.35)', icon: d3roPalette.tag.blue },
|
||||
purple: { from: '#8b5cf6', to: '#d946ef', glow: 'rgba(167,139,250,0.35)', icon: d3roPalette.tag.purple },
|
||||
green: { from: '#34d399', to: '#10b981', glow: 'rgba(52,211,153,0.35)', icon: d3roPalette.tag.green },
|
||||
orange: { from: '#fbbf24', to: '#f97316', glow: 'rgba(251,146,60,0.35)', icon: d3roPalette.tag.orange },
|
||||
red: { from: '#f87171', to: '#ef4444', glow: 'rgba(248,113,113,0.35)', icon: d3roPalette.tag.red },
|
||||
accent: { from: 'var(--d3-accent-light)', to: 'var(--d3-accent-dark)', glow: 'var(--d3-accent-dim)', icon: d3roPalette.accent.light },
|
||||
}
|
||||
|
||||
interface StatRingProps {
|
||||
/** 링 색 (기본 blue) */
|
||||
color?: RingColor
|
||||
/** 외경 px (기본 56) */
|
||||
size?: number
|
||||
/** 링 두께 px (기본 2) */
|
||||
thickness?: number
|
||||
/** 중앙 아이콘/콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function StatRing({
|
||||
color = 'blue',
|
||||
size = 56,
|
||||
thickness = 2,
|
||||
children,
|
||||
}: StatRingProps): React.ReactElement {
|
||||
const c = RING_COLORS[color]
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
p: `${thickness}px`,
|
||||
background: `conic-gradient(from 210deg, ${c.from}, ${c.to}, ${c.from})`,
|
||||
boxShadow: `0 0 ${size / 3.5}px ${c.glow}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: c.icon,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,3 +10,6 @@ export { PhosphorText } from './PhosphorText'
|
|||
export { MetalDial } from './MetalDial'
|
||||
export { ScreenPanel } from './ScreenPanel'
|
||||
export { ButtonGroup } from './ButtonGroup'
|
||||
// v2 "Midnight Glass" 신규
|
||||
export { GradientWave } from './GradientWave'
|
||||
export { StatRing } from './StatRing'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
// src/renderer/theme.ts
|
||||
// 08-design-system.md SSOT 기반 MUI 테마.
|
||||
// D3RO 디자인 시스템 SSOT — "Midnight Glass" v2.
|
||||
// CSS Custom Properties 기반: d3roPalette가 var()를 사용하여 테마 전환 시 자동 반응.
|
||||
//
|
||||
// v2 디자인 언어 (레퍼런스: 미드나이트 네이비 + 글래스모피즘):
|
||||
// - 딥 네이비 베이스 + 반투명 글래스 카드 + 옅은 헤어라인
|
||||
// - 시안→블루→퍼플→마젠타 그라디언트 (웨이브/프로그레스/로고)
|
||||
// - 컬러 링 스탯 타일, 소프트 글로우, 보더리스 UI
|
||||
// 테마 시스템: 6종(dark/light/nord/solarized/catppuccin/dracula) 전부
|
||||
// 동일 토큰 구조를 가지며 glass/gradient/glow도 테마별로 파생된다.
|
||||
|
||||
import { createTheme, type Theme } from '@mui/material/styles'
|
||||
import type { ThemeMode } from '@d3ro/core/types'
|
||||
|
|
@ -58,120 +65,268 @@ interface RawTheme {
|
|||
crtPhosphor: string
|
||||
crtPhosphorDim: string
|
||||
}
|
||||
/** v2: 글래스모피즘 표면 */
|
||||
glass: {
|
||||
/** 카드 표면 (반투명) */
|
||||
surface: string
|
||||
/** 카드 내부 상승 표면 */
|
||||
raised: string
|
||||
/** 헤어라인 보더 */
|
||||
hairline: string
|
||||
hairlineStrong: string
|
||||
/** 카드 상단 시인(광택) 오버레이 */
|
||||
sheen: string
|
||||
/** backdrop-filter blur 반경 */
|
||||
blur: string
|
||||
}
|
||||
/** v2: 그라디언트 */
|
||||
gradient: {
|
||||
/** 프라이머리 버튼/액티브 요소 */
|
||||
accent: string
|
||||
/** 로고/브랜드 텍스트 */
|
||||
logo: string
|
||||
/** 프로그레스 바 (시안→블루) */
|
||||
bar: string
|
||||
/** 프로그레스 바 대체 (퍼플→마젠타) */
|
||||
barAlt: string
|
||||
/** 웨이브폼 컬러 스톱 4개 (canvas에서 getComputedStyle로 읽음) */
|
||||
wave1: string
|
||||
wave2: string
|
||||
wave3: string
|
||||
wave4: string
|
||||
}
|
||||
/** v2: 글로우 */
|
||||
glow: {
|
||||
/** 프라이머리 버튼/액티브 글로우 */
|
||||
accent: string
|
||||
soft: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── 원시 색상값 (raw values) ────────────────────────────────────
|
||||
// CSS 변수에 주입되는 실제 색상값. 컴포넌트에서 직접 사용하지 않는다.
|
||||
const RAW: Record<ThemeKey, RawTheme> = {
|
||||
// ── Midnight (기본 다크) — 레퍼런스 시안 ────────────────────
|
||||
dark: {
|
||||
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21', sidebar: '#1e1f21', inset: '#1b1c1e', chassis: '#242528', crtBezel: '#1a1a1c', crtGlass: '#050605' },
|
||||
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e', engraving: '#1a1a1c', inactive: '#77797c', dimLabel: '#5c2615', muted: '#3a3b3f', hover: '#aaaaaa' },
|
||||
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
|
||||
bg: { app: '#0a0e1c', card: '#111a30', cardHover: '#152039', elevated: '#1a2540', input: '#0d1526', sidebar: '#0b101f', inset: '#0a111f', chassis: '#111a30', crtBezel: '#0d1424', crtGlass: '#070b16' },
|
||||
text: { primary: '#eef2fb', secondary: '#93a4c8', label: '#67789e', disabled: '#3c4763', engraving: '#0a0e1c', inactive: '#7385ab', dimLabel: '#4e5f85', muted: '#2b3550', hover: '#c9d5f2' },
|
||||
border: { subtle: 'rgba(148,180,255,0.07)', default: 'rgba(148,180,255,0.12)', strong: 'rgba(148,180,255,0.20)' },
|
||||
shadow: {
|
||||
card: '0 8px 30px rgba(0,0,0,0.3)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #111111, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.2)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.8)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
card: '0 12px 40px rgba(3,7,18,0.5)',
|
||||
buttonBase: '0 1px 2px rgba(3,7,18,0.4)',
|
||||
chassis: '0 24px 80px rgba(3,7,18,0.6)',
|
||||
inset: 'inset 0 1px 4px rgba(3,7,18,0.5)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(3,7,18,0.7)',
|
||||
buttonRaised: '0 2px 8px rgba(3,7,18,0.35), inset 0 1px 0 rgba(148,180,255,0.06)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(3,7,18,0.6)',
|
||||
screenGlow: 'inset 0 0 24px rgba(3,7,18,0.6)',
|
||||
tooltip: '0 8px 24px rgba(3,7,18,0.5)',
|
||||
},
|
||||
accent: { main: '#3b82f6', dim: 'rgba(59,130,246,0.14)', glow: 'rgba(59,130,246,0.5)', light: '#60a5fa', dark: '#2563eb', crtPhosphor: '#60a5fa', crtPhosphorDim: '#3564b4' },
|
||||
glass: {
|
||||
surface: 'rgba(17,26,48,0.62)',
|
||||
raised: 'rgba(26,38,68,0.55)',
|
||||
hairline: 'rgba(148,180,255,0.10)',
|
||||
hairlineStrong: 'rgba(148,180,255,0.18)',
|
||||
sheen: 'linear-gradient(180deg, rgba(148,180,255,0.06) 0%, rgba(148,180,255,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)',
|
||||
logo: 'linear-gradient(90deg, #60a5fa 0%, #818cf8 100%)',
|
||||
bar: 'linear-gradient(90deg, #22d3ee 0%, #3b82f6 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #8b5cf6 0%, #d946ef 100%)',
|
||||
wave1: '#22d3ee', wave2: '#3b82f6', wave3: '#8b5cf6', wave4: '#e879f9',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(59,130,246,0.35), 0 8px 24px rgba(37,99,235,0.35)',
|
||||
soft: '0 0 16px rgba(59,130,246,0.25)',
|
||||
},
|
||||
accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' },
|
||||
},
|
||||
// ── Light — 블루 액센트 유지, 밝은 글래스 ────────────────────
|
||||
light: {
|
||||
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#f7f7f9', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e2e2e5', crtBezel: '#d5d5d8', crtGlass: '#f0f0f2' },
|
||||
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#b07040', muted: '#b0b0b4', hover: '#555555' },
|
||||
border: { subtle: 'rgba(0,0,0,0.06)', default: 'rgba(0,0,0,0.10)', strong: 'rgba(0,0,0,0.16)' },
|
||||
bg: { app: '#f2f5fb', card: '#ffffff', cardHover: '#f7f9fd', elevated: '#eef2f9', input: '#ffffff', sidebar: '#e9eef7', inset: '#e4e9f2', chassis: '#ffffff', crtBezel: '#dde3ee', crtGlass: '#eef2f9' },
|
||||
text: { primary: '#111827', secondary: '#4b5a75', label: '#6b7a95', disabled: '#b5bfd1', engraving: '#dde3ee', inactive: '#8593ab', dimLabel: '#5a6f96', muted: '#c3cddd', hover: '#2b3a55' },
|
||||
border: { subtle: 'rgba(30,58,138,0.07)', default: 'rgba(30,58,138,0.12)', strong: 'rgba(30,58,138,0.20)' },
|
||||
shadow: {
|
||||
card: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
chassis: '0 40px 80px -20px rgba(0,0,0,0.08), 0 12px 0 #d5d5d8, 0 13px 4px rgba(0,0,0,0.06), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.08), 0 1px 1px rgba(255,255,255,0.6)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06), 0 1px 1px rgba(255,255,255,0.8)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.08), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.06)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.08)',
|
||||
card: '0 8px 30px rgba(30,58,138,0.08)',
|
||||
buttonBase: '0 1px 2px rgba(30,58,138,0.08)',
|
||||
chassis: '0 24px 80px rgba(30,58,138,0.10)',
|
||||
inset: 'inset 0 1px 4px rgba(30,58,138,0.06)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(30,58,138,0.10)',
|
||||
buttonRaised: '0 2px 8px rgba(30,58,138,0.08), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(30,58,138,0.10)',
|
||||
screenGlow: 'inset 0 0 24px rgba(30,58,138,0.05)',
|
||||
tooltip: '0 8px 24px rgba(30,58,138,0.10)',
|
||||
},
|
||||
accent: { main: '#2563eb', dim: 'rgba(37,99,235,0.10)', glow: 'rgba(37,99,235,0.35)', light: '#3b82f6', dark: '#1d4ed8', crtPhosphor: '#2563eb', crtPhosphorDim: '#6f96e8' },
|
||||
glass: {
|
||||
surface: 'rgba(255,255,255,0.72)',
|
||||
raised: 'rgba(255,255,255,0.85)',
|
||||
hairline: 'rgba(30,58,138,0.10)',
|
||||
hairlineStrong: 'rgba(30,58,138,0.18)',
|
||||
sheen: 'linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%)',
|
||||
logo: 'linear-gradient(90deg, #2563eb 0%, #6366f1 100%)',
|
||||
bar: 'linear-gradient(90deg, #06b6d4 0%, #2563eb 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #7c3aed 0%, #c026d3 100%)',
|
||||
wave1: '#06b6d4', wave2: '#2563eb', wave3: '#7c3aed', wave4: '#d946ef',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(37,99,235,0.25), 0 8px 24px rgba(37,99,235,0.25)',
|
||||
soft: '0 0 16px rgba(37,99,235,0.18)',
|
||||
},
|
||||
accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' },
|
||||
},
|
||||
// ── Nord (https://www.nordtheme.com/) ──────────────────────
|
||||
nord: {
|
||||
bg: { app: '#2e3440', card: '#3b4252', cardHover: '#434c5e', elevated: '#3b4252', input: '#3b4252', sidebar: '#2e3440', inset: '#2e3440', chassis: '#3b4252', crtBezel: '#2e3440', crtGlass: '#242831' },
|
||||
text: { primary: '#eceff4', secondary: '#d8dee9', label: '#adb5c7', disabled: '#4c566a', engraving: '#2e3440', inactive: '#7b88a1', dimLabel: '#5e81ac', muted: '#434c5e', hover: '#e5e9f0' },
|
||||
bg: { app: '#242933', card: '#2e3440', cardHover: '#343b49', elevated: '#3b4252', input: '#2a303c', sidebar: '#272c37', inset: '#232830', chassis: '#2e3440', crtBezel: '#272c37', crtGlass: '#1f242d' },
|
||||
text: { primary: '#eceff4', secondary: '#aeb8cc', label: '#8b96ad', disabled: '#4c566a', engraving: '#242933', inactive: '#7b88a1', dimLabel: '#5e81ac', muted: '#434c5e', hover: '#e5e9f0' },
|
||||
border: { subtle: 'rgba(216,222,233,0.06)', default: 'rgba(216,222,233,0.10)', strong: 'rgba(216,222,233,0.16)' },
|
||||
shadow: {
|
||||
card: '0 8px 30px rgba(0,0,0,0.35)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #242831, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.5), 0 1px 1px rgba(255,255,255,0.04)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.8), inset 0 0 0 1px #242831, 0 1px 1px rgba(255,255,255,0.08)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.35), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.7), inset 0 0 0 1px #242831',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.6)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
card: '0 12px 40px rgba(10,13,18,0.45)',
|
||||
buttonBase: '0 1px 2px rgba(10,13,18,0.4)',
|
||||
chassis: '0 24px 80px rgba(10,13,18,0.55)',
|
||||
inset: 'inset 0 1px 4px rgba(10,13,18,0.45)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(10,13,18,0.65)',
|
||||
buttonRaised: '0 2px 8px rgba(10,13,18,0.3), inset 0 1px 0 rgba(216,222,233,0.06)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(10,13,18,0.55)',
|
||||
screenGlow: 'inset 0 0 24px rgba(10,13,18,0.5)',
|
||||
tooltip: '0 8px 24px rgba(10,13,18,0.45)',
|
||||
},
|
||||
accent: { main: '#88c0d0', dim: 'rgba(136,192,208,0.14)', glow: 'rgba(136,192,208,0.5)', light: '#a3d0de', dark: '#6aacbe', crtPhosphor: '#88c0d0', crtPhosphorDim: '#5e9daf' },
|
||||
glass: {
|
||||
surface: 'rgba(46,52,64,0.62)',
|
||||
raised: 'rgba(59,66,82,0.55)',
|
||||
hairline: 'rgba(216,222,233,0.09)',
|
||||
hairlineStrong: 'rgba(216,222,233,0.16)',
|
||||
sheen: 'linear-gradient(180deg, rgba(216,222,233,0.05) 0%, rgba(216,222,233,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #5e81ac 0%, #81a1c1 55%, #88c0d0 100%)',
|
||||
logo: 'linear-gradient(90deg, #88c0d0 0%, #81a1c1 100%)',
|
||||
bar: 'linear-gradient(90deg, #8fbcbb 0%, #5e81ac 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #b48ead 0%, #d08770 100%)',
|
||||
wave1: '#8fbcbb', wave2: '#88c0d0', wave3: '#81a1c1', wave4: '#b48ead',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(136,192,208,0.3), 0 8px 24px rgba(94,129,172,0.35)',
|
||||
soft: '0 0 16px rgba(136,192,208,0.22)',
|
||||
},
|
||||
// Nord Frost: #88c0d0 (차가운 시안)
|
||||
accent: { main: '#88c0d0', dim: 'rgba(136,192,208,0.15)', glow: 'rgba(136,192,208,0.6)', light: '#a3d0de', dark: '#6aacbe', crtPhosphor: '#88c0d0', crtPhosphorDim: '#5e9daf' },
|
||||
},
|
||||
// ── Solarized Dark (https://ethanschoonover.com/solarized/) ─
|
||||
solarized: {
|
||||
bg: { app: '#002b36', card: '#073642', cardHover: '#0d4250', elevated: '#073642', input: '#073642', sidebar: '#002b36', inset: '#001f28', chassis: '#073642', crtBezel: '#002b36', crtGlass: '#001f28' },
|
||||
text: { primary: '#eee8d5', secondary: '#93a1a1', label: '#839496', disabled: '#586e75', engraving: '#002b36', inactive: '#657b83', dimLabel: '#7a6c2e', muted: '#073642', hover: '#b2c0bf' },
|
||||
bg: { app: '#00212b', card: '#073642', cardHover: '#0a3e4c', elevated: '#0d4250', input: '#03303c', sidebar: '#012731', inset: '#001f28', chassis: '#073642', crtBezel: '#012731', crtGlass: '#001b22' },
|
||||
text: { primary: '#eee8d5', secondary: '#93a1a1', label: '#839496', disabled: '#586e75', engraving: '#00212b', inactive: '#657b83', dimLabel: '#7a6c2e', muted: '#073642', hover: '#b2c0bf' },
|
||||
border: { subtle: 'rgba(131,148,150,0.08)', default: 'rgba(131,148,150,0.13)', strong: 'rgba(131,148,150,0.20)' },
|
||||
shadow: {
|
||||
card: '0 8px 30px rgba(0,0,0,0.4)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #001f28, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #001f28, 0 1px 1px rgba(255,255,255,0.06)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #001f28',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
card: '0 12px 40px rgba(0,10,13,0.5)',
|
||||
buttonBase: '0 1px 2px rgba(0,10,13,0.45)',
|
||||
chassis: '0 24px 80px rgba(0,10,13,0.6)',
|
||||
inset: 'inset 0 1px 4px rgba(0,10,13,0.5)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(0,10,13,0.7)',
|
||||
buttonRaised: '0 2px 8px rgba(0,10,13,0.35), inset 0 1px 0 rgba(238,232,213,0.05)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,10,13,0.6)',
|
||||
screenGlow: 'inset 0 0 24px rgba(0,10,13,0.55)',
|
||||
tooltip: '0 8px 24px rgba(0,10,13,0.5)',
|
||||
},
|
||||
accent: { main: '#b58900', dim: 'rgba(181,137,0,0.14)', glow: 'rgba(181,137,0,0.5)', light: '#d4a017', dark: '#8a6800', crtPhosphor: '#b58900', crtPhosphorDim: '#8a6800' },
|
||||
glass: {
|
||||
surface: 'rgba(7,54,66,0.62)',
|
||||
raised: 'rgba(13,66,80,0.55)',
|
||||
hairline: 'rgba(131,148,150,0.11)',
|
||||
hairlineStrong: 'rgba(131,148,150,0.19)',
|
||||
sheen: 'linear-gradient(180deg, rgba(238,232,213,0.04) 0%, rgba(238,232,213,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #8a6800 0%, #b58900 55%, #d4a017 100%)',
|
||||
logo: 'linear-gradient(90deg, #d4a017 0%, #cb4b16 100%)',
|
||||
bar: 'linear-gradient(90deg, #2aa198 0%, #268bd2 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #6c71c4 0%, #d33682 100%)',
|
||||
wave1: '#2aa198', wave2: '#268bd2', wave3: '#6c71c4', wave4: '#d33682',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(181,137,0,0.3), 0 8px 24px rgba(181,137,0,0.3)',
|
||||
soft: '0 0 16px rgba(181,137,0,0.22)',
|
||||
},
|
||||
// Solarized yellow: #b58900 (따뜻한 골드)
|
||||
accent: { main: '#b58900', dim: 'rgba(181,137,0,0.15)', glow: 'rgba(181,137,0,0.6)', light: '#d4a017', dark: '#8a6800', crtPhosphor: '#b58900', crtPhosphorDim: '#8a6800' },
|
||||
},
|
||||
// ── Catppuccin Mocha (https://catppuccin.com/) ──────────────
|
||||
catppuccin: {
|
||||
bg: { app: '#1e1e2e', card: '#313244', cardHover: '#3a3a54', elevated: '#313244', input: '#181825', sidebar: '#181825', inset: '#11111b', chassis: '#313244', crtBezel: '#1e1e2e', crtGlass: '#11111b' },
|
||||
text: { primary: '#cdd6f4', secondary: '#bac2de', label: '#a6adc8', disabled: '#585b70', engraving: '#11111b', inactive: '#7f849c', dimLabel: '#585b70', muted: '#313244', hover: '#e6e9f8' },
|
||||
border: { subtle: 'rgba(205,214,244,0.04)', default: 'rgba(205,214,244,0.08)', strong: 'rgba(205,214,244,0.14)' },
|
||||
bg: { app: '#181825', card: '#1e1e2e', cardHover: '#242438', elevated: '#313244', input: '#191926', sidebar: '#161622', inset: '#11111b', chassis: '#1e1e2e', crtBezel: '#181825', crtGlass: '#0f0f18' },
|
||||
text: { primary: '#cdd6f4', secondary: '#a8b2d8', label: '#8a94bc', disabled: '#585b70', engraving: '#11111b', inactive: '#7f849c', dimLabel: '#585b70', muted: '#313244', hover: '#e6e9f8' },
|
||||
border: { subtle: 'rgba(205,214,244,0.05)', default: 'rgba(205,214,244,0.09)', strong: 'rgba(205,214,244,0.15)' },
|
||||
shadow: {
|
||||
card: '0 8px 30px rgba(0,0,0,0.4)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #11111b, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #11111b, 0 1px 1px rgba(255,255,255,0.06)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #11111b',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
card: '0 12px 40px rgba(5,5,12,0.5)',
|
||||
buttonBase: '0 1px 2px rgba(5,5,12,0.45)',
|
||||
chassis: '0 24px 80px rgba(5,5,12,0.6)',
|
||||
inset: 'inset 0 1px 4px rgba(5,5,12,0.5)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(5,5,12,0.7)',
|
||||
buttonRaised: '0 2px 8px rgba(5,5,12,0.35), inset 0 1px 0 rgba(205,214,244,0.05)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(5,5,12,0.6)',
|
||||
screenGlow: 'inset 0 0 24px rgba(5,5,12,0.55)',
|
||||
tooltip: '0 8px 24px rgba(5,5,12,0.5)',
|
||||
},
|
||||
accent: { main: '#cba6f7', dim: 'rgba(203,166,247,0.14)', glow: 'rgba(203,166,247,0.5)', light: '#dfc0ff', dark: '#a67fd4', crtPhosphor: '#cba6f7', crtPhosphorDim: '#a67fd4' },
|
||||
glass: {
|
||||
surface: 'rgba(30,30,46,0.62)',
|
||||
raised: 'rgba(49,50,68,0.55)',
|
||||
hairline: 'rgba(205,214,244,0.08)',
|
||||
hairlineStrong: 'rgba(205,214,244,0.15)',
|
||||
sheen: 'linear-gradient(180deg, rgba(205,214,244,0.05) 0%, rgba(205,214,244,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #a67fd4 0%, #cba6f7 55%, #f5c2e7 100%)',
|
||||
logo: 'linear-gradient(90deg, #89b4fa 0%, #cba6f7 100%)',
|
||||
bar: 'linear-gradient(90deg, #89dceb 0%, #89b4fa 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #cba6f7 0%, #f5c2e7 100%)',
|
||||
wave1: '#89dceb', wave2: '#89b4fa', wave3: '#cba6f7', wave4: '#f5c2e7',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(203,166,247,0.3), 0 8px 24px rgba(203,166,247,0.3)',
|
||||
soft: '0 0 16px rgba(203,166,247,0.22)',
|
||||
},
|
||||
// Catppuccin Mauve: #cba6f7 (보라 파스텔)
|
||||
accent: { main: '#cba6f7', dim: 'rgba(203,166,247,0.15)', glow: 'rgba(203,166,247,0.6)', light: '#dfc0ff', dark: '#a67fd4', crtPhosphor: '#cba6f7', crtPhosphorDim: '#a67fd4' },
|
||||
},
|
||||
// ── Dracula (https://draculatheme.com/) ─────────────────────
|
||||
dracula: {
|
||||
bg: { app: '#282a36', card: '#44475a', cardHover: '#4f5266', elevated: '#383a4a', input: '#383a4a', sidebar: '#21222c', inset: '#21222c', chassis: '#44475a', crtBezel: '#282a36', crtGlass: '#1e2029' },
|
||||
text: { primary: '#f8f8f2', secondary: '#a9b0d0', label: '#8891b5', disabled: '#6272a4', engraving: '#21222c', inactive: '#6272a4', dimLabel: '#6272a4', muted: '#44475a', hover: '#ffffff' },
|
||||
border: { subtle: 'rgba(248,248,242,0.04)', default: 'rgba(248,248,242,0.08)', strong: 'rgba(248,248,242,0.14)' },
|
||||
bg: { app: '#1e2029', card: '#282a36', cardHover: '#2e3040', elevated: '#383a4a', input: '#232530', sidebar: '#21222c', inset: '#1b1c25', chassis: '#282a36', crtBezel: '#21222c', crtGlass: '#181a21' },
|
||||
text: { primary: '#f8f8f2', secondary: '#b6bdd9', label: '#8891b5', disabled: '#6272a4', engraving: '#21222c', inactive: '#6272a4', dimLabel: '#6272a4', muted: '#44475a', hover: '#ffffff' },
|
||||
border: { subtle: 'rgba(248,248,242,0.05)', default: 'rgba(248,248,242,0.09)', strong: 'rgba(248,248,242,0.15)' },
|
||||
shadow: {
|
||||
card: '0 8px 30px rgba(0,0,0,0.4)',
|
||||
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #1e2029, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
|
||||
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #1e2029, 0 1px 1px rgba(255,255,255,0.08)',
|
||||
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #1e2029',
|
||||
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
|
||||
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
card: '0 12px 40px rgba(8,9,13,0.5)',
|
||||
buttonBase: '0 1px 2px rgba(8,9,13,0.45)',
|
||||
chassis: '0 24px 80px rgba(8,9,13,0.6)',
|
||||
inset: 'inset 0 1px 4px rgba(8,9,13,0.5)',
|
||||
insetDeep: 'inset 0 2px 8px rgba(8,9,13,0.7)',
|
||||
buttonRaised: '0 2px 8px rgba(8,9,13,0.35), inset 0 1px 0 rgba(248,248,242,0.06)',
|
||||
buttonPressed: 'inset 0 2px 6px rgba(8,9,13,0.6)',
|
||||
screenGlow: 'inset 0 0 24px rgba(8,9,13,0.55)',
|
||||
tooltip: '0 8px 24px rgba(8,9,13,0.5)',
|
||||
},
|
||||
accent: { main: '#bd93f9', dim: 'rgba(189,147,249,0.14)', glow: 'rgba(189,147,249,0.5)', light: '#d4b8ff', dark: '#9a70e0', crtPhosphor: '#bd93f9', crtPhosphorDim: '#9a70e0' },
|
||||
glass: {
|
||||
surface: 'rgba(40,42,54,0.62)',
|
||||
raised: 'rgba(56,58,74,0.55)',
|
||||
hairline: 'rgba(248,248,242,0.08)',
|
||||
hairlineStrong: 'rgba(248,248,242,0.15)',
|
||||
sheen: 'linear-gradient(180deg, rgba(248,248,242,0.05) 0%, rgba(248,248,242,0) 42%)',
|
||||
blur: '24px',
|
||||
},
|
||||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #9a70e0 0%, #bd93f9 55%, #ff79c6 100%)',
|
||||
logo: 'linear-gradient(90deg, #8be9fd 0%, #bd93f9 100%)',
|
||||
bar: 'linear-gradient(90deg, #8be9fd 0%, #bd93f9 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #ff79c6 0%, #ffb86c 100%)',
|
||||
wave1: '#8be9fd', wave2: '#bd93f9', wave3: '#ff79c6', wave4: '#ffb86c',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(189,147,249,0.3), 0 8px 24px rgba(189,147,249,0.3)',
|
||||
soft: '0 0 16px rgba(189,147,249,0.22)',
|
||||
},
|
||||
// Dracula Purple: #bd93f9 (시그니처 퍼플)
|
||||
accent: { main: '#bd93f9', dim: 'rgba(189,147,249,0.15)', glow: 'rgba(189,147,249,0.6)', light: '#d4b8ff', dark: '#9a70e0', crtPhosphor: '#bd93f9', crtPhosphorDim: '#9a70e0' },
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -191,24 +346,58 @@ export const d3roPalette = {
|
|||
crtGlass: 'var(--d3-bg-crtGlass)',
|
||||
},
|
||||
accent: {
|
||||
/** 프라이머리 액센트 (v2 별칭 — amber와 동일 var) */
|
||||
main: 'var(--d3-accent-main)',
|
||||
light: 'var(--d3-accent-light)',
|
||||
dark: 'var(--d3-accent-dark)',
|
||||
dim: 'var(--d3-accent-dim)',
|
||||
/** @deprecated v2에서 main으로 개명 — 기존 사용처 호환용 별칭 */
|
||||
amber: 'var(--d3-accent-main)',
|
||||
amberDim: 'var(--d3-accent-dim)',
|
||||
amberGlow: 'var(--d3-accent-glow)',
|
||||
glow: 'var(--d3-accent-glow)',
|
||||
},
|
||||
/** v2: 글래스 표면 토큰 */
|
||||
glass: {
|
||||
surface: 'var(--d3-glass-surface)',
|
||||
raised: 'var(--d3-glass-raised)',
|
||||
hairline: 'var(--d3-glass-hairline)',
|
||||
hairlineStrong: 'var(--d3-glass-hairlineStrong)',
|
||||
sheen: 'var(--d3-glass-sheen)',
|
||||
blur: 'var(--d3-glass-blur)',
|
||||
},
|
||||
/** v2: 그라디언트 토큰 */
|
||||
gradient: {
|
||||
accent: 'var(--d3-gradient-accent)',
|
||||
logo: 'var(--d3-gradient-logo)',
|
||||
bar: 'var(--d3-gradient-bar)',
|
||||
barAlt: 'var(--d3-gradient-barAlt)',
|
||||
wave1: 'var(--d3-gradient-wave1)',
|
||||
wave2: 'var(--d3-gradient-wave2)',
|
||||
wave3: 'var(--d3-gradient-wave3)',
|
||||
wave4: 'var(--d3-gradient-wave4)',
|
||||
},
|
||||
/** v2: 글로우 토큰 */
|
||||
glow: {
|
||||
accent: 'var(--d3-glow-accent)',
|
||||
soft: 'var(--d3-glow-soft)',
|
||||
},
|
||||
tag: {
|
||||
purple: '#b854f5',
|
||||
purpleBg: 'rgba(184, 84, 245, 0.12)',
|
||||
orange: '#f59e0b',
|
||||
orangeBg: 'rgba(245, 158, 11, 0.12)',
|
||||
red: '#ef4444',
|
||||
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||
green: '#22c55e',
|
||||
greenBg: 'rgba(34, 197, 94, 0.12)',
|
||||
blue: '#3b82f6',
|
||||
blueBg: 'rgba(59, 130, 246, 0.12)',
|
||||
greenGlow: 'rgba(34, 197, 94, 0.6)',
|
||||
redGlow: 'rgba(239, 68, 68, 0.6)',
|
||||
orangeGlow: 'rgba(245, 158, 11, 0.6)',
|
||||
purple: '#a78bfa',
|
||||
purpleBg: 'rgba(167, 139, 250, 0.12)',
|
||||
orange: '#fb923c',
|
||||
orangeBg: 'rgba(251, 146, 60, 0.12)',
|
||||
red: '#f87171',
|
||||
redBg: 'rgba(248, 113, 113, 0.12)',
|
||||
green: '#34d399',
|
||||
greenBg: 'rgba(52, 211, 153, 0.12)',
|
||||
blue: '#60a5fa',
|
||||
blueBg: 'rgba(96, 165, 250, 0.12)',
|
||||
greenGlow: 'rgba(52, 211, 153, 0.55)',
|
||||
redGlow: 'rgba(248, 113, 113, 0.55)',
|
||||
orangeGlow: 'rgba(251, 146, 60, 0.55)',
|
||||
purpleGlow: 'rgba(167, 139, 250, 0.55)',
|
||||
blueGlow: 'rgba(96, 165, 250, 0.55)',
|
||||
},
|
||||
text: {
|
||||
primary: 'var(--d3-text-primary)',
|
||||
|
|
@ -233,13 +422,13 @@ export const d3roPalette = {
|
|||
bg: 'var(--d3-bg-chassis)',
|
||||
},
|
||||
led: {
|
||||
off: '#111111',
|
||||
off: '#1c2336',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const d3roFontSans = [
|
||||
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
|
||||
'"Helvetica Neue"', 'Arial', 'sans-serif',
|
||||
'"Pretendard Variable"', 'Pretendard', '-apple-system', 'BlinkMacSystemFont',
|
||||
'"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif',
|
||||
].join(',')
|
||||
|
||||
export const d3roFontMono = [
|
||||
|
|
@ -248,19 +437,20 @@ export const d3roFontMono = [
|
|||
].join(',')
|
||||
|
||||
// ── SSOT: 타이포그래피 토큰 ─────────────────────────────
|
||||
// v2: 클린 산세리프(Pretendard) 기반, 굵은 헤드라인 + 큰 수치
|
||||
export const d3roTypo = {
|
||||
hero: { size: '42px', weight: 300, spacing: '-2px', line: 1 },
|
||||
title: { size: '28px', weight: 300, spacing: '-1px', line: 1.2 },
|
||||
value: { size: '20px', weight: 400, spacing: '0.02em', line: 1 },
|
||||
heading: { size: '16px', weight: 600, spacing: '0.02em', line: 1.4 },
|
||||
body: { size: '14px', weight: 400, spacing: '0.01em', line: 1.5 },
|
||||
compact: { size: '13px', weight: 400, spacing: '0.01em', line: 1.5 },
|
||||
small: { size: '12px', weight: 600, spacing: '0.03em', line: 1.4 },
|
||||
meta: { size: '11px', weight: 600, spacing: '0.05em', line: 1.3 },
|
||||
label: { size: '10px', weight: 700, spacing: '2px', line: 1.2 },
|
||||
engrave: { size: '9px', weight: 700, spacing: '1.5px', line: 1 },
|
||||
micro: { size: '8px', weight: 700, spacing: '1.5px', line: 1 },
|
||||
nano: { size: '7px', weight: 700, spacing: '0.5px', line: 1 },
|
||||
hero: { size: '32px', weight: 700, spacing: '-0.5px', line: 1.2 },
|
||||
title: { size: '24px', weight: 700, spacing: '-0.3px', line: 1.25 },
|
||||
value: { size: '20px', weight: 600, spacing: '0', line: 1.1 },
|
||||
heading: { size: '16px', weight: 600, spacing: '0', line: 1.4 },
|
||||
body: { size: '14px', weight: 400, spacing: '0', line: 1.55 },
|
||||
compact: { size: '13px', weight: 400, spacing: '0', line: 1.5 },
|
||||
small: { size: '12px', weight: 500, spacing: '0.01em', line: 1.4 },
|
||||
meta: { size: '11px', weight: 600, spacing: '0.04em', line: 1.3 },
|
||||
label: { size: '11px', weight: 600, spacing: '0.08em', line: 1.2 },
|
||||
engrave: { size: '9px', weight: 700, spacing: '1px', line: 1 },
|
||||
micro: { size: '9px', weight: 700, spacing: '1px', line: 1 },
|
||||
nano: { size: '8px', weight: 700, spacing: '0.5px', line: 1 },
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
|
@ -294,16 +484,19 @@ export const d3roShadow = {
|
|||
dialog: 'var(--d3-shadow-card)',
|
||||
tooltip: 'var(--d3-shadow-tooltip)',
|
||||
screenGlow: 'var(--d3-shadow-screenGlow)',
|
||||
/** v2: 액센트 글로우 */
|
||||
glowAccent: 'var(--d3-glow-accent)',
|
||||
glowSoft: 'var(--d3-glow-soft)',
|
||||
} as const
|
||||
|
||||
// ── SSOT: 반경 토큰 ────────────────────────────────────
|
||||
export const d3roRadius = {
|
||||
outer: '24px',
|
||||
card: '22px',
|
||||
outer: '20px',
|
||||
card: '16px',
|
||||
inner: '12px',
|
||||
button: '8px',
|
||||
small: '6px',
|
||||
xs: '4px',
|
||||
button: '10px',
|
||||
small: '8px',
|
||||
xs: '6px',
|
||||
pill: '999px',
|
||||
} as const
|
||||
|
||||
|
|
@ -349,6 +542,22 @@ function buildCssVars(key: ThemeKey): Record<string, string> {
|
|||
'--d3-accent-dark': r.accent.dark,
|
||||
'--d3-accent-crtPhosphor': r.accent.crtPhosphor,
|
||||
'--d3-accent-crtPhosphorDim': r.accent.crtPhosphorDim,
|
||||
'--d3-glass-surface': r.glass.surface,
|
||||
'--d3-glass-raised': r.glass.raised,
|
||||
'--d3-glass-hairline': r.glass.hairline,
|
||||
'--d3-glass-hairlineStrong': r.glass.hairlineStrong,
|
||||
'--d3-glass-sheen': r.glass.sheen,
|
||||
'--d3-glass-blur': r.glass.blur,
|
||||
'--d3-gradient-accent': r.gradient.accent,
|
||||
'--d3-gradient-logo': r.gradient.logo,
|
||||
'--d3-gradient-bar': r.gradient.bar,
|
||||
'--d3-gradient-barAlt': r.gradient.barAlt,
|
||||
'--d3-gradient-wave1': r.gradient.wave1,
|
||||
'--d3-gradient-wave2': r.gradient.wave2,
|
||||
'--d3-gradient-wave3': r.gradient.wave3,
|
||||
'--d3-gradient-wave4': r.gradient.wave4,
|
||||
'--d3-glow-accent': r.glow.accent,
|
||||
'--d3-glow-soft': r.glow.soft,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -369,7 +578,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
palette: {
|
||||
mode: muiMode,
|
||||
primary: { main: accentMain, light: accentLight, dark: accentDark, contrastText: '#fff' },
|
||||
secondary: { main: d3roPalette.tag.purple, light: '#d084ff', dark: '#8a3cc4' },
|
||||
secondary: { main: d3roPalette.tag.purple, light: '#c4b5fd', dark: '#8b5cf6' },
|
||||
error: { main: d3roPalette.tag.red },
|
||||
warning: { main: d3roPalette.tag.orange },
|
||||
success: { main: d3roPalette.tag.green },
|
||||
|
|
@ -386,15 +595,18 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
body1: { fontSize: '14px', lineHeight: 1.5 },
|
||||
body2: { fontSize: '12px', lineHeight: 1.4 },
|
||||
button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '14px' },
|
||||
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: r.text.label },
|
||||
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
|
||||
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.06em', color: r.text.label },
|
||||
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
|
||||
},
|
||||
shape: { borderRadius: 22 },
|
||||
shape: { borderRadius: 16 },
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
':root': cssVars,
|
||||
body: { backgroundColor: r.bg.app, color: r.text.primary },
|
||||
// 보더리스 UI: 네이티브 스크롤바 제거 (레이아웃 폭 불변).
|
||||
// 시각적 스크롤 인디케이터는 OverlayScrollbars(고유 스타일)가 담당.
|
||||
'*::-webkit-scrollbar': { width: 0, height: 0, background: 'transparent' },
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
|
|
@ -402,23 +614,27 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none', fontWeight: 600, borderRadius: 10, padding: '10px 20px',
|
||||
transition: 'transform 0.05s linear, box-shadow 0.05s linear',
|
||||
boxShadow: r.shadow.buttonBase,
|
||||
'&:active': { transform: 'translateY(2px)', boxShadow: 'var(--d3-shadow-buttonPressed)' },
|
||||
transition: 'filter 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease',
|
||||
},
|
||||
containedPrimary: {
|
||||
color: '#fff',
|
||||
'&:hover': { backgroundColor: accentDark },
|
||||
backgroundImage: 'var(--d3-gradient-accent)',
|
||||
boxShadow: 'var(--d3-glow-accent)',
|
||||
'&:hover': { filter: 'brightness(1.1)', boxShadow: 'var(--d3-glow-accent)' },
|
||||
'&:active': { filter: 'brightness(0.95)' },
|
||||
'&.Mui-disabled': { backgroundImage: 'none' },
|
||||
},
|
||||
outlined: {
|
||||
borderColor: r.border.strong,
|
||||
borderColor: 'var(--d3-glass-hairlineStrong)',
|
||||
backgroundColor: 'var(--d3-glass-raised)',
|
||||
color: r.text.primary,
|
||||
'&:hover': { borderColor: accentMain, color: accentMain },
|
||||
'&:hover': { borderColor: accentMain, color: accentLight, backgroundColor: 'var(--d3-glass-raised)' },
|
||||
},
|
||||
containedSecondary: {
|
||||
backgroundColor: r.bg.elevated,
|
||||
color: r.text.primary,
|
||||
'&:hover': { backgroundColor: muiMode === 'dark' ? '#353539' : '#e5e5e7' },
|
||||
border: `1px solid ${r.glass.hairline}`,
|
||||
'&:hover': { backgroundColor: r.bg.cardHover },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -426,42 +642,54 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
defaultProps: { elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: r.bg.card, borderRadius: 22,
|
||||
borderTop: `1px solid ${r.border.subtle}`,
|
||||
backgroundColor: 'var(--d3-glass-surface)',
|
||||
backdropFilter: 'blur(var(--d3-glass-blur))',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${r.glass.hairline}`,
|
||||
boxShadow: r.shadow.card,
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': { backgroundColor: r.bg.cardHover },
|
||||
transition: 'border-color 0.2s ease',
|
||||
'&:hover': { borderColor: r.glass.hairlineStrong },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999, fontWeight: 700, fontSize: '11px', letterSpacing: '0.1em', textTransform: 'uppercase', height: 24 },
|
||||
colorPrimary: { backgroundColor: accentDim, color: accentMain },
|
||||
root: { borderRadius: 999, fontWeight: 600, fontSize: '11px', letterSpacing: '0.04em', height: 24 },
|
||||
colorPrimary: { backgroundColor: accentDim, color: accentLight },
|
||||
colorSecondary: { backgroundColor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple },
|
||||
colorSuccess: { backgroundColor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green },
|
||||
colorError: { backgroundColor: d3roPalette.tag.redBg, color: d3roPalette.tag.red },
|
||||
colorWarning: { backgroundColor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange },
|
||||
},
|
||||
},
|
||||
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: r.bg.app, borderRight: `1px solid ${r.border.subtle}` } } },
|
||||
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: r.bg.sidebar, borderRight: `1px solid ${r.glass.hairline}` } } },
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 10, marginLeft: 8, marginRight: 8,
|
||||
'&.Mui-selected': { backgroundColor: accentDim, color: accentMain, fontWeight: 600, '&:hover': { backgroundColor: accentDim } },
|
||||
borderRadius: 12, marginLeft: 8, marginRight: 8,
|
||||
'&.Mui-selected': { backgroundColor: accentDim, color: accentLight, fontWeight: 600, '&:hover': { backgroundColor: accentDim } },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: r.bg.card, borderRadius: 22,
|
||||
border: `1px solid ${r.border.subtle}`,
|
||||
backgroundColor: r.bg.card, borderRadius: 20,
|
||||
backgroundImage: 'var(--d3-glass-sheen)',
|
||||
border: `1px solid ${r.glass.hairline}`,
|
||||
boxShadow: 'var(--d3-shadow-card)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiBackdrop: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: muiMode === 'dark' ? 'rgba(4,8,20,0.6)' : 'rgba(30,58,138,0.18)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
'&.MuiBackdrop-invisible': { backgroundColor: 'transparent', backdropFilter: 'none' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: { size: 'small', variant: 'outlined' },
|
||||
styleOverrides: {
|
||||
|
|
@ -469,8 +697,8 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: r.bg.input, borderRadius: 10,
|
||||
color: r.text.primary,
|
||||
'& fieldset': { borderColor: r.border.default },
|
||||
'&:hover fieldset': { borderColor: r.border.strong },
|
||||
'& fieldset': { borderColor: r.glass.hairline },
|
||||
'&:hover fieldset': { borderColor: r.glass.hairlineStrong },
|
||||
'&.Mui-focused fieldset': { borderColor: accentMain },
|
||||
},
|
||||
},
|
||||
|
|
@ -512,11 +740,16 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
MuiTooltip: {
|
||||
defaultProps: { arrow: true },
|
||||
styleOverrides: {
|
||||
tooltip: { backgroundColor: r.bg.elevated, color: r.text.primary, fontSize: '12px', borderRadius: 8, border: `1px solid ${r.border.subtle}` },
|
||||
tooltip: { backgroundColor: r.bg.elevated, color: r.text.primary, fontSize: '12px', borderRadius: 8, border: `1px solid ${r.glass.hairline}` },
|
||||
},
|
||||
},
|
||||
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain, height: 2, borderRadius: 2 } } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentLight, fontWeight: 600 } } } },
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: { backgroundImage: 'none' },
|
||||
},
|
||||
},
|
||||
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain } } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentMain, fontWeight: 600 } } } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue