- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵 - .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입) - .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug - .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist - docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널), DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석, VoiceMode패턴, 디자인시스템, 히스토리팝업 - docs/phases/1-7+3.5: 전체 구현 페이즈 문서 - docs/re-findings/: Speakly RE 노하우 5개 문서
1095 lines
28 KiB
Markdown
1095 lines
28 KiB
Markdown
# 08. D3RO-VOICE 디자인 시스템
|
||
|
||
> 시안 A (Meteorological Instrument)와 시안 B (VoiceOps Dashboard)를 융합한 전용 디자인 시스템.
|
||
> 03-db-and-ui.md의 MUI 테마를 완전 교체한다.
|
||
|
||
---
|
||
|
||
## 1. 컬러 팔레트
|
||
|
||
### 1.1 CSS 커스텀 프로퍼티
|
||
|
||
```css
|
||
:root {
|
||
/* ── 배경 (시안 B 기반) ─────────────────────────── */
|
||
--bg-app: #19191b;
|
||
--bg-card: #242427;
|
||
--bg-card-hover: #2a2a2d;
|
||
--bg-elevated: #2e2e32;
|
||
--bg-input: #1e1e21;
|
||
|
||
/* ── 보더 ───────────────────────────────────────── */
|
||
--border-subtle: rgba(255, 255, 255, 0.04);
|
||
--border-default: rgba(255, 255, 255, 0.08);
|
||
--border-strong: rgba(255, 255, 255, 0.12);
|
||
|
||
/* ── 악센트: 앰버 (시안 A) ─────────────────────── */
|
||
--accent-amber: #f25b29;
|
||
--accent-amber-dim: rgba(242, 91, 41, 0.15);
|
||
--accent-amber-glow: rgba(242, 91, 41, 0.6);
|
||
|
||
/* ── 상태 태그 (시안 B) ─────────────────────────── */
|
||
--tag-purple: #b854f5;
|
||
--tag-purple-bg: rgba(184, 84, 245, 0.12);
|
||
--tag-orange: #f59e0b;
|
||
--tag-orange-bg: rgba(245, 158, 11, 0.12);
|
||
--tag-red: #ef4444;
|
||
--tag-red-bg: rgba(239, 68, 68, 0.12);
|
||
--tag-green: #22c55e;
|
||
--tag-green-bg: rgba(34, 197, 94, 0.12);
|
||
|
||
/* ── 텍스트 계층 (시안 B) ──────────────────────── */
|
||
--text-primary: #ffffff;
|
||
--text-secondary: #8e8e93;
|
||
--text-label: #7c7c82;
|
||
--text-disabled: #4a4a4e;
|
||
|
||
/* ── CRT 인광 (시안 A, RecordingTip 전용) ──────── */
|
||
--crt-phosphor: #f25b29;
|
||
--crt-phosphor-dim: #c44a22;
|
||
--crt-scanline: rgba(0, 0, 0, 0.15);
|
||
--crt-bg: #242528;
|
||
|
||
/* ── 시맨틱 ─────────────────────────────────────── */
|
||
--color-error: #ef4444;
|
||
--color-success: #22c55e;
|
||
--color-warning: #f59e0b;
|
||
--color-info: #b854f5;
|
||
}
|
||
```
|
||
|
||
### 1.2 태그 색상 매핑
|
||
|
||
| 태그 | 배경 | 텍스트 | 용도 |
|
||
|------|------|--------|------|
|
||
| STT | `--tag-purple-bg` | `--tag-purple` | STT 관련 서비스/상태 |
|
||
| TTS | `--tag-orange-bg` | `--tag-orange` | TTS 관련 서비스/상태 |
|
||
| LIVE | `--tag-red-bg` | `--tag-red` | 실시간 녹음/스트리밍 |
|
||
| COMPLETE | `--tag-green-bg` | `--tag-green` | 완료 상태 |
|
||
| PROCESSING | `--tag-orange-bg` | `--tag-orange` | 처리 중 |
|
||
| ERROR | `--tag-red-bg` | `--tag-red` | 에러 상태 |
|
||
|
||
---
|
||
|
||
## 2. 타이포그래피
|
||
|
||
### 2.1 폰트 스택
|
||
|
||
```css
|
||
:root {
|
||
/* 본문 (시안 B) */
|
||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", Arial, sans-serif;
|
||
|
||
/* 상태/모노 (시안 A) */
|
||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||
"Liberation Mono", monospace;
|
||
}
|
||
```
|
||
|
||
### 2.2 크기 체계
|
||
|
||
| 토큰 | 크기 | 행간 | 용도 |
|
||
|------|------|------|------|
|
||
| `--text-hero` | 28px | 1.2 | 대시보드 메인 수치 |
|
||
| `--text-title` | 22px | 1.3 | 섹션 타이틀 |
|
||
| `--text-subtitle` | 18px | 1.4 | 카드 헤딩, 라이브 전사 |
|
||
| `--text-body` | 14px | 1.5 | 본문 텍스트 |
|
||
| `--text-small` | 12px | 1.4 | 보조 텍스트 |
|
||
| `--text-label` | 11px | 1.2 | 라벨, 태그 |
|
||
|
||
### 2.3 라벨 스타일
|
||
|
||
```css
|
||
.label {
|
||
font-family: var(--font-sans);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text-label);
|
||
}
|
||
```
|
||
|
||
### 2.4 모노 숫자 스타일
|
||
|
||
```css
|
||
.mono-value {
|
||
font-family: var(--font-mono);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.02em;
|
||
color: var(--text-primary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 컴포넌트 스타일가이드
|
||
|
||
### 3.1 Card
|
||
|
||
시안 B 기반. 전체 레이아웃의 기본 컨테이너.
|
||
|
||
```css
|
||
.card {
|
||
background: var(--bg-card);
|
||
border-radius: 22px;
|
||
border-top: 1px solid var(--border-subtle); /* rgba(255,255,255,0.04) */
|
||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||
padding: 24px;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.card:hover {
|
||
background: var(--bg-card-hover);
|
||
}
|
||
```
|
||
|
||
### 3.2 Tag / Badge
|
||
|
||
시안 B 기반. pill 형태의 상태 태그.
|
||
|
||
```css
|
||
.tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
border-radius: 999px; /* pill */
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* 변형 */
|
||
.tag--purple { background: var(--tag-purple-bg); color: var(--tag-purple); }
|
||
.tag--orange { background: var(--tag-orange-bg); color: var(--tag-orange); }
|
||
.tag--red { background: var(--tag-red-bg); color: var(--tag-red); }
|
||
.tag--green { background: var(--tag-green-bg); color: var(--tag-green); }
|
||
```
|
||
|
||
### 3.3 Button
|
||
|
||
시안 A의 물리 버튼 느낌. 눌림 피드백 중시.
|
||
|
||
```css
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
font-family: var(--font-sans);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.05s linear, box-shadow 0.05s linear;
|
||
box-shadow:
|
||
0 2px 0 rgba(0, 0, 0, 0.4), /* 돌출 그림자 */
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.06); /* 상단 하이라이트 */
|
||
}
|
||
|
||
.btn:active {
|
||
transform: translateY(2px);
|
||
box-shadow:
|
||
0 0 0 rgba(0, 0, 0, 0.4),
|
||
inset 0 2px 4px rgba(0, 0, 0, 0.3); /* 눌림 inset */
|
||
}
|
||
|
||
/* Primary: 앰버 악센트 */
|
||
.btn--primary {
|
||
background: var(--accent-amber);
|
||
color: #fff;
|
||
}
|
||
.btn--primary:hover {
|
||
background: #d94f24;
|
||
}
|
||
|
||
/* Secondary: 다크 */
|
||
.btn--secondary {
|
||
background: var(--bg-elevated);
|
||
color: var(--text-primary);
|
||
}
|
||
.btn--secondary:hover {
|
||
background: #353539;
|
||
}
|
||
```
|
||
|
||
### 3.4 LED Indicator
|
||
|
||
시안 A 기반. 서비스 상태 표시등.
|
||
|
||
```css
|
||
.led {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--text-disabled);
|
||
flex-shrink: 0;
|
||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||
}
|
||
|
||
.led--active {
|
||
background: var(--tag-green);
|
||
box-shadow: 0 0 6px var(--tag-green), 0 0 12px rgba(34, 197, 94, 0.3);
|
||
}
|
||
|
||
.led--warning {
|
||
background: var(--tag-orange);
|
||
box-shadow: 0 0 6px var(--tag-orange), 0 0 12px rgba(245, 158, 11, 0.3);
|
||
}
|
||
|
||
.led--error {
|
||
background: var(--tag-red);
|
||
box-shadow: 0 0 6px var(--tag-red), 0 0 12px rgba(239, 68, 68, 0.3);
|
||
}
|
||
|
||
.led--recording {
|
||
background: var(--accent-amber);
|
||
box-shadow: 0 0 6px var(--accent-amber-glow), 0 0 16px var(--accent-amber-dim);
|
||
animation: led-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes led-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
```
|
||
|
||
### 3.5 WaveformBar
|
||
|
||
시안 B의 7개 바 레이아웃 + 시안 A의 앰버색.
|
||
|
||
```css
|
||
.waveform {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
height: 32px;
|
||
}
|
||
|
||
.waveform__bar {
|
||
width: 4px;
|
||
min-height: 3px;
|
||
border-radius: 2px;
|
||
background: var(--accent-amber);
|
||
transition: height 100ms ease-out;
|
||
}
|
||
|
||
/* 7개 바 코사인 분포 가중치 */
|
||
/* JS에서 scaleY로 높이 제어 */
|
||
```
|
||
|
||
```javascript
|
||
// 웨이브폼 파라미터
|
||
const WAVEFORM = {
|
||
BAR_COUNT: 7,
|
||
UPDATE_MS: 100,
|
||
MIN_SCALE: 0.1, // scaleY 최솟값
|
||
MAX_SCALE: 1.0, // scaleY 최댓값
|
||
SMOOTHING: 0.5,
|
||
RANDOM_FACTOR: 0.35,
|
||
};
|
||
|
||
// 코사인 분포 가중치 (중앙 최대)
|
||
const weights = Array.from({ length: WAVEFORM.BAR_COUNT }, (_, n) => {
|
||
const center = (WAVEFORM.BAR_COUNT - 1) / 2; // 3
|
||
const normalized = (n - center) / center; // -1 ~ +1
|
||
return Math.cos(normalized * Math.PI / 2);
|
||
});
|
||
// 결과: [0, 0.50, 0.87, 1.0, 0.87, 0.50, 0]
|
||
|
||
function updateWaveform(bars, audioLevel, currentScales) {
|
||
for (let i = 0; i < WAVEFORM.BAR_COUNT; i++) {
|
||
const base = audioLevel * weights[i];
|
||
const randomized = base * (1 + (Math.random() - 0.5) * 2 * WAVEFORM.RANDOM_FACTOR);
|
||
const target = Math.max(WAVEFORM.MIN_SCALE, Math.min(WAVEFORM.MAX_SCALE, randomized));
|
||
currentScales[i] += (target - currentScales[i]) * WAVEFORM.SMOOTHING;
|
||
bars[i].style.transform = `scaleY(${currentScales[i]})`;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 RecordingTip (CRT 모드)
|
||
|
||
시안 A의 CRT 인스트루먼트 느낌을 RecordingTip 팝업에 적용.
|
||
|
||
```css
|
||
.recording-tip--crt {
|
||
background: var(--crt-bg);
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
box-shadow:
|
||
0 0 20px rgba(242, 91, 41, 0.15),
|
||
inset 0 0 60px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
/* 스캔라인 오버레이 */
|
||
.recording-tip--crt::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
transparent,
|
||
transparent 2px,
|
||
var(--crt-scanline) 2px,
|
||
var(--crt-scanline) 4px
|
||
);
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
}
|
||
|
||
/* 비네팅 오버레이 */
|
||
.recording-tip--crt::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(
|
||
ellipse at center,
|
||
transparent 50%,
|
||
rgba(0, 0, 0, 0.4) 100%
|
||
);
|
||
pointer-events: none;
|
||
z-index: 3;
|
||
}
|
||
|
||
/* CRT 모드 웨이브바: 앰버 인광색 */
|
||
.recording-tip--crt .waveform__bar {
|
||
background: var(--crt-phosphor);
|
||
box-shadow: 0 0 4px var(--accent-amber-glow);
|
||
}
|
||
|
||
/* CRT 모드 텍스트 */
|
||
.recording-tip--crt .duration,
|
||
.recording-tip--crt .thinking-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--crt-phosphor);
|
||
text-shadow: 0 0 6px var(--accent-amber-glow);
|
||
letter-spacing: 1.5px;
|
||
}
|
||
|
||
/* 노이즈 텍스처 */
|
||
.recording-tip--crt .noise-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
opacity: 0.03;
|
||
mix-blend-mode: overlay;
|
||
pointer-events: none;
|
||
z-index: 4;
|
||
/* JS: canvas로 랜덤 픽셀 노이즈 생성 */
|
||
}
|
||
```
|
||
|
||
### 3.7 Dashboard 레이아웃
|
||
|
||
시안 B의 카드 그리드 + 헤더 구조.
|
||
|
||
```css
|
||
.dashboard {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 40px;
|
||
}
|
||
|
||
.dashboard__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.dashboard__header-title {
|
||
font-size: var(--text-title);
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.dashboard__header-subtitle {
|
||
font-size: var(--text-body);
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
}
|
||
```
|
||
|
||
### 3.8 StatusPanel
|
||
|
||
시안 B의 서비스 상태 리스트 카드.
|
||
|
||
```css
|
||
.status-panel {
|
||
/* .card 스타일 상속 */
|
||
}
|
||
|
||
.status-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.status-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.status-item__name {
|
||
font-size: var(--text-body);
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
flex: 1;
|
||
}
|
||
|
||
.status-item__value {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
```
|
||
|
||
### 3.9 ProcessingOverlay
|
||
|
||
시안 B의 blur + spinner 처리 중 표시.
|
||
|
||
```css
|
||
.processing-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
background: rgba(25, 25, 27, 0.6);
|
||
backdrop-filter: blur(4px);
|
||
border-radius: 22px; /* 부모 card와 동일 */
|
||
z-index: 10;
|
||
}
|
||
|
||
.processing-overlay__spinner {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 2px solid var(--border-strong);
|
||
border-top-color: var(--accent-amber);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
.processing-overlay__label {
|
||
font-size: var(--text-label);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 애니메이션 스펙
|
||
|
||
### 4.1 웨이브폼 애니메이션
|
||
|
||
시안 B의 `scaleY` pulse + 시안 A의 코사인 가중치.
|
||
|
||
```
|
||
방식: setInterval 100ms + smoothing 보간
|
||
높이 계산: audioLevel * cosWeight[i] * (1 ± 35% random)
|
||
보간: current += (target - current) * 0.5
|
||
CSS: transform: scaleY(n), transition: transform 100ms ease-out
|
||
바 개수: 7
|
||
```
|
||
|
||
### 4.2 글리치 전환 (RecordingTip CRT)
|
||
|
||
시안 A의 glitchAmount decay. 상태 전환 시 트리거.
|
||
|
||
```
|
||
트리거: 상태 전환 (recording → thinking, thinking → error 등)
|
||
초기값: glitchAmount = 1.0
|
||
감쇠: glitchAmount *= 0.85 (매 프레임)
|
||
종료 조건: glitchAmount < 0.01
|
||
효과: RGB 채널 분리 (셰이더) 또는 CSS translateX 떨림
|
||
CSS 폴백: transform: translateX(random(-3, 3)px), opacity 깜빡임
|
||
```
|
||
|
||
### 4.3 카드 hover
|
||
|
||
```css
|
||
transition: background-color 0.2s ease;
|
||
```
|
||
|
||
### 4.4 버튼 press
|
||
|
||
```css
|
||
transition: transform 0.05s linear, box-shadow 0.05s linear;
|
||
/* active: translateY(2px) */
|
||
```
|
||
|
||
### 4.5 스피너
|
||
|
||
```css
|
||
animation: spin 1s linear infinite;
|
||
/* rotate(360deg) */
|
||
```
|
||
|
||
### 4.6 프로그레스 바 (점근 수렴)
|
||
|
||
```javascript
|
||
// 시간 기반: 95%에서 정체, 완료 시 100%로 snap
|
||
const elapsed = (performance.now() - startTime) / 1000;
|
||
const progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100);
|
||
|
||
// 완료 시
|
||
progressBar.style.transition = 'width 200ms ease-out';
|
||
progressBar.style.width = '100%';
|
||
```
|
||
|
||
### 4.7 LED pulse
|
||
|
||
```css
|
||
@keyframes led-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
/* duration: 1.5s, timing: ease-in-out, iteration: infinite */
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 레이아웃 그리드
|
||
|
||
### 5.1 메인 그리드
|
||
|
||
시안 B 기반. `max-width: 1200px`, 패딩 40px.
|
||
|
||
```css
|
||
.layout {
|
||
display: grid;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 40px;
|
||
gap: 24px;
|
||
}
|
||
|
||
/* 상단 영역: 녹음 컨트롤(2fr) + 상태 패널(1fr) */
|
||
.layout__top {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 24px;
|
||
}
|
||
|
||
/* 카드 그리드: auto-fill */
|
||
.layout__cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 24px;
|
||
}
|
||
```
|
||
|
||
### 5.2 반응형
|
||
|
||
```css
|
||
/* 태블릿 (< 900px) */
|
||
@media (max-width: 900px) {
|
||
.layout {
|
||
padding: 24px;
|
||
}
|
||
.layout__top {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* 모바일 (< 600px) */
|
||
@media (max-width: 600px) {
|
||
.layout {
|
||
padding: 16px;
|
||
gap: 16px;
|
||
}
|
||
.layout__cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. MUI 테마
|
||
|
||
03-db-and-ui.md의 `createTheme()`을 이 디자인 시스템으로 완전 교체한다.
|
||
라이트 테마를 제거하고 다크 전용으로 운영한다.
|
||
|
||
```typescript
|
||
// src/renderer/theme.ts
|
||
|
||
import { createTheme } from '@mui/material/styles';
|
||
|
||
// ── CSS 변수와 동기화된 팔레트 상수 ──────────────────
|
||
const palette = {
|
||
bg: {
|
||
app: '#19191b',
|
||
card: '#242427',
|
||
cardHover: '#2a2a2d',
|
||
elevated: '#2e2e32',
|
||
input: '#1e1e21',
|
||
},
|
||
accent: {
|
||
amber: '#f25b29',
|
||
amberDim: 'rgba(242, 91, 41, 0.15)',
|
||
},
|
||
tag: {
|
||
purple: '#b854f5',
|
||
orange: '#f59e0b',
|
||
red: '#ef4444',
|
||
green: '#22c55e',
|
||
},
|
||
text: {
|
||
primary: '#ffffff',
|
||
secondary: '#8e8e93',
|
||
label: '#7c7c82',
|
||
disabled: '#4a4a4e',
|
||
},
|
||
border: {
|
||
subtle: 'rgba(255, 255, 255, 0.04)',
|
||
default: 'rgba(255, 255, 255, 0.08)',
|
||
strong: 'rgba(255, 255, 255, 0.12)',
|
||
},
|
||
} as const;
|
||
|
||
const fontSans = [
|
||
'-apple-system',
|
||
'BlinkMacSystemFont',
|
||
'"Segoe UI"',
|
||
'Roboto',
|
||
'"Helvetica Neue"',
|
||
'Arial',
|
||
'sans-serif',
|
||
].join(',');
|
||
|
||
const fontMono = [
|
||
'ui-monospace',
|
||
'SFMono-Regular',
|
||
'"SF Mono"',
|
||
'Menlo',
|
||
'Consolas',
|
||
'"Liberation Mono"',
|
||
'monospace',
|
||
].join(',');
|
||
|
||
// ── 테마 정의 ────────────────────────────────────────
|
||
export const theme = createTheme({
|
||
palette: {
|
||
mode: 'dark',
|
||
primary: {
|
||
main: palette.accent.amber, // #f25b29
|
||
light: '#ff7a4d',
|
||
dark: '#c44a22',
|
||
contrastText: '#ffffff',
|
||
},
|
||
secondary: {
|
||
main: palette.tag.purple, // #b854f5
|
||
light: '#d084ff',
|
||
dark: '#8a3cc4',
|
||
},
|
||
error: {
|
||
main: palette.tag.red, // #ef4444
|
||
light: '#ff7961',
|
||
},
|
||
warning: {
|
||
main: palette.tag.orange, // #f59e0b
|
||
},
|
||
success: {
|
||
main: palette.tag.green, // #22c55e
|
||
light: '#4ade80',
|
||
},
|
||
background: {
|
||
default: palette.bg.app, // #19191b
|
||
paper: palette.bg.card, // #242427
|
||
},
|
||
text: {
|
||
primary: palette.text.primary, // #ffffff
|
||
secondary: palette.text.secondary, // #8e8e93
|
||
disabled: palette.text.disabled, // #4a4a4e
|
||
},
|
||
divider: palette.border.default, // rgba(255,255,255,0.08)
|
||
},
|
||
|
||
typography: {
|
||
fontFamily: fontSans,
|
||
h4: { fontWeight: 700, fontSize: '22px', lineHeight: 1.3 },
|
||
h5: { fontWeight: 700, fontSize: '18px', lineHeight: 1.4 },
|
||
h6: { fontWeight: 600, fontSize: '14px', lineHeight: 1.5 },
|
||
subtitle1: {
|
||
fontWeight: 500,
|
||
fontSize: '18px',
|
||
lineHeight: 1.4,
|
||
},
|
||
body1: { fontSize: '14px', lineHeight: 1.5 },
|
||
body2: { fontSize: '12px', lineHeight: 1.4 },
|
||
button: {
|
||
textTransform: 'none' as const,
|
||
fontWeight: 600,
|
||
fontSize: '14px',
|
||
},
|
||
caption: {
|
||
fontSize: '11px',
|
||
fontWeight: 600,
|
||
letterSpacing: '0.1em',
|
||
textTransform: 'uppercase' as const,
|
||
color: palette.text.label,
|
||
},
|
||
overline: {
|
||
fontSize: '11px',
|
||
fontWeight: 600,
|
||
letterSpacing: '0.1em',
|
||
textTransform: 'uppercase' as const,
|
||
lineHeight: 1.2,
|
||
},
|
||
},
|
||
|
||
shape: {
|
||
borderRadius: 22, // 카드 기본 radius
|
||
},
|
||
|
||
components: {
|
||
MuiCssBaseline: {
|
||
styleOverrides: {
|
||
body: {
|
||
backgroundColor: palette.bg.app,
|
||
color: palette.text.primary,
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiButton: {
|
||
defaultProps: {
|
||
disableElevation: true,
|
||
},
|
||
styleOverrides: {
|
||
root: {
|
||
textTransform: 'none',
|
||
fontWeight: 600,
|
||
borderRadius: 10,
|
||
padding: '10px 20px',
|
||
transition: 'transform 0.05s linear, box-shadow 0.05s linear',
|
||
boxShadow: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||
'&:active': {
|
||
transform: 'translateY(2px)',
|
||
boxShadow: '0 0 0 rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)',
|
||
},
|
||
},
|
||
containedPrimary: {
|
||
'&:hover': {
|
||
backgroundColor: '#d94f24',
|
||
},
|
||
},
|
||
containedSecondary: {
|
||
backgroundColor: palette.bg.elevated,
|
||
color: palette.text.primary,
|
||
'&:hover': {
|
||
backgroundColor: '#353539',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiCard: {
|
||
defaultProps: {
|
||
elevation: 0,
|
||
},
|
||
styleOverrides: {
|
||
root: {
|
||
backgroundColor: palette.bg.card,
|
||
borderRadius: 22,
|
||
borderTop: `1px solid ${palette.border.subtle}`,
|
||
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.3)',
|
||
transition: 'background-color 0.2s ease',
|
||
'&:hover': {
|
||
backgroundColor: palette.bg.cardHover,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiChip: {
|
||
styleOverrides: {
|
||
root: {
|
||
borderRadius: 999,
|
||
fontWeight: 700,
|
||
fontSize: '11px',
|
||
letterSpacing: '0.1em',
|
||
textTransform: 'uppercase',
|
||
height: 24,
|
||
},
|
||
colorPrimary: {
|
||
backgroundColor: palette.accent.amberDim,
|
||
color: palette.accent.amber,
|
||
},
|
||
colorSecondary: {
|
||
backgroundColor: 'rgba(184, 84, 245, 0.12)',
|
||
color: palette.tag.purple,
|
||
},
|
||
colorSuccess: {
|
||
backgroundColor: 'rgba(34, 197, 94, 0.12)',
|
||
color: palette.tag.green,
|
||
},
|
||
colorError: {
|
||
backgroundColor: 'rgba(239, 68, 68, 0.12)',
|
||
color: palette.tag.red,
|
||
},
|
||
colorWarning: {
|
||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||
color: palette.tag.orange,
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiDrawer: {
|
||
styleOverrides: {
|
||
paper: {
|
||
width: 240,
|
||
backgroundColor: palette.bg.app,
|
||
borderRight: `1px solid ${palette.border.subtle}`,
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiListItemButton: {
|
||
styleOverrides: {
|
||
root: {
|
||
borderRadius: 10,
|
||
marginLeft: 8,
|
||
marginRight: 8,
|
||
'&.Mui-selected': {
|
||
backgroundColor: palette.accent.amberDim,
|
||
color: palette.accent.amber,
|
||
fontWeight: 600,
|
||
'&:hover': {
|
||
backgroundColor: 'rgba(242, 91, 41, 0.2)',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiDialog: {
|
||
styleOverrides: {
|
||
paper: {
|
||
backgroundColor: palette.bg.card,
|
||
borderRadius: 22,
|
||
border: `1px solid ${palette.border.subtle}`,
|
||
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiTextField: {
|
||
defaultProps: {
|
||
size: 'small',
|
||
variant: 'outlined',
|
||
},
|
||
styleOverrides: {
|
||
root: {
|
||
'& .MuiOutlinedInput-root': {
|
||
backgroundColor: palette.bg.input,
|
||
borderRadius: 10,
|
||
'& fieldset': {
|
||
borderColor: palette.border.default,
|
||
},
|
||
'&:hover fieldset': {
|
||
borderColor: palette.border.strong,
|
||
},
|
||
'&.Mui-focused fieldset': {
|
||
borderColor: palette.accent.amber,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiTooltip: {
|
||
defaultProps: {
|
||
arrow: true,
|
||
},
|
||
styleOverrides: {
|
||
tooltip: {
|
||
backgroundColor: palette.bg.elevated,
|
||
fontSize: '12px',
|
||
borderRadius: 8,
|
||
border: `1px solid ${palette.border.subtle}`,
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiTabs: {
|
||
styleOverrides: {
|
||
indicator: {
|
||
backgroundColor: palette.accent.amber,
|
||
},
|
||
},
|
||
},
|
||
|
||
MuiTab: {
|
||
styleOverrides: {
|
||
root: {
|
||
textTransform: 'none',
|
||
fontWeight: 500,
|
||
fontSize: '14px',
|
||
'&.Mui-selected': {
|
||
color: palette.accent.amber,
|
||
fontWeight: 600,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// 팔레트 상수를 외부에서 참조할 수 있도록 export
|
||
export { palette as d3roPalette, fontMono as d3roFontMono };
|
||
```
|
||
|
||
---
|
||
|
||
## 7. RecordingTip CRT 효과 스펙
|
||
|
||
RecordingTip 팝업에 시안 A의 WebGL CRT 효과를 적용하기 위한 상세 스펙.
|
||
|
||
### 7.1 구현 전략
|
||
|
||
RecordingTip은 경량 팝업이므로 WebGL 대신 **CSS + Canvas 2D** 조합으로 구현한다.
|
||
성능 문제 발생 시 WebGL fragment shader로 전환 가능하도록 인터페이스를 분리한다.
|
||
|
||
- 스캔라인: CSS `repeating-linear-gradient` (::before)
|
||
- 비네팅: CSS `radial-gradient` (::after)
|
||
- 노이즈: Canvas 2D 랜덤 픽셀 (mix-blend-mode: overlay)
|
||
- 글리치: CSS transform + opacity 떨림 (JS 제어)
|
||
|
||
### 7.2 WebGL Fragment Shader (고급 옵션)
|
||
|
||
성능이 허용되거나 더 정교한 효과가 필요할 때 사용.
|
||
|
||
```glsl
|
||
// crt.frag
|
||
precision mediump float;
|
||
|
||
uniform float u_time; // elapsed seconds
|
||
uniform float u_glitch; // 0.0 ~ 1.0, 상태 전환 시 1.0에서 시작, ×0.85/frame 감쇠
|
||
uniform float u_amplitude; // 오디오 레벨 0.0 ~ 1.0
|
||
uniform float u_frequency; // 웨이브 주파수 (기본 4.0)
|
||
uniform vec2 u_resolution; // 캔버스 해상도
|
||
|
||
varying vec2 v_uv;
|
||
|
||
// 의사 난수
|
||
float rand(vec2 co) {
|
||
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
|
||
}
|
||
|
||
void main() {
|
||
vec2 uv = v_uv;
|
||
|
||
// ── 글리치: 수평 오프셋 ───────────────────────
|
||
float glitchOffset = u_glitch * (rand(vec2(uv.y * 13.0, u_time)) - 0.5) * 0.05;
|
||
uv.x += glitchOffset;
|
||
|
||
// ── 스캔라인 ──────────────────────────────────
|
||
float scanline = sin(uv.y * u_resolution.y * 1.5) * 0.04;
|
||
|
||
// ── 앰버 인광색 기본 ──────────────────────────
|
||
vec3 amber = vec3(0.949, 0.357, 0.161); // #f25b29
|
||
|
||
// ── 웨이브 표현 (간략) ────────────────────────
|
||
float wave = sin(uv.x * u_frequency * 6.2832 + u_time * 2.0) * u_amplitude * 0.5 + 0.5;
|
||
|
||
// ── 비네팅 ────────────────────────────────────
|
||
float dist = distance(uv, vec2(0.5));
|
||
float vignette = smoothstep(0.7, 0.3, dist);
|
||
|
||
// ── 노이즈 ────────────────────────────────────
|
||
float noise = rand(uv + u_time * 0.01) * 0.05;
|
||
|
||
// ── 합성 ──────────────────────────────────────
|
||
float intensity = wave * vignette - scanline + noise;
|
||
intensity = clamp(intensity, 0.0, 1.0);
|
||
|
||
// ── RGB 채널 분리 (글리치 시) ──────────────────
|
||
float r = intensity;
|
||
float g = intensity;
|
||
float b = intensity;
|
||
if (u_glitch > 0.01) {
|
||
float shift = u_glitch * 0.01;
|
||
r = clamp(intensity + shift, 0.0, 1.0);
|
||
b = clamp(intensity - shift, 0.0, 1.0);
|
||
}
|
||
|
||
gl_FragColor = vec4(amber * vec3(r, g, b), intensity * 0.8);
|
||
}
|
||
```
|
||
|
||
### 7.3 Uniform 변수 제어
|
||
|
||
```typescript
|
||
interface CrtUniforms {
|
||
u_time: number; // performance.now() / 1000
|
||
u_glitch: number; // 상태 전환 시 1.0 → ×0.85/frame → 0.0
|
||
u_amplitude: number; // audioLevel (0.0 ~ 1.0)
|
||
u_frequency: number; // 기본 4.0
|
||
u_resolution: [number, number]; // canvas 크기
|
||
}
|
||
|
||
// 글리치 감쇠 로직
|
||
let glitchAmount = 0;
|
||
|
||
function triggerGlitch() {
|
||
glitchAmount = 1.0;
|
||
}
|
||
|
||
function updateGlitch() {
|
||
glitchAmount *= 0.85;
|
||
if (glitchAmount < 0.01) glitchAmount = 0;
|
||
}
|
||
// updateGlitch()는 매 rAF에서 호출
|
||
```
|
||
|
||
### 7.4 CSS 폴백 파라미터 (WebGL 미사용 시)
|
||
|
||
| 효과 | CSS 구현 | 파라미터 |
|
||
|------|----------|----------|
|
||
| 스캔라인 | `repeating-linear-gradient(0deg, transparent 0 2px, rgba(0,0,0,0.15) 2px 4px)` | 간격 4px, 투명도 0.15 |
|
||
| 비네팅 | `radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.4) 100%)` | 중심 50% 투명 |
|
||
| 노이즈 | Canvas 2D: 4x4 블록, 랜덤 alpha 0~0.03, mix-blend-mode: overlay | 30fps 갱신 |
|
||
| 글리치 | `transform: translateX(${(Math.random()-0.5)*6*glitch}px)`, opacity 깜빡임 | 감쇠율 0.85/frame |
|
||
| 인광 glow | `text-shadow: 0 0 6px rgba(242,91,41,0.6)`, `box-shadow: 0 0 4px` | spread 4~6px |
|
||
|
||
---
|
||
|
||
## 부록: 시안 A/B 요소 매핑 요약
|
||
|
||
| 컴포넌트 | 시안 A 요소 | 시안 B 요소 | D3RO-VOICE 적용 |
|
||
|----------|------------|------------|----------------|
|
||
| Card | 다크 섀시 #242528 | 카드 #242427, radius 22px | 시안 B 카드 스타일 |
|
||
| Button | 물리 눌림 (inset shadow) | - | 시안 A 물리 버튼 느낌 |
|
||
| Tag/Badge | - | pill, 색상코딩 | 시안 B 태그 시스템 |
|
||
| LED | 8px, glow | - | 시안 A LED |
|
||
| WaveformBar | cos 가중치 | 7개 바, scaleY | 시안 B 구조 + 시안 A 앰버색 |
|
||
| RecordingTip | CRT, 스캔라인, 글리치 | - | 시안 A CRT 전적용 |
|
||
| Dashboard | - | 카드 그리드, auto-fill | 시안 B 레이아웃 |
|
||
| StatusPanel | 각인 텍스트 | 서비스 상태 리스트 | 시안 B 리스트 + 시안 A 모노폰트 |
|
||
| ProcessingOverlay | - | blur(4px) + spinner | 시안 B |
|
||
| 컬러: 배경 | #242528 | #19191b, #242427 | 시안 B |
|
||
| 컬러: 악센트 | 앰버 #f25b29 | - | 시안 A |
|
||
| 타이포: 본문 | - | 시스템 폰트 | 시안 B |
|
||
| 타이포: 상태 | 모노스페이스, 각인 | - | 시안 A |
|