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

28 KiB
Raw Permalink Blame History

08. D3RO-VOICE 디자인 시스템

시안 A (Meteorological Instrument)와 시안 B (VoiceOps Dashboard)를 융합한 전용 디자인 시스템. 03-db-and-ui.md의 MUI 테마를 완전 교체한다.


1. 컬러 팔레트

1.1 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 폰트 스택

: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 라벨 스타일

.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 모노 숫자 스타일

.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 기반. 전체 레이아웃의 기본 컨테이너.

.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 형태의 상태 태그.

.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의 물리 버튼 느낌. 눌림 피드백 중시.

.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 기반. 서비스 상태 표시등.

.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의 앰버색.

.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로 높이 제어 */
// 웨이브폼 파라미터
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 팝업에 적용.

.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의 카드 그리드 + 헤더 구조.

.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의 서비스 상태 리스트 카드.

.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 처리 중 표시.

.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

transition: background-color 0.2s ease;

4.4 버튼 press

transition: transform 0.05s linear, box-shadow 0.05s linear;
/* active: translateY(2px) */

4.5 스피너

animation: spin 1s linear infinite;
/* rotate(360deg) */

4.6 프로그레스 바 (점근 수렴)

// 시간 기반: 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

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

.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 반응형

/* 태블릿 (< 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()을 이 디자인 시스템으로 완전 교체한다. 라이트 테마를 제거하고 다크 전용으로 운영한다.

// 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 (고급 옵션)

성능이 허용되거나 더 정교한 효과가 필요할 때 사용.

// 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 변수 제어

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