- 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개 문서
28 KiB
28 KiB
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 |