feat(ui): flat "quiet instrument" token v4 with a single raw-value SSOT
- Add theme-raw.ts (no MUI/DOM deps) as the one source of theme values. Each theme declares only surfaces, 3-tier text, borders, accent and overlay shadow; legacy glass/gradient/glow/crt keys are derived as flat values so existing call sites lose effects without edits. - Tertiary text now clears 4.5:1 on every surface in all six themes (label/inactive/dimLabel/muted alias it). Filled buttons use accent.solid + accent.ink (5.17:1 on dark/light, was 3.68:1). - theme-vars.ts derives popup CSS from the same raw values instead of a hand copy, and defines the previously undefined --d3-overlay-strong and --d3-accent-glow-dim. - MUI: no ripple, global focus-visible ring and reduced-motion rule, flat Card, status colors per theme, shape radius 16 -> 6. - DS: flatten MetalCard/DoubleBezelCard/ScreenPanel/TiltCard, tone-based PhysicalButton (500 weight, no lift), TactileBadge with zero tracking and keyboard support, glow-free Led/StatRing, idle wave amplitude 0. - PhosphorText drops mono/uppercase variants: mono has no Hangul glyphs, which spaced out Korean labels. - design.md: record the 2026-09-28 redesign brief, audit, IA and tokens.
This commit is contained in:
parent
c3367bc611
commit
2428ede03d
15 changed files with 969 additions and 1515 deletions
67
design.md
67
design.md
|
|
@ -217,3 +217,70 @@
|
||||||
- 빌드 경로 `base: '/'`(언어 하위 경로에서도 자산 경로가 맞도록).
|
- 빌드 경로 `base: '/'`(언어 하위 경로에서도 자산 경로가 맞도록).
|
||||||
- 검증: 본문 3,600~7,700자/언어, 하이드레이션 경고 0, 10개 언어 320/1440 넘침 0, axe 0, Lighthouse(모바일) SEO·접근성·권장사항 100.
|
- 검증: 본문 3,600~7,700자/언어, 하이드레이션 경고 0, 10개 언어 320/1440 넘침 0, axe 0, Lighthouse(모바일) SEO·접근성·권장사항 100.
|
||||||
- 하지 않은 것: 언어별 별도 콘텐츠(번역 외 추가 페이지)·블로그, 가짜 후기·평점, 자동 언어 리다이렉트.
|
- 하지 않은 것: 언어별 별도 콘텐츠(번역 외 추가 페이지)·블로그, 가짜 후기·평점, 자동 언어 리다이렉트.
|
||||||
|
|
||||||
|
## 2026-09-28 전면 개편 — 데스크톱·팝업·모바일·공유 DS ("기록이 주인공인 조용한 계기")
|
||||||
|
|
||||||
|
전체 경로 + 리디자인 감사. 방향(swiss-minimal × dark-instrument, 탈볼드, Pretendard, blue)은 계승하고,
|
||||||
|
정보 구조·토큰 v4·팝업·모바일 내비를 새로 정했다. 물결별 분리 커밋(① 토큰·DS → ② 데스크톱 구조 →
|
||||||
|
③ 받아쓰기 팝업 → ④ 모바일 → ⑤ 통합 검색).
|
||||||
|
|
||||||
|
### 브리프 (사용자 인터뷰 확정)
|
||||||
|
- 무엇을: 데스크톱(메인 앱·팝업 6종)·모바일 RN·공유 DS의 시각 언어 + 정보 구조·흐름 전면 개편.
|
||||||
|
- 누구에게: 1순위 회의 많은 직장인 → 2순위 지식노동자·개발자 → 3순위 일반 사용자.
|
||||||
|
목표: 기록·회의 결과를 쉽게 찾고 다시 쓰며, 받아쓰기가 끊김 없이 흐른다.
|
||||||
|
- 제약: 톤 = **조용한 정밀 도구**(사용자가 "극적인 정밀 기기(CRT·메탈·LED)"를 명시적으로 기각).
|
||||||
|
1급 = 기록·메모, 회의. 모바일 = 독립 완결형(1급 구조 동일). 기존 스택·기능 보존, 10개 로케일, 6테마.
|
||||||
|
- 우선순위: 구조(기능 산만) → 받아쓰기 흐름 → 기록 재사용 → 두 앱 일치.
|
||||||
|
- 기본 언어: ko-KR. 새 카피는 한국어, 영어는 기술 용어·키 이름만.
|
||||||
|
|
||||||
|
### 감사 (개편 전, 증거: 1280×800 dark/light 캡처 + 코드 감사 4건)
|
||||||
|
- 하드 게이트 위반: 데스크톱 사이드바 Box onClick(키보드 불가) / "100% LOCAL PRIVACY · ZERO CLOUD SYNC"(v1.9.0 기기 간
|
||||||
|
동기화와 모순) / 받아쓰기 Esc 취소 없음·처리 중 클릭 시 텍스트 소실 / 삭제 7종 확인·되돌림 없음 /
|
||||||
|
대비 미달 토큰 약 100곳(muted 1.42:1, dimLabel 2.72:1) / 감소 모션 전무.
|
||||||
|
- 산만함: 진입점 데스크톱 약 45개·모바일 27개. 회의는 기록에 저장되지 않고 통합 검색 없음. "메모"가 세 의미.
|
||||||
|
- 시각: 모든 카드가 글래스+글로우+그라디언트 링 → 같은 무게. 한글 라벨에 모노 폰트 → "누 름 유 지"처럼 벌어짐
|
||||||
|
(모노 폰트에 한글 글리프가 없다). 팝업 radius 999/16/10 혼재, 팔레트 웹·네이티브 3벌.
|
||||||
|
|
||||||
|
### 레퍼런스 (관찰은 scratch 아닌 이 기록에 요약)
|
||||||
|
- R1 구조(같은 시장): 클로바노트(노트+폴더, 요약·메모 영역 분리, 복사 2종), 다글로(보드, 요약/전사 탭, 모바일 가운데 +).
|
||||||
|
- R1 구조(목적): Granola(사용자 노트 검정·AI 회색, 근거 인용 🔍), Otter(Summary/Transcript 2탭, View in transcript).
|
||||||
|
- R1' 받아쓰기: Wispr Flow(화면 하단 작은 상태 바, 날짜별 기록+검색+hover 복사), Superwhisper(로딩/녹음/처리/완료 상태색, ESC 취소).
|
||||||
|
- R2 톤: Linear(요소마다 무게 다르게, 사이드바 몇 단계 어둡게, 크롬의 파랑 최소), Apple HIG(탭 바는 이동용, 빈번한 상호작용에 모션 자제).
|
||||||
|
- 가져온 원리: 요약 먼저·전사 근거 링크 / 재사용 행동 한 곳, 복사는 요약·전사 분리 / 받아쓰기는 날짜별 기록 /
|
||||||
|
녹음 중 작은 상시 표시 + 상태색 분리 / 탭 바는 이동, 녹음은 플로팅 버튼.
|
||||||
|
|
||||||
|
### 정보 구조 (두 앱 공통)
|
||||||
|
- 1급: **홈(오늘) · 기록 · 회의** — 같은 순서·같은 이름. 통합 검색(기록+회의, 데스크톱 Ctrl+K).
|
||||||
|
- 2급 "도구": AI 대화 · 명령어 · 사전 · 지식 · 템플릿 · 팀 · 음성 액션 · 데이터. 설정은 데스크톱 전체 화면.
|
||||||
|
- '메모 태그' → 기록 안의 '태그' 필터. 회의 안 메모는 '회의 메모'. 대시보드 통계는 홈 한 줄 요약.
|
||||||
|
- 모바일 하단: **탭 4(홈·기록·회의·더보기) + 떠 있는 녹음 버튼**. 녹음 버튼을 누르면 위로 시작 메뉴
|
||||||
|
(받아쓰기·회의 녹음·파일 가져오기)가 펼쳐진다(사용자 지시, M3 FAB 메뉴 패턴). 탭 바에 행동을 두지 않는다(HIG).
|
||||||
|
- 기록↔회의: 데이터는 분리 유지(스키마 변경 없음), 통합 검색 + 상호 링크(사용자 선택).
|
||||||
|
|
||||||
|
### 토큰 v4 (SSOT: `packages/ui/src/theme-raw.ts` — MUI·DOM 의존 없음)
|
||||||
|
- 구조: 테마마다 기본값(표면 7·텍스트 3단+disabled·경계 3·액센트 7·그림자 2)만 적고 `deriveRaw()`가 v2 호환
|
||||||
|
키(glass/gradient/glow/crt/label/inactive/dimLabel/muted)를 **평면 값으로 파생**. 컴포넌트 수백 곳을 고치지 않고 이펙트를 끈다.
|
||||||
|
glow.* = none, glass.sheen = none, blur = 0px, gradient.* = 단색, shadow.card/chassis/inset/button* = none.
|
||||||
|
- 텍스트 3단: tertiary 는 app/card/elevated/sidebar 모든 표면에서 4.5:1 이상(dark #7e8eb0 4.62, light #56647c,
|
||||||
|
nord #a6b0c4, solarized #a1aca9 4.70 + secondary #b7bdb6, catppuccin #939cbf, dracula #a1a7c1).
|
||||||
|
- accent.solid/solidHover/ink: 채운 버튼. dark·light #2563eb + 흰 글자 5.17:1(이전 #3b82f6 3.68:1 미달),
|
||||||
|
solarized #8a6800 5.17:1(이전 3.21), nord·catppuccin·dracula 는 밝은 채움 + 어두운 잉크. light accent.light #1d4ed8(글자용).
|
||||||
|
- 그림자는 떠 있는 층에만: `d3roShadow.overlay`(다이얼로그·메뉴·팝오버·플로팅 버튼), tooltip.
|
||||||
|
- radius: card 10 · outer 12 · inner 8 · button 8 · small 6 · xs 4 (정밀 도구 — 작고 일정). MUI shape 16→6
|
||||||
|
(sx borderRadius:2 가 32px 이던 버그 해소).
|
||||||
|
- 타입: hero 32→28, title 24→22, meta 11/500/0.02em→12/400/0, label·engrave·micro 11/500/**자간 0**,
|
||||||
|
nano 9→10(라틴 수치 전용). 모든 PhosphorText 변형 산세리프·대문자 변환 없음.
|
||||||
|
- 컨트롤 36/28, 모션 100/160/240/400ms + easeOut. `d3roTransition()`, `d3roFocusRing` 헬퍼 추가.
|
||||||
|
- MUI: ripple 끔, 전역 `:focus-visible` 링, 전역 prefers-reduced-motion, Card = 평면+1px, Chip·palette 상태색 = 테마별 STATUS
|
||||||
|
(고정 tag 는 점·아이콘 전용), Backdrop blur 제거.
|
||||||
|
- 팝업: `theme-vars.ts`가 같은 원시값에서 파생(손 복제 폐기). 정의 안 된 채 쓰이던 `--d3-overlay-strong`,
|
||||||
|
`--d3-accent-glow-dim` 정의. 파형 9단 = 액센트 단색. 'system' 테마 해석 함수 `resolvePopupThemeKey` 추가.
|
||||||
|
- DS: MetalCard·DoubleBezelCard·ScreenPanel 평면화, TiltCard = MetalCard(틸트 제거), PhysicalButton 톤 5종 평면
|
||||||
|
(accent 만 채움, 600→500, 떠오름 제거, 액센트 잉크 테마 반응), TactileBadge 자간 0·500·STATUS·키보드 조작,
|
||||||
|
Led 글로우 제거·상태색 STATUS, StatRing 다색 원뿔 → 1px 원, GradientWave 기본 유휴 진폭 0(대기 중 파형이 "살아있는 척"
|
||||||
|
움직이던 거짓 신호 제거)·글로우 0.
|
||||||
|
|
||||||
|
### 의도적으로 하지 않은 것 (물결 ①)
|
||||||
|
- **옛 토큰 키 삭제 안 함.** glass/gradient/glow/crt 키는 @deprecated 로 남긴다(웹·관리자·데스크톱 수백 곳). 새 코드는 쓰지 않는다.
|
||||||
|
- **6테마 액센트 통일 안 함.** nord·catppuccin·dracula·solarized 는 사용자가 고른 테마라 고유 액센트 유지, 대비만 보정.
|
||||||
|
- **`--d3-text-inverse` 정의 안 함.** 웹 앱 17곳이 제목 색으로 오용 중 — 값을 정하면 라이트 테마 제목이 사라진다. 웹 정리 때 함께.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
"types": "./src/theme.ts",
|
"types": "./src/theme.ts",
|
||||||
"default": "./src/theme.ts"
|
"default": "./src/theme.ts"
|
||||||
},
|
},
|
||||||
|
"./theme-raw": {
|
||||||
|
"types": "./src/theme-raw.ts",
|
||||||
|
"default": "./src/theme-raw.ts"
|
||||||
|
},
|
||||||
"./theme-vars": {
|
"./theme-vars": {
|
||||||
"types": "./src/theme-vars.ts",
|
"types": "./src/theme-vars.ts",
|
||||||
"default": "./src/theme-vars.ts"
|
"default": "./src/theme-vars.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,25 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// packages/ui/src/components/ds/DoubleBezelCard.tsx
|
// packages/ui/src/components/ds/DoubleBezelCard.tsx
|
||||||
// High-End Agency / Awwwards-Tier Double-Bezel (Doppelrand) Enclosure Component
|
// v4 "조용한 계기": 이중 베젤을 걷어낸 단일 카드. 이름과 props 는 호환을 위해 유지한다.
|
||||||
// Outer Shell: Precision machined tray with outer radius & ambient hairline
|
// bezelPadding 은 무시된다(바깥 틀이 없다). interactive 는 선·배경 변화만 — 떠오르지 않는다.
|
||||||
// Inner Core: Floating glass plate with concentric inner radius, specular highlight & blur
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Box, type BoxProps } from '@mui/material'
|
import { Box, type BoxProps } from '@mui/material'
|
||||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
import { d3roPalette, d3roRadius, d3roTransition } from '../../theme'
|
||||||
|
|
||||||
export interface DoubleBezelCardProps extends Omit<BoxProps, 'component'> {
|
export interface DoubleBezelCardProps extends Omit<BoxProps, 'component'> {
|
||||||
/** Outer padding between tray and core (default: 6px) */
|
/** @deprecated v4: 바깥 틀이 없어 쓰이지 않는다 */
|
||||||
bezelPadding?: number | string
|
bezelPadding?: number | string
|
||||||
/** Whether the card shows an interactive lift / glow on hover */
|
/** 누를 수 있는 카드 — hover 시 선과 배경만 바뀐다 */
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
/** Inner core padding (default: 3 / 24px) */
|
/** 안쪽 여백 (기본 3 = 24px) */
|
||||||
innerPadding?: number | string
|
innerPadding?: number | string
|
||||||
/** Content to render inside the inner core */
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DoubleBezelCard({
|
export function DoubleBezelCard({
|
||||||
bezelPadding = '6px',
|
bezelPadding: _bezelPadding,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
innerPadding = 3,
|
innerPadding = 3,
|
||||||
children,
|
children,
|
||||||
|
|
@ -33,69 +31,21 @@ export function DoubleBezelCard({
|
||||||
{...boxProps}
|
{...boxProps}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderRadius: d3roRadius.doubleBezelOuter,
|
borderRadius: d3roRadius.card,
|
||||||
p: bezelPadding,
|
p: innerPadding,
|
||||||
bgcolor: d3roPalette.glass.raised,
|
bgcolor: d3roPalette.bg.card,
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.border.default}`,
|
||||||
boxShadow: interactive ? d3roShadow.card : 'none',
|
transition: d3roTransition('border-color', 'background-color'),
|
||||||
transition: 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease, border-color 0.3s ease',
|
|
||||||
...(interactive
|
...(interactive
|
||||||
? {
|
? {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': {
|
'&:hover': { borderColor: d3roPalette.border.strong, bgcolor: d3roPalette.bg.cardHover },
|
||||||
borderColor: d3roPalette.glass.hairlineStrong,
|
|
||||||
boxShadow: d3roShadow.glowCardHover,
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
'& .d3-inner-core': {
|
|
||||||
bgcolor: d3roPalette.bg.cardHover,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
transform: 'translateY(0px) scale(0.99)',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Inner Core Container */}
|
{children}
|
||||||
<Box
|
|
||||||
className="d3-inner-core"
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: d3roRadius.doubleBezelInner,
|
|
||||||
bgcolor: d3roPalette.glass.surface,
|
|
||||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
|
||||||
boxShadow: `${d3roShadow.inset}, ${d3roShadow.glowCard}`,
|
|
||||||
p: innerPadding,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'background-color 0.25s ease',
|
|
||||||
// Top Sheen + Specular highlight
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
background: d3roPalette.glass.sheen,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
// 1px Gradient Hairline Rim
|
|
||||||
'&::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
padding: '1px',
|
|
||||||
background: d3roPalette.glass.borderGradient,
|
|
||||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
|
||||||
WebkitMaskComposite: 'xor',
|
|
||||||
maskComposite: 'exclude',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ position: 'relative', zIndex: 1 }}>{children}</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@ function seeded(i: number): number {
|
||||||
|
|
||||||
export function GradientWave({
|
export function GradientWave({
|
||||||
audioLevel = 0,
|
audioLevel = 0,
|
||||||
idleAmplitude = 0.25,
|
idleAmplitude = 0,
|
||||||
speed = 1,
|
speed = 1,
|
||||||
barWidth = 3,
|
barWidth = 3,
|
||||||
barGap = 3,
|
barGap = 3,
|
||||||
glow = 8,
|
glow = 0,
|
||||||
height = '100%',
|
height = '100%',
|
||||||
}: GradientWaveProps): React.ReactElement {
|
}: GradientWaveProps): React.ReactElement {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// src/renderer/components/ds/Led.tsx
|
// packages/ui/src/components/ds/Led.tsx
|
||||||
// v2 "Midnight Glass": 상태 도트 — 소프트 글로우, 활성 시 pulse
|
// v4 "조용한 계기": 상태 점. 번짐(글로우) 없음. 색만으로 상태를 말하지 않는다 — 옆에 글자를 둔다.
|
||||||
// (레퍼런스의 "● READY" / "● 시스템 정상" 인디케이터)
|
// pulse 는 진행 중(녹음·연결 중)에만. 감소 모션에서는 전역 규칙으로 멈춘다.
|
||||||
|
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { d3roPalette } from '../../theme'
|
import { d3roPalette } from '../../theme'
|
||||||
|
|
||||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
|
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
|
||||||
|
|
||||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
const LED_COLORS: Record<LedColor, string> = {
|
||||||
// 'amber'는 v1 호환 별칭 — 현재 테마의 프라이머리 액센트 색으로 렌더
|
// 'amber'는 v1 호환 별칭 — 현재 테마의 액센트
|
||||||
amber: { bg: d3roPalette.accent.main, glow: d3roPalette.accent.glow },
|
amber: d3roPalette.accent.main,
|
||||||
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
green: d3roPalette.status.success,
|
||||||
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
red: d3roPalette.status.danger,
|
||||||
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
orange: d3roPalette.status.warning,
|
||||||
blue: { bg: d3roPalette.tag.blue, glow: d3roPalette.tag.blueGlow },
|
blue: d3roPalette.accent.main,
|
||||||
purple: { bg: d3roPalette.tag.purple, glow: d3roPalette.tag.purpleGlow },
|
purple: d3roPalette.tag.purple,
|
||||||
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
off: d3roPalette.text.disabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LedProps {
|
interface LedProps {
|
||||||
|
|
@ -27,25 +27,23 @@ interface LedProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React.ReactElement {
|
export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React.ReactElement {
|
||||||
const c = LED_COLORS[color]
|
|
||||||
const isActive = color !== 'off'
|
const isActive = color !== 'off'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
aria-hidden
|
||||||
sx={{
|
sx={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: c.bg,
|
bgcolor: LED_COLORS[color],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: isActive ? `0 0 ${size * 1.2}px ${c.glow}` : 'none',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
...(pulse && isActive
|
...(pulse && isActive
|
||||||
? {
|
? {
|
||||||
animation: 'led-pulse 1.6s ease-in-out infinite',
|
animation: 'led-pulse 1.6s ease-in-out infinite',
|
||||||
'@keyframes led-pulse': {
|
'@keyframes led-pulse': {
|
||||||
'0%, 100%': { opacity: 1 },
|
'0%, 100%': { opacity: 1 },
|
||||||
'50%': { opacity: 0.45 },
|
'50%': { opacity: 0.4 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// packages/ui/src/components/ds/MetalCard.tsx
|
// packages/ui/src/components/ds/MetalCard.tsx
|
||||||
// v2 "Midnight Glass": 글래스모피즘 카드 —
|
// v4 "조용한 계기": 평평한 카드 — 표면 한 단 + 1px 선. 그림자·블러·광택 없음.
|
||||||
// 그라디언트 헤어라인 보더(좌상단 발광 → 우하단 소멸) + 앰비언트 글로우 + 상단 시인.
|
// inset: 카드 안의 한 단 들어간 영역 (카드 안의 카드가 아니라 우묵한 판).
|
||||||
// 보더는 mask 링 기법(::after) — 반투명 표면 위에서도 정확한 1px 그라디언트 링.
|
|
||||||
|
|
||||||
import { Box, type BoxProps } from '@mui/material'
|
import { Box, type BoxProps } from '@mui/material'
|
||||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
import { d3roPalette, d3roRadius, d3roTransition } from '../../theme'
|
||||||
|
|
||||||
interface MetalCardProps extends Omit<BoxProps, 'component'> {
|
interface MetalCardProps extends Omit<BoxProps, 'component'> {
|
||||||
/** 카드 내부의 상승 표면 (카드 안의 카드) */
|
/** 카드 내부의 우묵한 영역 */
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,41 +23,11 @@ export function MetalCard({
|
||||||
{...boxProps}
|
{...boxProps}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
bgcolor: inset ? d3roPalette.glass.raised : d3roPalette.glass.surface,
|
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
|
||||||
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
||||||
border: inset ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
|
border: `1px solid ${inset ? d3roPalette.border.subtle : d3roPalette.border.default}`,
|
||||||
boxShadow: inset ? 'none' : d3roShadow.glowCard,
|
|
||||||
p: inset ? 2 : 3,
|
p: inset ? 2 : 3,
|
||||||
overflow: 'hidden',
|
transition: d3roTransition('border-color', 'background-color'),
|
||||||
transition: 'box-shadow 0.25s ease',
|
|
||||||
// 상단 시인 + 앰비언트 라디얼 (theme sheen 토큰이 레이어 결합)
|
|
||||||
'&::before': inset
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
background: d3roPalette.glass.sheen,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
// 그라디언트 헤어라인 링 — padding 1px + mask exclude로 링만 남김
|
|
||||||
'&::after': inset
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
padding: '1px',
|
|
||||||
background: d3roPalette.glass.borderGradient,
|
|
||||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
|
||||||
WebkitMaskComposite: 'xor',
|
|
||||||
maskComposite: 'exclude',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&:hover': inset ? {} : { boxShadow: d3roShadow.glowCardHover },
|
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// src/renderer/components/ds/PhosphorText.tsx
|
// packages/ui/src/components/ds/PhosphorText.tsx
|
||||||
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
|
// v4 "조용한 계기": 타이포 변형 13종 — 전부 산세리프(Pretendard), 대문자 변환 없음.
|
||||||
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
|
// 위계는 크기와 명도(text 3단)로 만든다. 한글 라벨에 모노·양수 자간을 쓰지 않는다
|
||||||
|
// (모노 폰트에 한글 글리프가 없어 "누 름 유 지"처럼 벌어졌다 — 2026-09-28 실측).
|
||||||
|
// 수치는 tabular-nums 로 흔들리지 않게.
|
||||||
|
|
||||||
import { Typography, type TypographyProps } from '@mui/material'
|
import { Typography, type TypographyProps } from '@mui/material'
|
||||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
|
import { d3roPalette, d3roFontSans, d3roTypo } from '../../theme'
|
||||||
|
|
||||||
type PhosphorVariant =
|
type PhosphorVariant =
|
||||||
| 'hero' | 'title' | 'value' | 'heading'
|
| 'hero' | 'title' | 'value' | 'heading'
|
||||||
|
|
@ -19,24 +21,30 @@ interface VariantDef {
|
||||||
fontWeight: number
|
fontWeight: number
|
||||||
letterSpacing: string
|
letterSpacing: string
|
||||||
lineHeight: number
|
lineHeight: number
|
||||||
textTransform?: 'uppercase' | 'none'
|
|
||||||
mono?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const def = (key: keyof typeof d3roTypo, color: string): VariantDef => ({
|
||||||
|
fontSize: d3roTypo[key].size,
|
||||||
|
fontWeight: d3roTypo[key].weight,
|
||||||
|
letterSpacing: d3roTypo[key].spacing,
|
||||||
|
lineHeight: d3roTypo[key].line,
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
|
||||||
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
||||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
hero: def('hero', d3roPalette.text.primary),
|
||||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
title: def('title', d3roPalette.text.primary),
|
||||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
value: def('value', d3roPalette.text.primary),
|
||||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
heading: def('heading', d3roPalette.text.primary),
|
||||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
body: def('body', d3roPalette.text.secondary),
|
||||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
compact: def('compact', d3roPalette.text.primary),
|
||||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
small: def('small', d3roPalette.text.tertiary),
|
||||||
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 },
|
meta: def('meta', d3roPalette.text.tertiary),
|
||||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
label: def('label', d3roPalette.text.tertiary),
|
||||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
dim: { ...def('label', d3roPalette.text.tertiary), fontWeight: d3roTypo.body.weight },
|
||||||
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 },
|
engrave: def('engrave', d3roPalette.text.tertiary),
|
||||||
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 },
|
micro: def('micro', d3roPalette.text.tertiary),
|
||||||
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 },
|
nano: def('nano', d3roPalette.text.tertiary),
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||||
|
|
@ -51,14 +59,14 @@ export function PhosphorText({ variant = 'value', component = 'span', sx, ...pro
|
||||||
component={component}
|
component={component}
|
||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: v.fontSize,
|
fontSize: v.fontSize,
|
||||||
fontWeight: v.fontWeight,
|
fontWeight: v.fontWeight,
|
||||||
color: v.color,
|
color: v.color,
|
||||||
letterSpacing: v.letterSpacing,
|
letterSpacing: v.letterSpacing,
|
||||||
lineHeight: v.lineHeight,
|
lineHeight: v.lineHeight,
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
textTransform: v.textTransform ?? 'none',
|
textTransform: 'none',
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,68 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// packages/ui/src/components/ds/PhysicalButton.tsx
|
// packages/ui/src/components/ds/PhysicalButton.tsx
|
||||||
// High-End Tactile Button with Button-in-Button Trailing Icon & Spring Physics
|
// v4 "조용한 계기": 버튼 한 종류, 톤 다섯. 채움은 accent 톤 하나뿐이고 나머지는 선과 글자로 구분한다.
|
||||||
|
// 움직임은 색 변화뿐 — 떠오르거나 튀지 않는다.
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Button, Box, type ButtonProps } from '@mui/material'
|
import { Button, Box, type ButtonProps } from '@mui/material'
|
||||||
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
import { d3roPalette, d3roFontSans, d3roRadius, d3roTypo, d3roControl, d3roTransition } from '../../theme'
|
||||||
|
|
||||||
export interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
export interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||||
/** Active selected state (renders primary accent gradient) */
|
/** 선택된 상태 (accent 톤으로 렌더) */
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
/** Visual button tone */
|
/** 시각 톤 */
|
||||||
tone?: 'accent' | 'glass' | 'danger' | 'success' | 'ghost'
|
tone?: 'accent' | 'glass' | 'danger' | 'success' | 'ghost'
|
||||||
/** Trailing icon rendered in a nested circular capsule (Button-in-Button pattern) */
|
/** 글자 뒤 아이콘 */
|
||||||
trailingIcon?: React.ReactNode
|
trailingIcon?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToneStyle {
|
||||||
|
bg: string
|
||||||
|
bgHover: string
|
||||||
|
color: string
|
||||||
|
colorHover: string
|
||||||
|
border: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONES: Record<'accent' | 'glass' | 'danger' | 'success' | 'ghost', ToneStyle> = {
|
||||||
|
accent: {
|
||||||
|
bg: d3roPalette.accent.solid,
|
||||||
|
bgHover: d3roPalette.accent.solidHover,
|
||||||
|
color: d3roPalette.accent.ink,
|
||||||
|
colorHover: d3roPalette.accent.ink,
|
||||||
|
border: 'transparent',
|
||||||
|
},
|
||||||
|
glass: {
|
||||||
|
bg: 'transparent',
|
||||||
|
bgHover: d3roPalette.surface.overlaySoft,
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
colorHover: d3roPalette.text.primary,
|
||||||
|
border: d3roPalette.border.strong,
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
bg: 'transparent',
|
||||||
|
bgHover: d3roPalette.status.dangerBg,
|
||||||
|
color: d3roPalette.status.danger,
|
||||||
|
colorHover: d3roPalette.status.danger,
|
||||||
|
border: d3roPalette.border.strong,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bg: 'transparent',
|
||||||
|
bgHover: d3roPalette.status.successBg,
|
||||||
|
color: d3roPalette.status.success,
|
||||||
|
colorHover: d3roPalette.status.success,
|
||||||
|
border: d3roPalette.border.strong,
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
bg: 'transparent',
|
||||||
|
bgHover: d3roPalette.surface.overlaySoft,
|
||||||
|
color: d3roPalette.text.secondary,
|
||||||
|
colorHover: d3roPalette.text.primary,
|
||||||
|
border: 'transparent',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export function PhysicalButton({
|
export function PhysicalButton({
|
||||||
selected = false,
|
selected = false,
|
||||||
tone = 'glass',
|
tone = 'glass',
|
||||||
|
|
@ -24,71 +71,34 @@ export function PhysicalButton({
|
||||||
sx,
|
sx,
|
||||||
...props
|
...props
|
||||||
}: PhysicalButtonProps): React.ReactElement {
|
}: PhysicalButtonProps): React.ReactElement {
|
||||||
const isAccent = selected || tone === 'accent'
|
const t = TONES[selected ? 'accent' : tone]
|
||||||
const isDanger = tone === 'danger'
|
|
||||||
const isSuccess = tone === 'success'
|
|
||||||
|
|
||||||
let bg: string = d3roPalette.glass.raised
|
|
||||||
let bgImage: string = 'none'
|
|
||||||
let textColor: string = d3roPalette.text.primary
|
|
||||||
let borderColor: string = d3roPalette.glass.hairline
|
|
||||||
let shadow: string = 'none'
|
|
||||||
|
|
||||||
if (isAccent) {
|
|
||||||
bg = 'transparent'
|
|
||||||
bgImage = d3roPalette.gradient.accent
|
|
||||||
textColor = d3roPalette.text.inverse
|
|
||||||
borderColor = 'transparent'
|
|
||||||
shadow = d3roShadow.glowAccent
|
|
||||||
} else if (isDanger) {
|
|
||||||
bg = d3roPalette.tag.redBg
|
|
||||||
textColor = d3roPalette.tag.red
|
|
||||||
borderColor = d3roPalette.tag.redGlow
|
|
||||||
} else if (isSuccess) {
|
|
||||||
bg = d3roPalette.tag.greenBg
|
|
||||||
textColor = d3roPalette.tag.green
|
|
||||||
borderColor = d3roPalette.tag.greenGlow
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
height: 40,
|
height: d3roControl.h,
|
||||||
px: trailingIcon ? 2 : 2.5,
|
px: 1.75,
|
||||||
bgcolor: bg,
|
bgcolor: t.bg,
|
||||||
backgroundImage: bgImage,
|
backgroundImage: 'none',
|
||||||
border: `1px solid ${borderColor}`,
|
border: `1px solid ${t.border}`,
|
||||||
borderRadius: d3roRadius.button,
|
borderRadius: d3roRadius.button,
|
||||||
color: textColor,
|
color: t.color,
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: d3roTypo.compact.size,
|
fontSize: d3roTypo.compact.size,
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
boxShadow: 'none',
|
||||||
boxShadow: shadow,
|
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
letterSpacing: '0.01em',
|
letterSpacing: 0,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 1.25,
|
gap: 1,
|
||||||
transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
whiteSpace: 'nowrap',
|
||||||
'&:hover': {
|
transition: d3roTransition('background-color', 'color', 'border-color'),
|
||||||
bgcolor: isAccent ? undefined : d3roPalette.bg.cardHover,
|
'&:hover': { bgcolor: t.bgHover, color: t.colorHover, boxShadow: 'none' },
|
||||||
borderColor: isAccent ? 'transparent' : d3roPalette.glass.hairlineStrong,
|
'&.Mui-disabled': { opacity: 0.5, color: t.color, borderColor: t.border },
|
||||||
color: isAccent ? d3roPalette.text.inverse : d3roPalette.text.primary,
|
|
||||||
filter: isAccent ? 'brightness(1.08)' : 'none',
|
|
||||||
boxShadow: isAccent ? d3roShadow.glowAccent : d3roShadow.card,
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
'& .d3-button-trailing-icon': {
|
|
||||||
transform: 'translateX(2px)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
transform: 'translateY(0) scale(0.98)',
|
|
||||||
filter: 'brightness(0.95)',
|
|
||||||
},
|
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -97,21 +107,10 @@ export function PhysicalButton({
|
||||||
</Box>
|
</Box>
|
||||||
{trailingIcon && (
|
{trailingIcon && (
|
||||||
<Box
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-hidden
|
||||||
className="d3-button-trailing-icon"
|
className="d3-button-trailing-icon"
|
||||||
sx={{
|
sx={{ display: 'inline-flex', alignItems: 'center', opacity: 0.8, '& svg': { width: 14, height: 14 } }}
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.bg.inset,
|
|
||||||
border: `1px solid ${isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.border.subtle}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: isAccent ? d3roPalette.text.inverse : d3roPalette.text.secondary,
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
flexShrink: 0,
|
|
||||||
'& svg': { width: 14, height: 14 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{trailingIcon}
|
{trailingIcon}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// src/renderer/components/ds/ScreenPanel.tsx
|
// packages/ui/src/components/ds/ScreenPanel.tsx
|
||||||
// v2 "Midnight Glass": 딥 글래스 패널 — 카드 내부의 어두운 정보 표시 영역
|
// v4 "조용한 계기": 카드 안의 우묵한 표시 영역 — 한 단 어두운 판 + 1px 선. 광택 없음.
|
||||||
// (레퍼런스의 히어로 카드 내부 / "현재 백엔드" 내부 패널 패턴)
|
|
||||||
|
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { d3roPalette, d3roRadius } from '../../theme'
|
import { d3roPalette, d3roRadius } from '../../theme'
|
||||||
|
|
@ -18,34 +17,17 @@ export function ScreenPanel({ children, height }: ScreenPanelProps): React.React
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
bgcolor: d3roPalette.bg.crtGlass,
|
bgcolor: d3roPalette.bg.inset,
|
||||||
borderRadius: d3roRadius.inner,
|
borderRadius: d3roRadius.inner,
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height,
|
height,
|
||||||
// 상단 시인
|
p: 2,
|
||||||
'&::after': {
|
display: 'flex',
|
||||||
content: '""',
|
flexDirection: 'column',
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: d3roPalette.glass.sheen,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{children}
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 2,
|
|
||||||
p: 2,
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,41 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// src/renderer/components/ds/StatRing.tsx
|
// packages/ui/src/components/ds/StatRing.tsx
|
||||||
// v2 신규: 컬러 그라디언트 링 아이콘 — 스탯 타일의 원형 링 (레퍼런스 4타일 패턴)
|
// v4 "조용한 계기": 아이콘 자리. 다색 원뿔 그라디언트 링·글로우를 걷어내고 1px 원 + 한 색 아이콘으로.
|
||||||
|
// color 는 호환용 — accent 만 강조색, 나머지는 모두 보조 텍스트색(색으로 항목을 구분하지 않는다).
|
||||||
|
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { d3roPalette } from '../../theme'
|
import { d3roPalette } from '../../theme'
|
||||||
|
|
||||||
type RingColor = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'accent'
|
type RingColor = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'accent'
|
||||||
|
|
||||||
const RING_COLORS: Record<RingColor, { from: string; to: string; glow: string; icon: string }> = {
|
|
||||||
blue: { from: 'var(--d3-gradient-wave1)', to: 'var(--d3-gradient-wave2)', glow: d3roPalette.tag.blueBg, icon: d3roPalette.tag.blue },
|
|
||||||
purple: { from: 'var(--d3-gradient-wave3)', to: 'var(--d3-gradient-wave4)', glow: d3roPalette.tag.purpleBg, icon: d3roPalette.tag.purple },
|
|
||||||
green: { from: d3roPalette.tag.green, to: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow, icon: d3roPalette.tag.green },
|
|
||||||
orange: { from: d3roPalette.tag.orange, to: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow, icon: d3roPalette.tag.orange },
|
|
||||||
red: { from: d3roPalette.tag.red, to: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow, icon: d3roPalette.tag.red },
|
|
||||||
accent: { from: 'var(--d3-accent-light)', to: 'var(--d3-accent-dark)', glow: 'var(--d3-accent-dim)', icon: d3roPalette.accent.light },
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatRingProps {
|
interface StatRingProps {
|
||||||
/** 링 색 (기본 blue) */
|
|
||||||
color?: RingColor
|
color?: RingColor
|
||||||
/** 외경 px (기본 56) */
|
/** 외경 px (기본 56) */
|
||||||
size?: number
|
size?: number
|
||||||
/** 링 두께 px (기본 2) */
|
/** @deprecated v4: 선은 1px 고정 */
|
||||||
thickness?: number
|
thickness?: number
|
||||||
/** 중앙 아이콘/콘텐츠 */
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatRing({
|
export function StatRing({ color = 'blue', size = 56, children }: StatRingProps): React.ReactElement {
|
||||||
color = 'blue',
|
|
||||||
size = 56,
|
|
||||||
thickness = 2,
|
|
||||||
children,
|
|
||||||
}: StatRingProps): React.ReactElement {
|
|
||||||
const c = RING_COLORS[color]
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
aria-hidden
|
||||||
sx={{
|
sx={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
p: `${thickness}px`,
|
border: `1px solid ${d3roPalette.border.default}`,
|
||||||
background: `conic-gradient(from 210deg, ${c.from}, ${c.to}, ${c.from})`,
|
bgcolor: d3roPalette.bg.inset,
|
||||||
boxShadow: `0 0 ${size / 3.5}px ${c.glow}`,
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: color === 'accent' ? d3roPalette.accent.light : d3roPalette.text.secondary,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{children}
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: d3roPalette.bg.crtGlass,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: c.icon,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,37 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// packages/ui/src/components/ds/TactileBadge.tsx
|
// packages/ui/src/components/ds/TactileBadge.tsx
|
||||||
// Hardware-grade telemetry badge / pill chip with optional LED bead and keycap rendering
|
// v4 "조용한 계기": 작은 상태 표지. 채움은 옅게, 글자는 상태 역할색(테마 반응, 대비 유지).
|
||||||
|
// 한글이 들어가므로 자간 0, 웨이트 500. mono 는 라틴 수치·키 이름에만.
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Box, type BoxProps } from '@mui/material'
|
import { Box, type BoxProps } from '@mui/material'
|
||||||
import { Led } from './Led'
|
import { Led } from './Led'
|
||||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, typoSx } from '../../theme'
|
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTransition, d3roFocusRing } from '../../theme'
|
||||||
|
|
||||||
export interface TactileBadgeProps extends Omit<BoxProps, 'component'> {
|
export interface TactileBadgeProps extends Omit<BoxProps, 'component'> {
|
||||||
/** Optional LED indicator color */
|
/** 앞에 붙는 상태 점 */
|
||||||
ledColor?: 'green' | 'amber' | 'red' | 'blue' | 'orange' | 'purple' | 'off'
|
ledColor?: 'green' | 'amber' | 'red' | 'blue' | 'orange' | 'purple' | 'off'
|
||||||
/** Whether the LED should pulse */
|
/** 상태 점 깜빡임 (감소 모션에서는 멈춘다) */
|
||||||
ledPulse?: boolean
|
ledPulse?: boolean
|
||||||
/** Semantic tone for border and background */
|
/** 의미 톤 */
|
||||||
tone?: 'default' | 'accent' | 'success' | 'warning' | 'error' | 'mono'
|
tone?: 'default' | 'accent' | 'success' | 'warning' | 'error' | 'mono'
|
||||||
/** Whether to use monospace font */
|
/** 라틴 수치·키 이름일 때만 */
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
/** Interactive clickable chip */
|
/** 누를 수 있는 칩 */
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||||
/** Content to display */
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TONES = {
|
||||||
|
default: { color: d3roPalette.text.secondary, bg: 'transparent', border: d3roPalette.border.default },
|
||||||
|
accent: { color: d3roPalette.accent.light, bg: d3roPalette.accent.dim, border: 'transparent' },
|
||||||
|
success: { color: d3roPalette.status.success, bg: d3roPalette.status.successBg, border: 'transparent' },
|
||||||
|
warning: { color: d3roPalette.status.warning, bg: d3roPalette.status.warningBg, border: 'transparent' },
|
||||||
|
error: { color: d3roPalette.status.danger, bg: d3roPalette.status.dangerBg, border: 'transparent' },
|
||||||
|
mono: { color: d3roPalette.text.primary, bg: d3roPalette.bg.inset, border: d3roPalette.border.default },
|
||||||
|
} as const
|
||||||
|
|
||||||
export function TactileBadge({
|
export function TactileBadge({
|
||||||
ledColor,
|
ledColor,
|
||||||
ledPulse = false,
|
ledPulse = false,
|
||||||
|
|
@ -34,73 +43,47 @@ export function TactileBadge({
|
||||||
...boxProps
|
...boxProps
|
||||||
}: TactileBadgeProps): React.ReactElement {
|
}: TactileBadgeProps): React.ReactElement {
|
||||||
const isClickable = Boolean(onClick)
|
const isClickable = Boolean(onClick)
|
||||||
|
const t = TONES[tone]
|
||||||
let textColor: string = d3roPalette.text.secondary
|
|
||||||
let borderColor: string = d3roPalette.glass.hairlineStrong
|
|
||||||
let bgColor: string = d3roPalette.glass.raised
|
|
||||||
|
|
||||||
switch (tone) {
|
|
||||||
case 'accent':
|
|
||||||
textColor = d3roPalette.accent.light
|
|
||||||
borderColor = d3roPalette.accent.dim
|
|
||||||
bgColor = d3roPalette.accent.dim
|
|
||||||
break
|
|
||||||
case 'success':
|
|
||||||
textColor = d3roPalette.tag.green
|
|
||||||
borderColor = d3roPalette.tag.greenBg
|
|
||||||
bgColor = d3roPalette.tag.greenBg
|
|
||||||
break
|
|
||||||
case 'warning':
|
|
||||||
textColor = d3roPalette.tag.orange
|
|
||||||
borderColor = d3roPalette.tag.orangeBg
|
|
||||||
bgColor = d3roPalette.tag.orangeBg
|
|
||||||
break
|
|
||||||
case 'error':
|
|
||||||
textColor = d3roPalette.tag.red
|
|
||||||
borderColor = d3roPalette.tag.redBg
|
|
||||||
bgColor = d3roPalette.tag.redBg
|
|
||||||
break
|
|
||||||
case 'mono':
|
|
||||||
textColor = d3roPalette.text.primary
|
|
||||||
borderColor = d3roPalette.border.default
|
|
||||||
bgColor = d3roPalette.bg.inset
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
{...boxProps}
|
{...boxProps}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
{...(isClickable
|
||||||
|
? {
|
||||||
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.click()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.85,
|
gap: 0.75,
|
||||||
px: 1.25,
|
px: 1,
|
||||||
py: 0.45,
|
height: 22,
|
||||||
borderRadius: d3roRadius.badge,
|
borderRadius: d3roRadius.small,
|
||||||
border: `1px solid ${borderColor}`,
|
border: `1px solid ${t.border}`,
|
||||||
bgcolor: bgColor,
|
bgcolor: t.bg,
|
||||||
color: textColor,
|
color: t.color,
|
||||||
...typoSx('meta'),
|
|
||||||
fontFamily: mono ? d3roFontMono : d3roFontSans,
|
fontFamily: mono ? d3roFontMono : d3roFontSans,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.04em',
|
lineHeight: 1,
|
||||||
textTransform: 'none',
|
letterSpacing: 0,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
cursor: isClickable ? 'pointer' : 'default',
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
transition: 'all 0.15s cubic-bezier(0.16, 1, 0.3, 1)',
|
transition: d3roTransition('border-color', 'color'),
|
||||||
...(isClickable
|
...(isClickable
|
||||||
? {
|
? {
|
||||||
'&:hover': {
|
'&:hover': { borderColor: d3roPalette.border.strong, color: d3roPalette.text.primary },
|
||||||
borderColor: d3roPalette.accent.main,
|
'&:focus-visible': d3roFocusRing,
|
||||||
color: d3roPalette.text.primary,
|
|
||||||
filter: 'brightness(1.15)',
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
transform: 'translateY(0) scale(0.97)',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...sx,
|
...sx,
|
||||||
|
|
|
||||||
|
|
@ -1,175 +1,20 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// packages/ui/src/components/ds/TiltCard.tsx
|
// packages/ui/src/components/ds/TiltCard.tsx
|
||||||
// v2 "Midnight Glass": 3D 틸트 + 커서 추종 빛 반사(specular highlight) 카드.
|
// v4 "조용한 계기": 3D 틸트·커서 반사를 걷어냈다. 이름과 props 는 호환용 — 평평한 MetalCard 로 렌더한다.
|
||||||
// MetalCard의 글래스 표면(동일 토큰)에 마우스 추적 3D 기울임 + 커서 위치에서
|
// 근거: 하루 종일 켜 두는 도구에서 커서를 따라 기우는 카드는 정보가 아니라 소음이다(design.md 2026-09-28).
|
||||||
// 빛이 반사되는 듯한 스페큘러 하이라이트를 더한다. 라이브러리 없이 네이티브
|
|
||||||
// pointer 이벤트 + CSS 변수로 구현(리렌더 0). prefers-reduced-motion 완전 존중.
|
|
||||||
//
|
|
||||||
// 구조: 루트 Box(transform/perspective/표면/blur/링/시인/overflow) +
|
|
||||||
// ::before(앰비언트 시인) + ::after(그라디언트 헤어라인 링) +
|
|
||||||
// 자식 span.d3-tilt-spotlight(커서 추종 스페큘러, z1) + 자식 콘텐츠(z2).
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { type BoxProps } from '@mui/material'
|
||||||
import { Box, type BoxProps } from '@mui/material'
|
import { MetalCard } from './MetalCard'
|
||||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
|
||||||
|
|
||||||
interface TiltCardProps extends Omit<BoxProps, 'component'> {
|
interface TiltCardProps extends Omit<BoxProps, 'component'> {
|
||||||
/** 최대 기울기 각도(deg). 히어로 8, 작은 타일 6 권장. */
|
/** @deprecated v4: 틸트 없음 */
|
||||||
maxTilt?: number
|
maxTilt?: number
|
||||||
/** 커서 추종 스페큘러 하이라이트(빛 반사) 표시 여부. */
|
/** @deprecated v4: 반사 없음 */
|
||||||
spotlight?: boolean
|
spotlight?: boolean
|
||||||
/** 카드 콘텐츠 */
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function TiltCard({ maxTilt: _maxTilt, spotlight: _spotlight, children, ...rest }: TiltCardProps): React.ReactElement {
|
||||||
* 3D 틸트 + 스페큘러 상호작용 엔진.
|
return <MetalCard {...rest}>{children}</MetalCard>
|
||||||
* React state를 쓰지 않고 ref의 CSS 변수를 직접 갱신해 60fps 합성.
|
|
||||||
* prefers-reduced-motion 시 틸트/추종을 모두 비활성화한다.
|
|
||||||
*/
|
|
||||||
function useTiltCard(maxTilt: number) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
const reduceRef = useRef(false)
|
|
||||||
|
|
||||||
// OS 설정 변경(런타임 토글)에도 즉시 반응하도록 구독.
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined' || !('matchMedia' in window)) return
|
|
||||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
||||||
reduceRef.current = mq.matches
|
|
||||||
const handler = (e: MediaQueryListEvent): void => {
|
|
||||||
reduceRef.current = e.matches
|
|
||||||
}
|
|
||||||
mq.addEventListener('change', handler)
|
|
||||||
return () => mq.removeEventListener('change', handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
|
||||||
(e: React.PointerEvent<HTMLDivElement>): void => {
|
|
||||||
if (reduceRef.current) return
|
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const px = (e.clientX - rect.left) / rect.width // 0..1
|
|
||||||
const py = (e.clientY - rect.top) / rect.height // 0..1
|
|
||||||
// rotateY: 좌우 / rotateX: 상하(반전 → 커서 쪽으로 기울임)
|
|
||||||
el.style.setProperty('--d3-tilt-ry', `${((px - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
|
|
||||||
el.style.setProperty('--d3-tilt-rx', `${(-(py - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
|
|
||||||
el.style.setProperty('--d3-tilt-mx', `${(px * 100).toFixed(1)}%`)
|
|
||||||
el.style.setProperty('--d3-tilt-my', `${(py * 100).toFixed(1)}%`)
|
|
||||||
el.setAttribute('data-tilting', 'true')
|
|
||||||
},
|
|
||||||
[maxTilt],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPointerLeave = useCallback((): void => {
|
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
// 변수를 중립으로 리셋한 뒤 data 속성 제거 → 복귀 트랜지션 동작.
|
|
||||||
// (리셋하지 않으면 마지막 각도에 고정됨)
|
|
||||||
el.style.setProperty('--d3-tilt-rx', '0deg')
|
|
||||||
el.style.setProperty('--d3-tilt-ry', '0deg')
|
|
||||||
el.style.setProperty('--d3-tilt-mx', '50%')
|
|
||||||
el.style.setProperty('--d3-tilt-my', '50%')
|
|
||||||
el.removeAttribute('data-tilting')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { ref, onPointerMove, onPointerLeave }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TiltCard({
|
|
||||||
children,
|
|
||||||
maxTilt = 8,
|
|
||||||
spotlight = true,
|
|
||||||
onPointerMove,
|
|
||||||
onPointerLeave,
|
|
||||||
sx,
|
|
||||||
...boxProps
|
|
||||||
}: TiltCardProps): React.ReactElement {
|
|
||||||
const tilt = useTiltCard(maxTilt)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={tilt.ref}
|
|
||||||
{...boxProps}
|
|
||||||
onPointerMove={(e) => {
|
|
||||||
onPointerMove?.(e)
|
|
||||||
tilt.onPointerMove(e)
|
|
||||||
}}
|
|
||||||
onPointerLeave={(e) => {
|
|
||||||
onPointerLeave?.(e)
|
|
||||||
tilt.onPointerLeave()
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
bgcolor: d3roPalette.glass.surface,
|
|
||||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
|
||||||
borderRadius: d3roRadius.card,
|
|
||||||
boxShadow: d3roShadow.glowCard,
|
|
||||||
p: 3,
|
|
||||||
overflow: 'hidden',
|
|
||||||
// perspective() 단일 요소 틸트. 항상 non-none → 안정적 스태킹 컨텍스트.
|
|
||||||
transform:
|
|
||||||
'perspective(900px) rotateX(var(--d3-tilt-rx,0deg)) rotateY(var(--d3-tilt-ry,0deg))',
|
|
||||||
transition: 'transform 0.5s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s ease',
|
|
||||||
// 앰비언트 시인 (정적) — MetalCard와 동일
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
background: d3roPalette.glass.sheen,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
// 그라디언트 헤어라인 링 (mask 링 기법) — MetalCard와 동일
|
|
||||||
'&::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
padding: '1px',
|
|
||||||
background: d3roPalette.glass.borderGradient,
|
|
||||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
|
||||||
WebkitMaskComposite: 'xor',
|
|
||||||
maskComposite: 'exclude',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&:hover': { boxShadow: d3roShadow.glowCardHover },
|
|
||||||
// 트래킹 중: transform 트랜지션 제거(즉시 반응) + 합성 레이어 승격
|
|
||||||
'&[data-tilting="true"]': {
|
|
||||||
transition: 'box-shadow 0.25s ease',
|
|
||||||
willChange: 'transform',
|
|
||||||
},
|
|
||||||
// 스페큘러 reveal (Emotion이 plain class를 리라이트하지 않으므로 리터럴 className 사용)
|
|
||||||
'&:hover .d3-tilt-spotlight': { opacity: 1 },
|
|
||||||
// reduced-motion: 틸트·트랜지션·추종 모두 정지
|
|
||||||
'@media (prefers-reduced-motion: reduce)': {
|
|
||||||
transition: 'none',
|
|
||||||
transform: 'none',
|
|
||||||
'& .d3-tilt-spotlight': { transition: 'none' },
|
|
||||||
},
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{spotlight && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
className="d3-tilt-spotlight"
|
|
||||||
aria-hidden
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.25s ease',
|
|
||||||
mixBlendMode: 'screen',
|
|
||||||
background: `radial-gradient(circle 200px at var(--d3-tilt-mx,50%) var(--d3-tilt-my,50%), ${d3roPalette.glass.specular}, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Box sx={{ position: 'relative', zIndex: 2 }}>{children}</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
448
packages/ui/src/theme-raw.ts
Normal file
448
packages/ui/src/theme-raw.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
// packages/ui/src/theme-raw.ts
|
||||||
|
// D3RO 디자인 토큰 원시값 SSOT — v4 "조용한 계기" (2026-09-28).
|
||||||
|
// MUI·DOM 의존이 없다. 렌더러 theme.ts 와 팝업/main 의 theme-vars.ts 가 모두 여기서 값을 받는다.
|
||||||
|
//
|
||||||
|
// v4 원칙 (루트 design.md "2026-09-28 전면 개편"):
|
||||||
|
// - 표면은 평평하다. 위계는 크기·명도·1px 선으로 만든다. 글래스·글로우·그라디언트는 쓰지 않는다.
|
||||||
|
// - 테마마다 적는 것은 기본값(표면·텍스트 3단·경계·액센트)뿐이다. v2 의 glass/gradient/glow 키는
|
||||||
|
// 하위 호환을 위해 남기되 deriveRaw() 가 평면 값으로 파생한다 — 컴포넌트 수백 곳을 고치지 않고 이펙트를 끈다.
|
||||||
|
// - 텍스트 3단(primary/secondary/tertiary)은 모든 표면(app/card/elevated/sidebar)에서 4.5:1 이상.
|
||||||
|
// v2 의 label/inactive/dimLabel/muted 는 tertiary 의 별칭이다. disabled 만 대비 예외.
|
||||||
|
// - accent.solid 는 채운 버튼 배경, accent.ink 는 그 위 글자 — 두 값의 대비 4.5:1 이상.
|
||||||
|
|
||||||
|
export type ThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula'
|
||||||
|
|
||||||
|
/** 테마가 직접 정하는 값. 이것만 테마별로 적는다. */
|
||||||
|
interface ThemeBase {
|
||||||
|
bg: { app: string; card: string; cardHover: string; elevated: string; input: string; sidebar: string; inset: string }
|
||||||
|
text: { primary: string; secondary: string; tertiary: string; disabled: string; hover: string }
|
||||||
|
border: { subtle: string; default: string; strong: string }
|
||||||
|
accent: {
|
||||||
|
main: string
|
||||||
|
/** 어두운 표면 위 강조 글자 (라이트 테마에서는 더 진한 값) */
|
||||||
|
light: string
|
||||||
|
dark: string
|
||||||
|
dim: string
|
||||||
|
/** 채운 버튼 배경 — ink 와 4.5:1 이상 */
|
||||||
|
solid: string
|
||||||
|
solidHover: string
|
||||||
|
ink: string
|
||||||
|
}
|
||||||
|
elevation: { overlay: string; tooltip: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v2 호환 전체 구조 — CSS 변수 이름이 이 구조에서 나온다. */
|
||||||
|
export interface RawTheme {
|
||||||
|
bg: ThemeBase['bg'] & { chassis: string; crtBezel: string; crtGlass: string }
|
||||||
|
text: {
|
||||||
|
primary: string
|
||||||
|
secondary: string
|
||||||
|
tertiary: string
|
||||||
|
label: string
|
||||||
|
disabled: string
|
||||||
|
engraving: string
|
||||||
|
inactive: string
|
||||||
|
dimLabel: string
|
||||||
|
muted: string
|
||||||
|
hover: string
|
||||||
|
}
|
||||||
|
border: ThemeBase['border']
|
||||||
|
shadow: {
|
||||||
|
card: string
|
||||||
|
buttonBase: string
|
||||||
|
chassis: string
|
||||||
|
inset: string
|
||||||
|
insetDeep: string
|
||||||
|
buttonRaised: string
|
||||||
|
buttonPressed: string
|
||||||
|
screenGlow: string
|
||||||
|
tooltip: string
|
||||||
|
overlay: string
|
||||||
|
}
|
||||||
|
accent: ThemeBase['accent'] & { glow: string; crtPhosphor: string; crtPhosphorDim: string }
|
||||||
|
glass: {
|
||||||
|
surface: string
|
||||||
|
raised: string
|
||||||
|
hairline: string
|
||||||
|
hairlineStrong: string
|
||||||
|
sheen: string
|
||||||
|
borderGradient: string
|
||||||
|
blur: string
|
||||||
|
specular: string
|
||||||
|
}
|
||||||
|
gradient: {
|
||||||
|
accent: string
|
||||||
|
logo: string
|
||||||
|
bar: string
|
||||||
|
barAlt: string
|
||||||
|
wave1: string
|
||||||
|
wave2: string
|
||||||
|
wave3: string
|
||||||
|
wave4: string
|
||||||
|
}
|
||||||
|
glow: { accent: string; soft: string; card: string; cardHover: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const solidFill = (color: string): string => `linear-gradient(${color}, ${color})`
|
||||||
|
|
||||||
|
/** 기본값 → v2 호환 구조. 이펙트 키는 전부 평면 값으로 떨어진다. */
|
||||||
|
function deriveRaw(b: ThemeBase): RawTheme {
|
||||||
|
const none = 'none'
|
||||||
|
return {
|
||||||
|
bg: { ...b.bg, chassis: b.bg.card, crtBezel: b.bg.inset, crtGlass: b.bg.inset },
|
||||||
|
text: {
|
||||||
|
primary: b.text.primary,
|
||||||
|
secondary: b.text.secondary,
|
||||||
|
tertiary: b.text.tertiary,
|
||||||
|
label: b.text.tertiary,
|
||||||
|
inactive: b.text.tertiary,
|
||||||
|
dimLabel: b.text.tertiary,
|
||||||
|
muted: b.text.tertiary,
|
||||||
|
disabled: b.text.disabled,
|
||||||
|
engraving: b.bg.app,
|
||||||
|
hover: b.text.hover,
|
||||||
|
},
|
||||||
|
border: b.border,
|
||||||
|
shadow: {
|
||||||
|
card: none,
|
||||||
|
buttonBase: none,
|
||||||
|
chassis: none,
|
||||||
|
inset: none,
|
||||||
|
insetDeep: none,
|
||||||
|
buttonRaised: none,
|
||||||
|
buttonPressed: none,
|
||||||
|
screenGlow: none,
|
||||||
|
tooltip: b.elevation.tooltip,
|
||||||
|
overlay: b.elevation.overlay,
|
||||||
|
},
|
||||||
|
accent: { ...b.accent, glow: b.accent.dim, crtPhosphor: b.accent.light, crtPhosphorDim: b.accent.dark },
|
||||||
|
glass: {
|
||||||
|
surface: b.bg.card,
|
||||||
|
raised: b.bg.elevated,
|
||||||
|
hairline: b.border.default,
|
||||||
|
hairlineStrong: b.border.strong,
|
||||||
|
sheen: none,
|
||||||
|
borderGradient: solidFill(b.border.default),
|
||||||
|
blur: '0px',
|
||||||
|
specular: 'transparent',
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
accent: solidFill(b.accent.solid),
|
||||||
|
logo: solidFill(b.text.primary),
|
||||||
|
bar: solidFill(b.accent.main),
|
||||||
|
barAlt: solidFill(b.accent.light),
|
||||||
|
wave1: b.accent.light,
|
||||||
|
wave2: b.accent.main,
|
||||||
|
wave3: b.accent.main,
|
||||||
|
wave4: b.accent.light,
|
||||||
|
},
|
||||||
|
glow: { accent: none, soft: none, card: none, cardHover: none },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE: Record<ThemeKey, ThemeBase> = {
|
||||||
|
dark: {
|
||||||
|
bg: { app: '#0a0e1c', card: '#111a30', cardHover: '#152039', elevated: '#1a2540', input: '#0d1526', sidebar: '#080b17', inset: '#0a111f' },
|
||||||
|
text: { primary: '#eef2fb', secondary: '#93a4c8', tertiary: '#7e8eb0', disabled: '#3c4763', hover: '#c9d5f2' },
|
||||||
|
border: { subtle: 'rgba(148,180,255,0.07)', default: 'rgba(148,180,255,0.12)', strong: 'rgba(148,180,255,0.20)' },
|
||||||
|
accent: { main: '#3b82f6', light: '#60a5fa', dark: '#2563eb', dim: 'rgba(59,130,246,0.14)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(3,7,18,0.55)', tooltip: '0 8px 24px rgba(3,7,18,0.5)' },
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bg: { app: '#f2f5fb', card: '#ffffff', cardHover: '#f7f9fd', elevated: '#eef2f9', input: '#ffffff', sidebar: '#e9eef7', inset: '#e4e9f2' },
|
||||||
|
text: { primary: '#111827', secondary: '#4b5a75', tertiary: '#56647c', disabled: '#b5bfd1', hover: '#2b3a55' },
|
||||||
|
border: { subtle: 'rgba(30,58,138,0.07)', default: 'rgba(30,58,138,0.12)', strong: 'rgba(30,58,138,0.22)' },
|
||||||
|
accent: { main: '#2563eb', light: '#1d4ed8', dark: '#1e40af', dim: 'rgba(37,99,235,0.10)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(30,58,138,0.14)', tooltip: '0 8px 24px rgba(30,58,138,0.10)' },
|
||||||
|
},
|
||||||
|
// https://www.nordtheme.com/
|
||||||
|
nord: {
|
||||||
|
bg: { app: '#242933', card: '#2e3440', cardHover: '#343b49', elevated: '#3b4252', input: '#2a303c', sidebar: '#20242d', inset: '#232830' },
|
||||||
|
text: { primary: '#eceff4', secondary: '#c2cad8', tertiary: '#a6b0c4', disabled: '#4c566a', hover: '#e5e9f0' },
|
||||||
|
border: { subtle: 'rgba(216,222,233,0.06)', default: 'rgba(216,222,233,0.11)', strong: 'rgba(216,222,233,0.18)' },
|
||||||
|
accent: { main: '#88c0d0', light: '#a3d0de', dark: '#6aacbe', dim: 'rgba(136,192,208,0.14)', solid: '#88c0d0', solidHover: '#9ccbd8', ink: '#2e3440' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(10,13,18,0.5)', tooltip: '0 8px 24px rgba(10,13,18,0.45)' },
|
||||||
|
},
|
||||||
|
// https://ethanschoonover.com/solarized/
|
||||||
|
solarized: {
|
||||||
|
bg: { app: '#00212b', card: '#073642', cardHover: '#0a3e4c', elevated: '#0d4250', input: '#03303c', sidebar: '#001c25', inset: '#001f28' },
|
||||||
|
text: { primary: '#eee8d5', secondary: '#b7bdb6', tertiary: '#a1aca9', disabled: '#586e75', hover: '#d6d9cf' },
|
||||||
|
border: { subtle: 'rgba(131,148,150,0.08)', default: 'rgba(131,148,150,0.14)', strong: 'rgba(131,148,150,0.22)' },
|
||||||
|
accent: { main: '#b58900', light: '#d4a017', dark: '#8a6800', dim: 'rgba(181,137,0,0.14)', solid: '#8a6800', solidHover: '#7a5c00', ink: '#ffffff' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(0,10,13,0.55)', tooltip: '0 8px 24px rgba(0,10,13,0.5)' },
|
||||||
|
},
|
||||||
|
// https://catppuccin.com/ (Mocha)
|
||||||
|
catppuccin: {
|
||||||
|
bg: { app: '#181825', card: '#1e1e2e', cardHover: '#242438', elevated: '#313244', input: '#191926', sidebar: '#14141f', inset: '#11111b' },
|
||||||
|
text: { primary: '#cdd6f4', secondary: '#a8b2d8', tertiary: '#939cbf', disabled: '#585b70', hover: '#e6e9f8' },
|
||||||
|
border: { subtle: 'rgba(205,214,244,0.05)', default: 'rgba(205,214,244,0.10)', strong: 'rgba(205,214,244,0.17)' },
|
||||||
|
accent: { main: '#cba6f7', light: '#dfc0ff', dark: '#a67fd4', dim: 'rgba(203,166,247,0.14)', solid: '#cba6f7', solidHover: '#d7b9fa', ink: '#11111b' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(5,5,12,0.55)', tooltip: '0 8px 24px rgba(5,5,12,0.5)' },
|
||||||
|
},
|
||||||
|
// https://draculatheme.com/
|
||||||
|
dracula: {
|
||||||
|
bg: { app: '#1e2029', card: '#282a36', cardHover: '#2e3040', elevated: '#383a4a', input: '#232530', sidebar: '#1a1c24', inset: '#1b1c25' },
|
||||||
|
text: { primary: '#f8f8f2', secondary: '#b6bdd9', tertiary: '#a1a7c1', disabled: '#6272a4', hover: '#ffffff' },
|
||||||
|
border: { subtle: 'rgba(248,248,242,0.05)', default: 'rgba(248,248,242,0.10)', strong: 'rgba(248,248,242,0.17)' },
|
||||||
|
accent: { main: '#bd93f9', light: '#d4b8ff', dark: '#9a70e0', dim: 'rgba(189,147,249,0.14)', solid: '#bd93f9', solidHover: '#caa6fa', ink: '#1e2029' },
|
||||||
|
elevation: { overlay: '0 12px 32px rgba(8,9,13,0.55)', tooltip: '0 8px 24px rgba(8,9,13,0.5)' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RAW: Record<ThemeKey, RawTheme> = {
|
||||||
|
dark: deriveRaw(BASE.dark),
|
||||||
|
light: deriveRaw(BASE.light),
|
||||||
|
nord: deriveRaw(BASE.nord),
|
||||||
|
solarized: deriveRaw(BASE.solarized),
|
||||||
|
catppuccin: deriveRaw(BASE.catppuccin),
|
||||||
|
dracula: deriveRaw(BASE.dracula),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_KEYS = Object.keys(RAW) as ThemeKey[]
|
||||||
|
|
||||||
|
export function isThemeKey(value: string): value is ThemeKey {
|
||||||
|
return (THEME_KEYS as string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 시맨틱 상태 역할 (액센트와 분리 — 기능 우선) ─────────────
|
||||||
|
// 다크는 밝은 상태색, 라이트는 어두운 상태색. 텍스트로 쓸 수 있는 대비를 유지한다.
|
||||||
|
export interface StatusRole {
|
||||||
|
success: string
|
||||||
|
successBg: string
|
||||||
|
warning: string
|
||||||
|
warningBg: string
|
||||||
|
danger: string
|
||||||
|
dangerBg: string
|
||||||
|
info: string
|
||||||
|
infoBg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS: Record<ThemeKey, StatusRole> = {
|
||||||
|
dark: {
|
||||||
|
success: '#34d399', successBg: 'rgba(52,211,153,0.12)',
|
||||||
|
warning: '#fbbf24', warningBg: 'rgba(251,191,36,0.12)',
|
||||||
|
danger: '#f87171', dangerBg: 'rgba(248,113,113,0.12)',
|
||||||
|
info: '#60a5fa', infoBg: 'rgba(96,165,250,0.12)',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
success: '#047857', successBg: 'rgba(4,120,87,0.10)',
|
||||||
|
warning: '#b45309', warningBg: 'rgba(180,83,9,0.10)',
|
||||||
|
danger: '#b91c1c', dangerBg: 'rgba(185,28,28,0.10)',
|
||||||
|
info: '#1d4ed8', infoBg: 'rgba(29,78,216,0.10)',
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
success: '#a3be8c', successBg: 'rgba(163,190,140,0.14)',
|
||||||
|
warning: '#ebcb8b', warningBg: 'rgba(235,203,139,0.14)',
|
||||||
|
danger: '#d08770', dangerBg: 'rgba(208,135,112,0.14)',
|
||||||
|
info: '#88c0d0', infoBg: 'rgba(136,192,208,0.14)',
|
||||||
|
},
|
||||||
|
solarized: {
|
||||||
|
success: '#a4b82a', successBg: 'rgba(133,153,0,0.16)',
|
||||||
|
warning: '#d4a017', warningBg: 'rgba(181,137,0,0.16)',
|
||||||
|
danger: '#ef6461', dangerBg: 'rgba(220,50,47,0.16)',
|
||||||
|
info: '#4ea3de', infoBg: 'rgba(38,139,210,0.16)',
|
||||||
|
},
|
||||||
|
catppuccin: {
|
||||||
|
success: '#a6e3a1', successBg: 'rgba(166,227,161,0.14)',
|
||||||
|
warning: '#f9e2af', warningBg: 'rgba(249,226,175,0.14)',
|
||||||
|
danger: '#f38ba8', dangerBg: 'rgba(243,139,168,0.14)',
|
||||||
|
info: '#89b4fa', infoBg: 'rgba(137,180,250,0.14)',
|
||||||
|
},
|
||||||
|
dracula: {
|
||||||
|
success: '#50fa7b', successBg: 'rgba(80,250,123,0.14)',
|
||||||
|
warning: '#f1fa8c', warningBg: 'rgba(241,250,140,0.14)',
|
||||||
|
danger: '#ff6e6e', dangerBg: 'rgba(255,85,85,0.14)',
|
||||||
|
info: '#8be9fd', infoBg: 'rgba(139,233,253,0.14)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 테마 불변 태그색 (카테고리 구분용 — 텍스트 대비를 보장하지 않는다) ──
|
||||||
|
// 상태를 말할 때는 STATUS 를 쓴다. tag 는 아이콘·점·범례처럼 글자가 아닌 곳에만.
|
||||||
|
export const TAG = {
|
||||||
|
purple: '#8b5cf6',
|
||||||
|
purpleBg: 'rgba(139, 92, 246, 0.12)',
|
||||||
|
orange: '#f59e0b',
|
||||||
|
orangeBg: 'rgba(245, 158, 11, 0.12)',
|
||||||
|
red: '#ef4444',
|
||||||
|
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
green: '#10b981',
|
||||||
|
greenBg: 'rgba(16, 185, 129, 0.12)',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
blueBg: 'rgba(59, 130, 246, 0.12)',
|
||||||
|
greenGlow: 'rgba(16, 185, 129, 0.35)',
|
||||||
|
redGlow: 'rgba(239, 68, 68, 0.35)',
|
||||||
|
orangeGlow: 'rgba(245, 158, 11, 0.35)',
|
||||||
|
purpleGlow: 'rgba(139, 92, 246, 0.35)',
|
||||||
|
blueGlow: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
cyan: '#06b6d4',
|
||||||
|
cyanBg: 'rgba(6, 182, 212, 0.12)',
|
||||||
|
cyanGlow: 'rgba(6, 182, 212, 0.35)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ── 간격 리듬 (4px 그리드) ─────────────────────────────────
|
||||||
|
export const SPACE = { s1: '4px', s2: '8px', s3: '12px', s4: '16px', s5: '24px', s6: '40px', s7: '64px', s8: '96px' } as const
|
||||||
|
|
||||||
|
// ── 컨트롤 치수 (버튼처럼 생긴 것은 전부 같은 치수) ──────────
|
||||||
|
export const CONTROL = { h: '36px', hSm: '28px', padX: '14px', padXSm: '10px' } as const
|
||||||
|
|
||||||
|
// ── 웨이트는 세 개 ─────────────────────────────────────────
|
||||||
|
export const WEIGHT = { body: 400, medium: 500, strong: 600 } as const
|
||||||
|
|
||||||
|
// ── 모션 문법 — 상태 변화 피드백만. 등장 연출은 쓰지 않는다 ──
|
||||||
|
export const MOTION = {
|
||||||
|
instant: '100ms',
|
||||||
|
quick: '160ms',
|
||||||
|
normal: '240ms',
|
||||||
|
slow: '400ms',
|
||||||
|
easeOut: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
easeIn: 'cubic-bezier(0.64, 0, 0.78, 0)',
|
||||||
|
easeSoft: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ── 반경 — 정밀 도구: 작고 일정하게 ──────────────────────────
|
||||||
|
export const RADIUS = {
|
||||||
|
doubleBezelOuter: '12px',
|
||||||
|
doubleBezelInner: '10px',
|
||||||
|
outer: '12px',
|
||||||
|
card: '10px',
|
||||||
|
inner: '8px',
|
||||||
|
button: '8px',
|
||||||
|
small: '6px',
|
||||||
|
xs: '4px',
|
||||||
|
badge: '999px',
|
||||||
|
pill: '999px',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 한글 우선 폰트 스택 — Pretendard(한/영 조화) → 플랫폼 한글 시스템 폰트 폴백.
|
||||||
|
export const FONT_SANS = [
|
||||||
|
'"Pretendard Variable"', 'Pretendard', '-apple-system', 'BlinkMacSystemFont',
|
||||||
|
'"Apple SD Gothic Neo"', '"Noto Sans KR"', '"Segoe UI"', 'Roboto',
|
||||||
|
'"Helvetica Neue"', 'Arial', '"Malgun Gothic"', 'sans-serif',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
/** 숫자·코드·키 이름(라틴) 전용. 한글이 섞일 수 있는 문자열에는 쓰지 않는다 — 한글 글리프가 없어 자간이 벌어진다. */
|
||||||
|
export const FONT_MONO = [
|
||||||
|
'ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Menlo', 'Consolas',
|
||||||
|
'"Liberation Mono"', 'monospace',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
// ── CSS Custom Properties ────────────────────────────────────
|
||||||
|
export function buildCssVars(key: ThemeKey): Record<string, string> {
|
||||||
|
const r = RAW[key]
|
||||||
|
const st = STATUS[key]
|
||||||
|
return {
|
||||||
|
'--d3-bg-app': r.bg.app,
|
||||||
|
'--d3-bg-card': r.bg.card,
|
||||||
|
'--d3-bg-cardHover': r.bg.cardHover,
|
||||||
|
'--d3-bg-elevated': r.bg.elevated,
|
||||||
|
'--d3-bg-input': r.bg.input,
|
||||||
|
'--d3-bg-sidebar': r.bg.sidebar,
|
||||||
|
'--d3-bg-inset': r.bg.inset,
|
||||||
|
'--d3-bg-chassis': r.bg.chassis,
|
||||||
|
'--d3-bg-crtBezel': r.bg.crtBezel,
|
||||||
|
'--d3-bg-crtGlass': r.bg.crtGlass,
|
||||||
|
'--d3-text-primary': r.text.primary,
|
||||||
|
'--d3-text-secondary': r.text.secondary,
|
||||||
|
'--d3-text-tertiary': r.text.tertiary,
|
||||||
|
'--d3-text-label': r.text.label,
|
||||||
|
'--d3-text-disabled': r.text.disabled,
|
||||||
|
'--d3-text-engraving': r.text.engraving,
|
||||||
|
'--d3-text-inactive': r.text.inactive,
|
||||||
|
'--d3-text-dimLabel': r.text.dimLabel,
|
||||||
|
'--d3-text-muted': r.text.muted,
|
||||||
|
'--d3-text-hover': r.text.hover,
|
||||||
|
'--d3-border-subtle': r.border.subtle,
|
||||||
|
'--d3-border-default': r.border.default,
|
||||||
|
'--d3-border-strong': r.border.strong,
|
||||||
|
'--d3-shadow-card': r.shadow.card,
|
||||||
|
'--d3-shadow-buttonBase': r.shadow.buttonBase,
|
||||||
|
'--d3-shadow-chassis': r.shadow.chassis,
|
||||||
|
'--d3-shadow-inset': r.shadow.inset,
|
||||||
|
'--d3-shadow-insetDeep': r.shadow.insetDeep,
|
||||||
|
'--d3-shadow-buttonRaised': r.shadow.buttonRaised,
|
||||||
|
'--d3-shadow-buttonPressed': r.shadow.buttonPressed,
|
||||||
|
'--d3-shadow-screenGlow': r.shadow.screenGlow,
|
||||||
|
'--d3-shadow-tooltip': r.shadow.tooltip,
|
||||||
|
'--d3-shadow-overlay': r.shadow.overlay,
|
||||||
|
'--d3-accent-main': r.accent.main,
|
||||||
|
'--d3-accent-dim': r.accent.dim,
|
||||||
|
'--d3-accent-glow': r.accent.glow,
|
||||||
|
'--d3-accent-light': r.accent.light,
|
||||||
|
'--d3-accent-dark': r.accent.dark,
|
||||||
|
'--d3-accent-solid': r.accent.solid,
|
||||||
|
'--d3-accent-solidHover': r.accent.solidHover,
|
||||||
|
'--d3-accent-crtPhosphor': r.accent.crtPhosphor,
|
||||||
|
'--d3-accent-crtPhosphorDim': r.accent.crtPhosphorDim,
|
||||||
|
'--d3-accent-ink': r.accent.ink,
|
||||||
|
'--d3-glass-surface': r.glass.surface,
|
||||||
|
'--d3-glass-raised': r.glass.raised,
|
||||||
|
'--d3-glass-hairline': r.glass.hairline,
|
||||||
|
'--d3-glass-hairlineStrong': r.glass.hairlineStrong,
|
||||||
|
'--d3-glass-sheen': r.glass.sheen,
|
||||||
|
'--d3-glass-borderGradient': r.glass.borderGradient,
|
||||||
|
'--d3-glass-blur': r.glass.blur,
|
||||||
|
'--d3-glass-specular': r.glass.specular,
|
||||||
|
'--d3-gradient-accent': r.gradient.accent,
|
||||||
|
'--d3-gradient-logo': r.gradient.logo,
|
||||||
|
'--d3-gradient-bar': r.gradient.bar,
|
||||||
|
'--d3-gradient-barAlt': r.gradient.barAlt,
|
||||||
|
'--d3-gradient-wave1': r.gradient.wave1,
|
||||||
|
'--d3-gradient-wave2': r.gradient.wave2,
|
||||||
|
'--d3-gradient-wave3': r.gradient.wave3,
|
||||||
|
'--d3-gradient-wave4': r.gradient.wave4,
|
||||||
|
'--d3-glow-accent': r.glow.accent,
|
||||||
|
'--d3-glow-soft': r.glow.soft,
|
||||||
|
'--d3-glow-card': r.glow.card,
|
||||||
|
'--d3-glow-cardHover': r.glow.cardHover,
|
||||||
|
'--d3-status-success': st.success,
|
||||||
|
'--d3-status-successBg': st.successBg,
|
||||||
|
'--d3-status-warning': st.warning,
|
||||||
|
'--d3-status-warningBg': st.warningBg,
|
||||||
|
'--d3-status-danger': st.danger,
|
||||||
|
'--d3-status-dangerBg': st.dangerBg,
|
||||||
|
'--d3-status-info': st.info,
|
||||||
|
'--d3-status-infoBg': st.infoBg,
|
||||||
|
'--d3-space-1': SPACE.s1,
|
||||||
|
'--d3-space-2': SPACE.s2,
|
||||||
|
'--d3-space-3': SPACE.s3,
|
||||||
|
'--d3-space-4': SPACE.s4,
|
||||||
|
'--d3-space-5': SPACE.s5,
|
||||||
|
'--d3-space-6': SPACE.s6,
|
||||||
|
'--d3-space-7': SPACE.s7,
|
||||||
|
'--d3-space-8': SPACE.s8,
|
||||||
|
'--d3-control-h': CONTROL.h,
|
||||||
|
'--d3-control-h-sm': CONTROL.hSm,
|
||||||
|
'--d3-control-pad-x': CONTROL.padX,
|
||||||
|
'--d3-control-pad-x-sm': CONTROL.padXSm,
|
||||||
|
'--d3-weight-body': String(WEIGHT.body),
|
||||||
|
'--d3-weight-medium': String(WEIGHT.medium),
|
||||||
|
'--d3-weight-strong': String(WEIGHT.strong),
|
||||||
|
'--d3-dur-instant': MOTION.instant,
|
||||||
|
'--d3-dur-quick': MOTION.quick,
|
||||||
|
'--d3-dur-normal': MOTION.normal,
|
||||||
|
'--d3-dur-slow': MOTION.slow,
|
||||||
|
'--d3-ease-out': MOTION.easeOut,
|
||||||
|
'--d3-ease-in': MOTION.easeIn,
|
||||||
|
'--d3-ease-soft': MOTION.easeSoft,
|
||||||
|
'--d3-radius-card': RADIUS.card,
|
||||||
|
'--d3-radius-inner': RADIUS.inner,
|
||||||
|
'--d3-radius-button': RADIUS.button,
|
||||||
|
'--d3-radius-small': RADIUS.small,
|
||||||
|
'--d3-tag-purple': TAG.purple,
|
||||||
|
'--d3-tag-orange': TAG.orange,
|
||||||
|
'--d3-tag-red': TAG.red,
|
||||||
|
'--d3-tag-green': TAG.green,
|
||||||
|
'--d3-tag-blue': TAG.blue,
|
||||||
|
'--d3-tag-cyan': TAG.cyan,
|
||||||
|
'--d3-tag-cyanBg': TAG.cyanBg,
|
||||||
|
'--d3-bg-inset-soft': 'color-mix(in srgb, var(--d3-bg-inset) 70%, transparent)',
|
||||||
|
'--d3-bg-card-soft': 'color-mix(in srgb, var(--d3-bg-card) 70%, transparent)',
|
||||||
|
'--d3-bg-raised-soft': 'color-mix(in srgb, var(--d3-bg-elevated) 80%, transparent)',
|
||||||
|
'--d3-scrim': key === 'light' ? 'rgba(17,24,39,0.32)' : 'rgba(0,0,0,0.6)',
|
||||||
|
// 표면 위 옅은 오버레이(hover·선택) — 텍스트 색에서 파생해 라이트 테마에서도 보인다
|
||||||
|
'--d3-overlay-soft': 'color-mix(in srgb, var(--d3-text-primary) 6%, transparent)',
|
||||||
|
'--d3-overlay-strong': 'color-mix(in srgb, var(--d3-text-primary) 12%, transparent)',
|
||||||
|
'--d3-font-sans': FONT_SANS,
|
||||||
|
'--d3-font-mono': FONT_MONO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,283 +1,56 @@
|
||||||
// src/shared/theme-vars.ts
|
// packages/ui/src/theme-vars.ts
|
||||||
// 팝업 윈도우용 CSS 변수 맵.
|
// 팝업 윈도우용 CSS 변수 맵.
|
||||||
// main process (WindowManager)에서 insertCSS로 팝업에 테마를 주입할 때 사용.
|
// main process (WindowManager)에서 insertCSS로 팝업에 테마를 주입할 때 사용.
|
||||||
// renderer의 theme.ts RAW와 동기화 유지 필요.
|
// 값은 theme-raw.ts(렌더러와 같은 원시값)에서 파생한다 — 손으로 복제하지 않는다.
|
||||||
// Node.js/Electron main 환경에서 import 가능 (DOM API 없음).
|
// Node.js/Electron main 환경에서 import 가능 (MUI·DOM 의존 없음).
|
||||||
|
|
||||||
export type PopupThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula'
|
import { RAW, STATUS, buildCssVars, isThemeKey, type ThemeKey } from './theme-raw'
|
||||||
|
|
||||||
interface PopupThemeVars {
|
export type PopupThemeKey = ThemeKey
|
||||||
/** 카드 배경 (팝업 본체) */
|
|
||||||
'--d3-bg-card': string
|
|
||||||
/** 앱 배경 (recording-tip 반투명 배경) */
|
|
||||||
'--d3-bg-tip': string
|
|
||||||
/** 주요 텍스트 */
|
|
||||||
'--d3-text-primary': string
|
|
||||||
/** 보조 텍스트 */
|
|
||||||
'--d3-text-secondary': string
|
|
||||||
/** 비활성 텍스트 (번호, 시간 등) */
|
|
||||||
'--d3-text-inactive': string
|
|
||||||
/** 힌트/최약 텍스트 */
|
|
||||||
'--d3-text-muted': string
|
|
||||||
/** dimLabel 텍스트 (command-popup 제목) */
|
|
||||||
'--d3-text-dimLabel': string
|
|
||||||
/** 기본 테두리 */
|
|
||||||
'--d3-border-default': string
|
|
||||||
/** 미묘한 테두리 (구분선, hover) */
|
|
||||||
'--d3-border-subtle': string
|
|
||||||
/** 강한 테두리 (progress background) */
|
|
||||||
'--d3-border-strong': string
|
|
||||||
/** 악센트 색상 (웨이브바, 선택 바) */
|
|
||||||
'--d3-accent-main': string
|
|
||||||
/** 악센트 dim (active 배경) */
|
|
||||||
'--d3-accent-dim': string
|
|
||||||
/** 팝업 박스 섀도 */
|
|
||||||
'--d3-shadow-popup': string
|
|
||||||
/** 결과 팝업 배경 (불투명) */
|
|
||||||
'--d3-bg-result': string
|
|
||||||
/** 결과 팝업 테두리 */
|
|
||||||
'--d3-border-result': string
|
|
||||||
/** 결과 팝업 텍스트 */
|
|
||||||
'--d3-text-result': string
|
|
||||||
/** 결과 팝업 버튼 색상 */
|
|
||||||
'--d3-action-btn': string
|
|
||||||
/** 결과 팝업 버튼 hover 배경 */
|
|
||||||
'--d3-action-btn-hover-bg': string
|
|
||||||
/** 결과 팝업 버튼 hover 색상 */
|
|
||||||
'--d3-action-btn-hover': string
|
|
||||||
/** recording-tip 9단계 wave-bar 색상 (wave1~4 보간) */
|
|
||||||
'--d3-wave-1': string
|
|
||||||
'--d3-wave-2': string
|
|
||||||
'--d3-wave-3': string
|
|
||||||
'--d3-wave-4': string
|
|
||||||
'--d3-wave-5': string
|
|
||||||
'--d3-wave-6': string
|
|
||||||
'--d3-wave-7': string
|
|
||||||
'--d3-wave-8': string
|
|
||||||
'--d3-wave-9': string
|
|
||||||
/** 에러 상태 색상 (recording-tip error-icon) */
|
|
||||||
'--d3-status-error': string
|
|
||||||
/** 성공 상태 색상 (result-popup copied 상태) */
|
|
||||||
'--d3-status-success': string
|
|
||||||
}
|
|
||||||
|
|
||||||
const POPUP_THEME_VARS: Record<PopupThemeKey, PopupThemeVars> = {
|
|
||||||
dark: {
|
|
||||||
'--d3-bg-card': '#111a30',
|
|
||||||
'--d3-bg-tip': 'rgba(10,14,28,0.85)',
|
|
||||||
'--d3-text-primary': '#eef2fb',
|
|
||||||
'--d3-text-secondary': '#93a4c8',
|
|
||||||
'--d3-text-inactive': '#7385ab',
|
|
||||||
'--d3-text-muted': '#2b3550',
|
|
||||||
'--d3-text-dimLabel': '#4e5f85',
|
|
||||||
'--d3-border-default': 'rgba(148,180,255,0.12)',
|
|
||||||
'--d3-border-subtle': 'rgba(148,180,255,0.07)',
|
|
||||||
'--d3-border-strong': 'rgba(148,180,255,0.20)',
|
|
||||||
'--d3-accent-main': '#3b82f6',
|
|
||||||
'--d3-accent-dim': 'rgba(59,130,246,0.14)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(3,7,18,0.5)',
|
|
||||||
'--d3-bg-result': '#111a30',
|
|
||||||
'--d3-border-result': 'rgba(148,180,255,0.12)',
|
|
||||||
'--d3-text-result': '#eef2fb',
|
|
||||||
'--d3-action-btn': '#60a5fa',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(59,130,246,0.14)',
|
|
||||||
'--d3-action-btn-hover': '#93a4c8',
|
|
||||||
// wave-bar 9단계: theme.ts RAW.dark.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#93c5fd',
|
|
||||||
'--d3-wave-2': '#60a5fa',
|
|
||||||
'--d3-wave-3': '#4f8dfa',
|
|
||||||
'--d3-wave-4': '#3b82f6',
|
|
||||||
'--d3-wave-5': '#3b82f6',
|
|
||||||
'--d3-wave-6': '#5b75f7',
|
|
||||||
'--d3-wave-7': '#6e71f7',
|
|
||||||
'--d3-wave-8': '#818cf8',
|
|
||||||
'--d3-wave-9': '#a78bfa',
|
|
||||||
'--d3-status-error': '#ef4444',
|
|
||||||
'--d3-status-success': '#10b981',
|
|
||||||
},
|
|
||||||
light: {
|
|
||||||
'--d3-bg-card': '#ffffff',
|
|
||||||
'--d3-bg-tip': 'rgba(242,245,251,0.92)',
|
|
||||||
'--d3-text-primary': '#111827',
|
|
||||||
'--d3-text-secondary': '#4b5a75',
|
|
||||||
'--d3-text-inactive': '#8593ab',
|
|
||||||
'--d3-text-muted': '#c3cddd',
|
|
||||||
'--d3-text-dimLabel': '#5a6f96',
|
|
||||||
'--d3-border-default': 'rgba(30,58,138,0.12)',
|
|
||||||
'--d3-border-subtle': 'rgba(30,58,138,0.07)',
|
|
||||||
'--d3-border-strong': 'rgba(30,58,138,0.20)',
|
|
||||||
'--d3-accent-main': '#2563eb',
|
|
||||||
'--d3-accent-dim': 'rgba(37,99,235,0.10)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(30,58,138,0.12)',
|
|
||||||
'--d3-bg-result': '#ffffff',
|
|
||||||
'--d3-border-result': 'rgba(30,58,138,0.12)',
|
|
||||||
'--d3-text-result': '#111827',
|
|
||||||
'--d3-action-btn': '#3b82f6',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(37,99,235,0.10)',
|
|
||||||
'--d3-action-btn-hover': '#4b5a75',
|
|
||||||
// theme.ts RAW.light.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#06b6d4',
|
|
||||||
'--d3-wave-2': '#1cbff8',
|
|
||||||
'--d3-wave-3': '#32bbfc',
|
|
||||||
'--d3-wave-4': '#3b82f6',
|
|
||||||
'--d3-wave-5': '#4966eb',
|
|
||||||
'--d3-wave-6': '#574ae0',
|
|
||||||
'--d3-wave-7': '#6c2edd',
|
|
||||||
'--d3-wave-8': '#8112da',
|
|
||||||
'--d3-wave-9': '#d946ef',
|
|
||||||
'--d3-status-error': '#ef4444',
|
|
||||||
'--d3-status-success': '#34d399',
|
|
||||||
},
|
|
||||||
nord: {
|
|
||||||
'--d3-bg-card': '#3b4252',
|
|
||||||
'--d3-bg-tip': 'rgba(46,52,64,0.92)',
|
|
||||||
'--d3-text-primary': 'rgba(236,239,244,0.87)',
|
|
||||||
'--d3-text-secondary': 'rgba(216,222,233,0.6)',
|
|
||||||
'--d3-text-inactive': 'rgba(216,222,233,0.35)',
|
|
||||||
'--d3-text-muted': 'rgba(216,222,233,0.25)',
|
|
||||||
'--d3-text-dimLabel': '#5e81ac',
|
|
||||||
'--d3-border-default': 'rgba(216,222,233,0.10)',
|
|
||||||
'--d3-border-subtle': 'rgba(216,222,233,0.06)',
|
|
||||||
'--d3-border-strong': 'rgba(216,222,233,0.16)',
|
|
||||||
'--d3-accent-main': '#88c0d0',
|
|
||||||
'--d3-accent-dim': 'rgba(136,192,208,0.08)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
|
||||||
'--d3-bg-result': '#434c5e',
|
|
||||||
'--d3-border-result': 'rgba(216,222,233,0.10)',
|
|
||||||
'--d3-text-result': 'rgba(236,239,244,0.87)',
|
|
||||||
'--d3-action-btn': 'rgba(216,222,233,0.4)',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(216,222,233,0.08)',
|
|
||||||
'--d3-action-btn-hover': 'rgba(216,222,233,0.7)',
|
|
||||||
// theme.ts RAW.nord.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#8fbcbb',
|
|
||||||
'--d3-wave-2': '#8dbdcd',
|
|
||||||
'--d3-wave-3': '#8bbea0',
|
|
||||||
'--d3-wave-4': '#88c0d0',
|
|
||||||
'--d3-wave-5': '#85c6d0',
|
|
||||||
'--d3-wave-6': '#a3be8c',
|
|
||||||
'--d3-wave-7': '#b48ead',
|
|
||||||
'--d3-wave-8': '#bf88a8',
|
|
||||||
'--d3-wave-9': '#d08770',
|
|
||||||
'--d3-status-error': '#bf616a',
|
|
||||||
'--d3-status-success': '#a3be8c',
|
|
||||||
},
|
|
||||||
solarized: {
|
|
||||||
'--d3-bg-card': '#073642',
|
|
||||||
'--d3-bg-tip': 'rgba(0,43,54,0.92)',
|
|
||||||
'--d3-text-primary': 'rgba(238,232,213,0.87)',
|
|
||||||
'--d3-text-secondary': 'rgba(147,161,161,0.7)',
|
|
||||||
'--d3-text-inactive': 'rgba(147,161,161,0.4)',
|
|
||||||
'--d3-text-muted': 'rgba(147,161,161,0.25)',
|
|
||||||
'--d3-text-dimLabel': '#7a6c2e',
|
|
||||||
'--d3-border-default': 'rgba(131,148,150,0.13)',
|
|
||||||
'--d3-border-subtle': 'rgba(131,148,150,0.08)',
|
|
||||||
'--d3-border-strong': 'rgba(131,148,150,0.20)',
|
|
||||||
'--d3-accent-main': '#b58900',
|
|
||||||
'--d3-accent-dim': 'rgba(181,137,0,0.08)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
|
||||||
'--d3-bg-result': '#0d4250',
|
|
||||||
'--d3-border-result': 'rgba(131,148,150,0.13)',
|
|
||||||
'--d3-text-result': 'rgba(238,232,213,0.87)',
|
|
||||||
'--d3-action-btn': 'rgba(147,161,161,0.4)',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(131,148,150,0.10)',
|
|
||||||
'--d3-action-btn-hover': 'rgba(147,161,161,0.7)',
|
|
||||||
// theme.ts RAW.solarized.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#2aa198',
|
|
||||||
'--d3-wave-2': '#2693cb',
|
|
||||||
'--d3-wave-3': '#268bd2',
|
|
||||||
'--d3-wave-4': '#268bd2',
|
|
||||||
'--d3-wave-5': '#4676d9',
|
|
||||||
'--d3-wave-6': '#6c71c4',
|
|
||||||
'--d3-wave-7': '#6c71c4',
|
|
||||||
'--d3-wave-8': '#9a33d3',
|
|
||||||
'--d3-wave-9': '#d33682',
|
|
||||||
'--d3-status-error': '#dc322f',
|
|
||||||
'--d3-status-success': '#859900',
|
|
||||||
},
|
|
||||||
catppuccin: {
|
|
||||||
'--d3-bg-card': '#313244',
|
|
||||||
'--d3-bg-tip': 'rgba(30,30,46,0.92)',
|
|
||||||
'--d3-text-primary': 'rgba(205,214,244,0.87)',
|
|
||||||
'--d3-text-secondary': 'rgba(186,194,222,0.7)',
|
|
||||||
'--d3-text-inactive': 'rgba(166,173,200,0.4)',
|
|
||||||
'--d3-text-muted': 'rgba(166,173,200,0.25)',
|
|
||||||
'--d3-text-dimLabel': '#585b70',
|
|
||||||
'--d3-border-default': 'rgba(205,214,244,0.08)',
|
|
||||||
'--d3-border-subtle': 'rgba(205,214,244,0.04)',
|
|
||||||
'--d3-border-strong': 'rgba(205,214,244,0.14)',
|
|
||||||
'--d3-accent-main': '#cba6f7',
|
|
||||||
'--d3-accent-dim': 'rgba(203,166,247,0.08)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
|
||||||
'--d3-bg-result': '#3a3a54',
|
|
||||||
'--d3-border-result': 'rgba(205,214,244,0.08)',
|
|
||||||
'--d3-text-result': 'rgba(205,214,244,0.87)',
|
|
||||||
'--d3-action-btn': 'rgba(166,173,200,0.4)',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(205,214,244,0.06)',
|
|
||||||
'--d3-action-btn-hover': 'rgba(205,214,244,0.7)',
|
|
||||||
// theme.ts RAW.catppuccin.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#89dceb',
|
|
||||||
'--d3-wave-2': '#89cff4',
|
|
||||||
'--d3-wave-3': '#89c2f9',
|
|
||||||
'--d3-wave-4': '#89b4fa',
|
|
||||||
'--d3-wave-5': '#b4a9f2',
|
|
||||||
'--d3-wave-6': '#df9eea',
|
|
||||||
'--d3-wave-7': '#cba6f7',
|
|
||||||
'--d3-wave-8': '#e5b6f6',
|
|
||||||
'--d3-wave-9': '#f5c2e7',
|
|
||||||
'--d3-status-error': '#f38ba8',
|
|
||||||
'--d3-status-success': '#a6e3a1',
|
|
||||||
},
|
|
||||||
dracula: {
|
|
||||||
'--d3-bg-card': '#44475a',
|
|
||||||
'--d3-bg-tip': 'rgba(40,42,54,0.92)',
|
|
||||||
'--d3-text-primary': 'rgba(248,248,242,0.87)',
|
|
||||||
'--d3-text-secondary': 'rgba(169,176,208,0.7)',
|
|
||||||
'--d3-text-inactive': 'rgba(169,176,208,0.4)',
|
|
||||||
'--d3-text-muted': 'rgba(169,176,208,0.25)',
|
|
||||||
'--d3-text-dimLabel': '#6272a4',
|
|
||||||
'--d3-border-default': 'rgba(248,248,242,0.08)',
|
|
||||||
'--d3-border-subtle': 'rgba(248,248,242,0.04)',
|
|
||||||
'--d3-border-strong': 'rgba(248,248,242,0.14)',
|
|
||||||
'--d3-accent-main': '#bd93f9',
|
|
||||||
'--d3-accent-dim': 'rgba(189,147,249,0.08)',
|
|
||||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
|
||||||
'--d3-bg-result': '#4f5266',
|
|
||||||
'--d3-border-result': 'rgba(248,248,242,0.08)',
|
|
||||||
'--d3-text-result': 'rgba(248,248,242,0.87)',
|
|
||||||
'--d3-action-btn': 'rgba(169,176,208,0.4)',
|
|
||||||
'--d3-action-btn-hover-bg': 'rgba(248,248,242,0.06)',
|
|
||||||
'--d3-action-btn-hover': 'rgba(248,248,242,0.7)',
|
|
||||||
// theme.ts RAW.dracula.gradient.wave1-4 보간
|
|
||||||
'--d3-wave-1': '#8be9fd',
|
|
||||||
'--d3-wave-2': '#a6fbff',
|
|
||||||
'--d3-wave-3': '#c1fdff',
|
|
||||||
'--d3-wave-4': '#bd93f9',
|
|
||||||
'--d3-wave-5': '#d485ff',
|
|
||||||
'--d3-wave-6': '#eb77ff',
|
|
||||||
'--d3-wave-7': '#ff79c6',
|
|
||||||
'--d3-wave-8': '#ff88c4',
|
|
||||||
'--d3-wave-9': '#ffb86c',
|
|
||||||
'--d3-status-error': '#ff5555',
|
|
||||||
'--d3-status-success': '#50fa7b',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업용 CSS 변수 맵 조회.
|
* 팝업 CSS 가 쓰는 전용 별칭. 렌더러 공통 변수(buildCssVars) 위에 얹는다.
|
||||||
* ThemeMode('system' | 'light' | 'dark') 또는 테마 키를 받아 해당 변수 맵 반환.
|
* v4: 팝업도 평면 — 반투명 배경·다색 파형을 쓰지 않는다.
|
||||||
* 'system' 또는 알 수 없는 값은 'dark'로 폴백.
|
|
||||||
*/
|
*/
|
||||||
export function getPopupThemeVars(themeKey: string): PopupThemeVars {
|
function popupAliases(key: ThemeKey): Record<string, string> {
|
||||||
const key = themeKey as PopupThemeKey
|
const r = RAW[key]
|
||||||
return POPUP_THEME_VARS[key] ?? POPUP_THEME_VARS.dark
|
const st = STATUS[key]
|
||||||
|
const wave: Record<string, string> = {}
|
||||||
|
for (let i = 1; i <= 9; i++) wave[`--d3-wave-${i}`] = r.accent.main
|
||||||
|
return {
|
||||||
|
'--d3-bg-tip': r.bg.card,
|
||||||
|
'--d3-shadow-popup': r.shadow.overlay,
|
||||||
|
'--d3-bg-result': r.bg.card,
|
||||||
|
'--d3-border-result': r.border.default,
|
||||||
|
'--d3-text-result': r.text.primary,
|
||||||
|
'--d3-action-btn': r.text.secondary,
|
||||||
|
'--d3-action-btn-hover-bg': 'var(--d3-overlay-soft)',
|
||||||
|
'--d3-action-btn-hover': r.text.primary,
|
||||||
|
'--d3-accent-glow-dim': r.accent.dim,
|
||||||
|
'--d3-status-error': st.danger,
|
||||||
|
...wave,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ThemeMode('system' | 'auto' | 테마 키)를 팝업 테마 키로. 시스템 모드는 호출자가 prefersDark 를 넘긴다. */
|
||||||
|
export function resolvePopupThemeKey(themeKey: string, prefersDark = true): PopupThemeKey {
|
||||||
|
if (isThemeKey(themeKey)) return themeKey
|
||||||
|
if (themeKey === 'system' || themeKey === 'auto') return prefersDark ? 'dark' : 'light'
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업용 CSS 변수 맵 조회. 알 수 없는 값은 'dark'. */
|
||||||
|
export function getPopupThemeVars(themeKey: string, prefersDark = true): Record<string, string> {
|
||||||
|
const key = resolvePopupThemeKey(themeKey, prefersDark)
|
||||||
|
return { ...buildCssVars(key), ...popupAliases(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업 BrowserWindow에 insertCSS로 주입할 CSS 문자열 반환.
|
* 팝업 BrowserWindow에 insertCSS로 주입할 CSS 문자열 반환.
|
||||||
* `:root { --d3-bg-card: #242427; ... }`
|
* `:root { --d3-bg-card: #111a30; ... }`
|
||||||
*/
|
*/
|
||||||
export function buildPopupThemeCss(themeKey: string): string {
|
export function buildPopupThemeCss(themeKey: string, prefersDark = true): string {
|
||||||
const vars = getPopupThemeVars(themeKey)
|
const vars = getPopupThemeVars(themeKey, prefersDark)
|
||||||
const declarations = Object.entries(vars)
|
const declarations = Object.entries(vars)
|
||||||
.map(([prop, value]) => ` ${prop}: ${value};`)
|
.map(([prop, value]) => ` ${prop}: ${value};`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue