diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 04068e7..d03c0c7 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -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",
diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts
index 6dbde74..3d36472 100644
--- a/apps/desktop/src/main/windows/WindowManager.ts
+++ b/apps/desktop/src/main/windows/WindowManager.ts
@@ -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,
diff --git a/apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2 b/apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2
new file mode 100644
index 0000000..49c54b5
Binary files /dev/null and b/apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2 differ
diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx
index ba8f29f..c045faa 100644
--- a/apps/desktop/src/renderer/components/AppLayout.tsx
+++ b/apps/desktop/src/renderer/components/AppLayout.tsx
@@ -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: },
- { route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: },
- { route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: },
- { route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: },
- { route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: },
- { route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: },
- { route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: },
+ { route: 'dashboard', labelKey: 'nav.dashboard', icon: },
+ { route: 'history', labelKey: 'nav.history', icon: },
+ { route: 'dictionary', labelKey: 'nav.dictionary', icon: },
+ { route: 'commands', labelKey: 'nav.commands', icon: },
+ { route: 'conversation', labelKey: 'nav.conversation', icon: },
+ { route: 'knowledge', labelKey: 'nav.knowledge', icon: },
+ { route: 'meeting', labelKey: 'nav.meeting', icon: },
]
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 (
+
- {/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
+ {/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
- {/* 로고 LED — reflects license tier */}
-
-
+ {/* 로고 — 그라디언트 브랜드 텍스트 */}
+
+
+
+
+ D3RO
+
+
- D3RO
+ Voice
- {/* 네비게이션 버튼 */}
+ {/* 네비게이션 */}
{NAV_ITEMS.map((item) => {
const isActive = currentRoute === item.route
return (
-
- setCurrentRoute(item.route)}
+ 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}
+
- {item.icon}
-
- {item.abbr}
-
-
-
+ {t(item.labelKey)}
+
+
)
})}
@@ -194,50 +224,64 @@ export function AppLayout(): React.ReactElement {
{/* Settings */}
-
- 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 },
- }}
- >
-
-
-
+ 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 },
+ }}
+ >
+
+
+ {t('nav.settings')}
+
+
- {/* ── 콘텐츠 영역 ────────────────────────── */}
+ {/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
- {currentRoute === 'dashboard' && }
- {currentRoute === 'history' && }
- {currentRoute === 'dictionary' && }
- {currentRoute === 'commands' && }
- {currentRoute === 'conversation' && }
- {currentRoute === 'knowledge' && }
- {currentRoute === 'meeting' && }
+
+ {currentRoute === 'dashboard' && }
+ {currentRoute === 'history' && }
+ {currentRoute === 'dictionary' && }
+ {currentRoute === 'commands' && }
+ {currentRoute === 'conversation' && }
+ {currentRoute === 'knowledge' && }
+ {currentRoute === 'meeting' && }
+
diff --git a/apps/desktop/src/renderer/components/StatusBar.tsx b/apps/desktop/src/renderer/components/StatusBar.tsx
index 1d33995..4c703c3 100644
--- a/apps/desktop/src/renderer/components/StatusBar.tsx
+++ b/apps/desktop/src/renderer/components/StatusBar.tsx
@@ -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(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 {
setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 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 {
-
-
+ {/* 좌: 브랜드 + 버전 */}
+ !connected && setGuideOpen(true)}
+ >
+
!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
+ {version && (
+
+ v{version}
+
+ )}
{llmStatus?.activeModel && (
- {llmStatus.activeModel.toUpperCase()}
+ {llmStatus.activeModel}
)}
{/* 자막 상태 표시 */}
{captionActive && (
-
+
- CAPTION
+ {t('dashboard.caption')}
)}
-
- PRECISION DATA LINK
-
+ {/* 우: 로컬 시간 */}
+
+
+ {t('status.localTime')}
+
+
+ {clock}
+
+
setGuideOpen(false)} />
diff --git a/apps/desktop/src/renderer/components/TitleBar.tsx b/apps/desktop/src/renderer/components/TitleBar.tsx
new file mode 100644
index 0000000..73dd288
--- /dev/null
+++ b/apps/desktop/src/renderer/components/TitleBar.tsx
@@ -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 (
+ theme.zIndex.drawer + 2,
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'flex-start',
+ WebkitAppRegion: 'drag',
+ pointerEvents: 'auto',
+ }}
+ >
+
+ window.electronAPI.window.minimize()}>
+
+
+
+ {isMaximized ? : }
+
+ window.electronAPI.window.close()}>
+
+
+
+
+ )
+}
+
+function ControlButton({
+ children,
+ danger = false,
+ onClick,
+}: {
+ children: React.ReactNode
+ danger?: boolean
+ onClick: () => void
+}): React.ReactElement {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx
index 4417244..bcbc7cf 100644
--- a/apps/desktop/src/renderer/main.tsx
+++ b/apps/desktop/src/renderer/main.tsx
@@ -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')
diff --git a/apps/desktop/src/renderer/pages/DashboardPage.tsx b/apps/desktop/src/renderer/pages/DashboardPage.tsx
index 0665b2d..8ca9677 100644
--- a/apps/desktop/src/renderer/pages/DashboardPage.tsx
+++ b/apps/desktop/src/renderer/pages/DashboardPage.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
+function CardHeader({
+ title,
+ right,
+}: {
+ title: string
+ right?: React.ReactNode
+}): React.ReactElement {
+ return (
+
+ {title}
+ {right}
+
+ )
+}
+
+/** 그라디언트 프로그레스 바 */
+function GradientBar({
+ ratio,
+ alt = false,
+}: {
+ /** 0~1 (무제한이면 1) */
+ ratio: number
+ alt?: boolean
+}): React.ReactElement {
+ return (
+
+
+
+ )
+}
+
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
- const { t, formatTime, formatRelativeDate } = useI18n()
+ const { t, formatRelativeDate } = useI18n()
const [stats, setStats] = useState(null)
const [history, setHistory] = useState([])
const [ollamaConnected, setOllamaConnected] = useState(false)
+ const [llmModel, setLlmModel] = useState(null)
const [dictationBinding, setDictationBinding] = useState(null)
- const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
const [captionState, setCaptionState] = useState('inactive')
const [audioLevel, setAudioLevel] = useState(0)
const audioDecayRef = useRef | null>(null)
const [licenseTier, setLicenseTier] = useState('free')
const [usageQuotas, setUsageQuotas] = useState([])
const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null)
+ const statsRef = useRef(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: , label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
+ { ring: 'purple' as const, icon: , label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
+ { ring: 'green' as const, icon: , label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
+ { ring: 'orange' as const, icon: , label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
+ ]
+
return (
-
- {/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
-
-
- {/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
-
- {/* 상단 라벨 */}
-
-
- {activeView === 'stats'
- ? t('dashboard.sessionOverview').toUpperCase()
- : t('dashboard.systemStatus').toUpperCase()}
-
-
- {new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
-
-
-
- {/* 중앙: 큰 수치 or 서비스 상태 */}
-
- {activeView === 'stats' ? (
- <>
-
-
- {stats?.todaySessionCount ?? 0}
-
-
- {t('dashboard.sessionsToday').toUpperCase()}
-
-
-
- {dictationBinding
- ? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() }).toUpperCase()
- : t('dashboard.hotkeyNotSet').toUpperCase()}
-
- >
- ) : (
-
- {services.map((svc) => (
-
-
- {svc.name}
-
- {svc.status}
-
-
- ))}
-
- )}
-
-
- {/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
- {activeView === 'stats' && (
-
-
- {t('dashboard.words').toUpperCase()}
-
-
- {formatNumber(stats?.totalWordCount ?? 0)}
-
-
- {t('dashboard.total').toUpperCase()}
-
-
-
-
- {t('dashboard.streak').toUpperCase()}
-
-
- {stats?.streakDays ?? 0}
-
-
- {t('dashboard.days').toUpperCase()}
-
-
-
-
- )}
-
-
- {/* 우측: 컨트롤 패널 */}
-
- {/* LED 상태 클러스터 */}
-
-
-
-
-
- {/* 버튼 그룹 */}
-
- setActiveView('stats')}
- sx={{ minWidth: 0 }}
- >
- {t('dashboard.stat').toUpperCase()}
-
- setActiveView('status')}
- sx={{ minWidth: 0 }}
- >
- {t('dashboard.sys').toUpperCase()}
-
-
+
+ {/* ── 1. 히어로 카드 ─────────────────────────── */}
+
+ {/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
+
+
+
+
+ {t('dashboard.sessionsToday')}
+
+
+
+
+ {now.toLocaleDateString()}
+
+ {weekday}
+
+
+ {allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
+
-
- {/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
-
- {[
- { 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) => (
-
-
-
- {card.label}
-
-
-
- {card.value}
+
+ {/* 좌: 헤드라인 + 힌트 + 키 설정 */}
+
+
+ {t('dashboard.heroIdle')}
+
+
+ {dictationBinding
+ ? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
+ : t('dashboard.hotkeyNotSet')}
+
+
+
+ {t('dashboard.keySetup')}
+
+
+
+ {/* 중: 수치 2종 */}
+
+ {[
+ { label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
+ { label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
+ ].map((item) => (
+
+
+ {item.label}
-
- {card.unit}
+
+
+ {item.value}
+
+ {item.unit && (
+
+ {item.unit}
+
+ )}
+
+
+ ))}
+
+
+ {/* 우: CTA 버튼 스택 */}
+
+ }
+ onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
+ sx={{ py: 1.25 }}
+ >
+ {t('dashboard.viewStats')}
+
+
+
+ {t('dashboard.systemSettings')}
+
+
+
+
+ {/* 하단: 그라디언트 웨이브 */}
+
+
+
+
+
+ {/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
+
+ {statTiles.map((tile) => (
+
+
+
+ {tile.icon}
+
+
+
+ {tile.label}
+
+
+ {tile.value}
+
+
+ {tile.unit}
+
+
))}
- {/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */}
-
- {/* 현재 백엔드 카드 */}
-
-
-
- {t('dashboard.currentBackend').toUpperCase()}
-
-
-
-
- {premiumStatus?.backend === 'premium'
- ? t('dashboard.backendPremium').toUpperCase()
- : t('dashboard.backendLocal').toUpperCase()}
+ {/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
+
+ {/* 백엔드 카드 */}
+
+
+ {premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
+
+ }
+ />
+
+
+
+
+
+
+ {premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')}
+
+
+ {t('dashboard.modelLabel')}: {llmModel ?? '—'}
+
+
+
+ {t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
+
+
+
+
-
+
- {/* 티어 카드 */}
-
-
-
- {t('license.currentTier').toUpperCase()}
-
-
-
-
- {t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
-
+ {/* 요금제 카드 */}
+
+
+
+
+
+
+
+
+
+ {t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
+
+
+ {licenseTier === 'free' && {t('dashboard.planFree')}}
+
+
+ {[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
+
+
+
+ {feat}
+
+
+ ))}
- {/* 사용량 바 — 모든 티어에서 표시 */}
- {usageQuotas.length > 0 && (
-
-
-
-
- {t('license.usageToday').toUpperCase()}
-
- {usageQuotas.map((q) => (
-
-
-
- {t(`license.feature.${q.feature}`)}
-
-
- {q.limit === -1
- ? t('license.unlimited')
- : `${q.used}/${q.limit}`}
-
-
- {q.limit > 0 && (
-
- = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
- borderRadius: '2px',
- transition: 'width 0.3s ease',
- }} />
-
- )}
+ {/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
+
+ {/* 사용량 카드 */}
+
+
+
+ {usageQuotas.length === 0 && (
+ —
+ )}
+ {usageQuotas.map((q, idx) => (
+
+
+
+ {idx % 2 === 0 ? : }
+
+
+ {t(`license.feature.${q.feature}`)}
+
+
+ {q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
+
- ))}
+
+
+ ))}
+
+
- {/* Premium 모델별 쿼터 표시 */}
- {premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
- <>
-
- {t('license.premiumQuota').toUpperCase()}
-
- {PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
-
-
-
- {t(m.i18nKey)}
-
-
- {m.limit === -1
- ? t('license.unlimited')
- : `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
-
-
- {m.limit > 0 && (
-
-
-
- )}
-
- ))}
- >
- )}
-
-
-
- )}
-
- {/* ── 2.5. 실시간 자막 토글 ────────────────── */}
-
-
-
-
-
-
-
- {t('dashboard.caption').toUpperCase()}
+ {/* 실시간 자막 카드 */}
+
+
+
+
+ {captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
- {captionState === 'active' && (
-
- {t('dashboard.captionActive').toUpperCase()}
-
- )}
-
-
+
+
+ {t('dashboard.captionPlaceholder')}
+
+ }
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()}
-
+ {captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
+
- {/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
-
-
-
+ {/* ── 5. 시스템 상태 ──────────────────────────── */}
+
+
+
+ {/* 좌: 서비스 상태 리스트 */}
+
{services.map((svc) => (
-
- {svc.name}
+
+
+ {svc.name}
+
{svc.status}
))}
-
-
- {/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
-
-
- {t('fileTranscription.title').toUpperCase()}
-
+ {/* 중: 웨이브 + 레벨 미터 */}
+
+
+
+
+
+
+ {t('dashboard.inputLevel')}
+
+
+ {Array.from({ length: 24 }, (_, i) => (
+
+ ))}
+
+
+ {Math.round(audioLevel * 100)}%
+
+
+
+
+ {/* 우: 백엔드 요약 */}
+
+
+
+ {t('service.ollamaLlm')}
+
+
+ {ollamaConnected ? t('service.ready') : t('service.offline')}
+
+
+
+
+ {t('dashboard.modelLabel')}
+
+
+ {llmModel ?? '—'}
+
+
+
+
+ {t('dashboard.statusLabel')}
+
+
+ {allOk ? 'CONNECTED' : 'CHECK'}
+
+
+
+
+
+
+ {/* ── 6. 파일 전사 ────────────────────────────── */}
+
+
-
+
- {/* ── 4. 최근 히스토리 ──────────────────────── */}
+ {/* ── 7. 최근 히스토리 ──────────────────────── */}
-
- {t('dashboard.recentTranscriptions').toUpperCase()}
+
+ {t('dashboard.recentTranscriptions')}
{history.length === 0 ? (
@@ -464,10 +568,10 @@ export function DashboardPage(): React.ReactElement {
{/* 날짜 구분자 */}
-
+
{group.label}
-
+
{t('dashboard.entries', { count: group.entries.length })}
diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css
new file mode 100644
index 0000000..f107a59
--- /dev/null
+++ b/apps/desktop/src/renderer/styles/global.css
@@ -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;
+}
diff --git a/memory/project_status.md b/memory/project_status.md
index 22720ed..ac17d47 100644
--- a/memory/project_status.md
+++ b/memory/project_status.md
@@ -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 완료(코드)
### 방향 결정 (사용자 컨펌)
diff --git a/package-lock.json b/package-lock.json
index 76b4519..ed5736a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index 7d2f3b5..4cb2e62 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -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",
diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json
index 16a9b71..c3dce06 100644
--- a/packages/i18n/src/locales/ko.json
+++ b/packages/i18n/src/locales/ko.json
@@ -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가 실행되지 않고 있어요",
diff --git a/packages/ui/src/components/ds/ButtonGroup.tsx b/packages/ui/src/components/ds/ButtonGroup.tsx
index f14d388..613bea7 100644
--- a/packages/ui/src/components/ds/ButtonGroup.tsx
+++ b/packages/ui/src/components/ds/ButtonGroup.tsx
@@ -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',
diff --git a/packages/ui/src/components/ds/CrtDisplay.tsx b/packages/ui/src/components/ds/CrtDisplay.tsx
index 062646f..d453785 100644
--- a/packages/ui/src/components/ds/CrtDisplay.tsx
+++ b/packages/ui/src/components/ds/CrtDisplay.tsx
@@ -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(null)
- const glRef = useRef<{
- gl: WebGLRenderingContext
- uTime: WebGLUniformLocation | null
- uGlitch: WebGLUniformLocation | null
- uAmp: WebGLUniformLocation | null
- uFreq: WebGLUniformLocation | null
- } | null>(null)
- const animRef = useRef(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 (
- {/* Glass surface */}
+ {/* 앰비언트 배경 글로우 */}
-
+ />
- {/* Glass reflection */}
-
+ {/* 그라디언트 스펙트럼 웨이브 — 하단 영역 */}
+
+
- {/* Content overlay (phosphor text) */}
+ {/* 상단 시인 */}
+
+
+ {/* 플래시 오버레이 (글리치 호환) */}
+
+
+ {/* 콘텐츠 오버레이 */}
{children}
diff --git a/packages/ui/src/components/ds/GradientWave.tsx b/packages/ui/src/components/ds/GradientWave.tsx
new file mode 100644
index 0000000..9f7657c
--- /dev/null
+++ b/packages/ui/src/components/ds/GradientWave.tsx
@@ -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(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 (
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/ds/InstrumentPanel.tsx b/packages/ui/src/components/ds/InstrumentPanel.tsx
index 467902a..7d89375 100644
--- a/packages/ui/src/components/ds/InstrumentPanel.tsx
+++ b/packages/ui/src/components/ds/InstrumentPanel.tsx
@@ -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({
- {/* 각인 텍스트 */}
+ {/* 모서리 메타 라벨 */}
{engravingLeft}
{engravingRight}
{engravingBottom}
- {/* 콘텐츠 (z-index 5로 노이즈 위) */}
{children}
@@ -59,20 +59,17 @@ export function InstrumentPanel({
}
function Engraving({ children, sx }: { children: string; sx: Record }): React.ReactElement {
- const theme = useTheme()
- const isLight = theme.palette.mode === 'light'
return (
= {
- 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 },
},
}
: {}),
diff --git a/packages/ui/src/components/ds/MetalCard.tsx b/packages/ui/src/components/ds/MetalCard.tsx
index cfbb96c..db2899c 100644
--- a/packages/ui/src/components/ds/MetalCard.tsx
+++ b/packages/ui/src/components/ds/MetalCard.tsx
@@ -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 {
+ /** 카드 내부의 상승 표면 (카드 안의 카드) */
inset?: boolean
}
@@ -21,14 +22,27 @@ export function MetalCard({
diff --git a/packages/ui/src/components/ds/PhosphorText.tsx b/packages/ui/src/components/ds/PhosphorText.tsx
index f841eb0..a681f4f 100644
--- a/packages/ui/src/components/ds/PhosphorText.tsx
+++ b/packages/ui/src/components/ds/PhosphorText.tsx
@@ -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 = {
- 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 {
@@ -54,11 +51,10 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
{
selected?: boolean
@@ -16,27 +16,29 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto