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:
Yun Chan 2026-09-28 18:26:03 +09:00
parent c3367bc611
commit 2428ede03d
15 changed files with 969 additions and 1515 deletions

View file

@ -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곳이 제목 색으로 오용 중 — 값을 정하면 라이트 테마 제목이 사라진다. 웹 정리 때 함께.

View file

@ -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"

View file

@ -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>
) )
} }

View file

@ -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)

View file

@ -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 },
}, },
} }
: {}), : {}),

View file

@ -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,
}} }}
> >

View file

@ -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,
}} }}
/> />

View file

@ -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>

View file

@ -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,
// 상단 시인
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
background: d3roPalette.glass.sheen,
pointerEvents: 'none',
zIndex: 1,
},
}}
>
<Box
sx={{
position: 'relative',
zIndex: 2,
p: 2, p: 2,
height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{children} {children}
</Box> </Box>
</Box>
) )
} }

View file

@ -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}`,
flexShrink: 0,
}}
>
<Box
sx={{
width: '100%',
height: '100%',
borderRadius: '50%',
bgcolor: d3roPalette.bg.crtGlass,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: c.icon, color: color === 'accent' ? d3roPalette.accent.light : d3roPalette.text.secondary,
flexShrink: 0,
}} }}
> >
{children} {children}
</Box> </Box>
</Box>
) )
} }

View file

@ -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,

View file

@ -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>
)
} }

View 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,
}
}

View file

@ -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