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 버튼 스택 */} + + + + + {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')} + + - {/* ── 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