d3ro-voice/docs/design/08-design-system.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- 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개 문서
2026-04-05 01:03:03 +09:00

1095 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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