feat(ui): Midnight Glass v2 전면 리디자인 — 기반+셸+대시보드
- theme.ts 재작성: glass/gradient/glow 토큰군, 기본 dark=Midnight(블루), 6종 테마 변형 전부 파생 토큰 유지 (테마 시스템 보존) - DS 리스킨(API 호환): 글래스 카드/버튼, Pretendard 타이포, 소프트 LED - 신규: GradientWave(canvas 스펙트럼 음파), StatRing(컬러 링 타일) - 보더리스: frame:false + 커스텀 TitleBar, 네이티브 스크롤바 제거(폭 불변) + OverlayScrollbars 오버레이 스크롤 - AppLayout 와이드 사이드바(lucide, 그라디언트 액티브 필), 대시보드 재구축, StatusBar(브랜드/로컬시간), Pretendard 번들, i18n +20키(ko/en)
This commit is contained in:
parent
f3ca0eafd4
commit
a8a1d6e546
25 changed files with 1638 additions and 960 deletions
|
|
@ -1,11 +1,10 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/ButtonGroup.tsx
|
||||
// 시안 A: 인셋 버튼 클러스터 — 레퍼런스의 .button-group 패턴
|
||||
// 물리 버튼들을 인셋 패널 안에 배치하여 그룹화
|
||||
// v2 "Midnight Glass": 세그먼트 글래스 그룹 — 반투명 표면에 버튼 클러스터
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { d3roPalette, d3roRadius } from '../../theme'
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -20,7 +19,7 @@ export function ButtonGroup({ children, horizontal = false }: ButtonGroupProps):
|
|||
bgcolor: d3roPalette.bg.inset,
|
||||
p: '6px',
|
||||
borderRadius: d3roRadius.inner,
|
||||
boxShadow: d3roShadow.inset,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
display: 'flex',
|
||||
flexDirection: horizontal ? 'row' : 'column',
|
||||
gap: '6px',
|
||||
|
|
|
|||
|
|
@ -1,284 +1,98 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/CrtDisplay.tsx
|
||||
// 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형)
|
||||
// v2 "Midnight Glass": 히어로 디스플레이 — 딥 글래스 패널 + 그라디언트 스펙트럼 웨이브.
|
||||
// (v1 WebGL 오실로스코프를 대체. props API는 v1과 호환 유지.)
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme'
|
||||
|
||||
// ── WebGL 유틸 ─────────────────────────────────────────
|
||||
|
||||
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource)
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource)
|
||||
if (!vs || !fs) return null
|
||||
const prog = gl.createProgram()
|
||||
if (!prog) return null
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
return prog
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
vUv = position * 0.5 + 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec2 vUv;
|
||||
uniform float u_time;
|
||||
uniform float u_glitch;
|
||||
uniform float u_amp;
|
||||
uniform float u_freq;
|
||||
|
||||
float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); }
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Glitch displacement
|
||||
if (u_glitch > 0.0) {
|
||||
float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1;
|
||||
uv.x += gOffset;
|
||||
}
|
||||
|
||||
// Base phosphor background
|
||||
vec3 color = vec3(0.02, 0.015, 0.01);
|
||||
|
||||
// Background Grid
|
||||
float gridX = step(0.98, fract(uv.x * 15.0));
|
||||
float gridY = step(0.98, fract(uv.y * 10.0));
|
||||
color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch);
|
||||
|
||||
// Oscilloscope Waveform
|
||||
float t = u_time * 0.5 + u_glitch * random(uv) * 0.1;
|
||||
float waveY = sin((uv.x * u_freq) + t) * u_amp;
|
||||
float waveDist = abs((uv.y - 0.5) - waveY);
|
||||
float lineThick = 0.005 + (u_glitch * 0.02);
|
||||
float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist);
|
||||
float waveCore = smoothstep(lineThick, 0.0, waveDist);
|
||||
|
||||
// Amber wave (#f25b29)
|
||||
vec3 waveColor = vec3(0.95, 0.35, 0.16);
|
||||
color += waveColor * waveCore;
|
||||
color += waveColor * 0.4 * waveGlow;
|
||||
|
||||
// Scanlines
|
||||
float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04;
|
||||
color -= scanline;
|
||||
|
||||
// Noise
|
||||
float noise = random(uv + u_time) * 0.08;
|
||||
color += noise;
|
||||
|
||||
// Vignette
|
||||
float dist = distance(vUv, vec2(0.5));
|
||||
float vig = smoothstep(0.8, 0.4, dist);
|
||||
color *= vig;
|
||||
|
||||
// Edge darkening
|
||||
color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x);
|
||||
color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||
|
||||
// ── Props ──────────────────────────────────────────────
|
||||
import { d3roPalette, d3roFontSans, d3roRadius } from '../../theme'
|
||||
import { GradientWave } from './GradientWave'
|
||||
|
||||
interface CrtDisplayProps {
|
||||
/** 파형 진폭 (0.0 ~ 1.0) */
|
||||
/** 유휴 파형 진폭 (0.0 ~ 1.0) */
|
||||
amplitude?: number
|
||||
/** 파형 주파수 */
|
||||
/** 파형 속도 배율 (v1 주파수 개념 호환 — 8 기준 1배) */
|
||||
frequency?: number
|
||||
/** 글리치 트리거 (변경 시 글리치 발생) */
|
||||
/** 글리치 트리거 (변경 시 플래시 발생) */
|
||||
glitchTrigger?: number
|
||||
/** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */
|
||||
audioLevel?: number
|
||||
/** 오버레이 콘텐츠 (인광 텍스트 등) */
|
||||
/** 오버레이 콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
/** 높이 (기본 280px) */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function CrtDisplay({
|
||||
amplitude = 0.1,
|
||||
amplitude = 0.25,
|
||||
frequency = 8.0,
|
||||
glitchTrigger = 0,
|
||||
audioLevel = 0,
|
||||
children,
|
||||
height = 280,
|
||||
}: CrtDisplayProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const glRef = useRef<{
|
||||
gl: WebGLRenderingContext
|
||||
uTime: WebGLUniformLocation | null
|
||||
uGlitch: WebGLUniformLocation | null
|
||||
uAmp: WebGLUniformLocation | null
|
||||
uFreq: WebGLUniformLocation | null
|
||||
} | null>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const startTimeRef = useRef(Date.now())
|
||||
const glitchRef = useRef(0)
|
||||
const ampRef = useRef(amplitude)
|
||||
const freqRef = useRef(frequency)
|
||||
const audioLevelRef = useRef(audioLevel)
|
||||
const currentAmpRef = useRef(amplitude)
|
||||
const currentFreqRef = useRef(frequency)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
// amplitude/frequency/audioLevel 변경 추적
|
||||
useEffect(() => {
|
||||
ampRef.current = amplitude
|
||||
freqRef.current = frequency
|
||||
}, [amplitude, frequency])
|
||||
|
||||
useEffect(() => {
|
||||
audioLevelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
// 글리치 트리거
|
||||
// v1 글리치 → v2 소프트 플래시
|
||||
useEffect(() => {
|
||||
if (glitchTrigger > 0) {
|
||||
glitchRef.current = 1.0
|
||||
setFlash(true)
|
||||
const timer = setTimeout(() => setFlash(false), 220)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [glitchTrigger])
|
||||
|
||||
const render = useCallback(() => {
|
||||
const ctx = glRef.current
|
||||
if (!ctx) return
|
||||
|
||||
const { gl, uTime, uGlitch, uAmp, uFreq } = ctx
|
||||
|
||||
// audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭
|
||||
const targetAmp = ampRef.current + audioLevelRef.current * 0.35
|
||||
|
||||
// Smoothing
|
||||
currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15
|
||||
currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1
|
||||
glitchRef.current *= 0.85
|
||||
|
||||
gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000)
|
||||
gl.uniform1f(uGlitch, glitchRef.current)
|
||||
gl.uniform1f(uAmp, currentAmpRef.current)
|
||||
gl.uniform1f(uFreq, currentFreqRef.current)
|
||||
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const gl = canvas.getContext('webgl', { alpha: true })
|
||||
if (!gl) return
|
||||
|
||||
canvas.width = canvas.clientWidth * 2
|
||||
canvas.height = canvas.clientHeight * 2
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
if (!prog) return
|
||||
|
||||
gl.useProgram(prog)
|
||||
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW)
|
||||
|
||||
const posLoc = gl.getAttribLocation(prog, 'position')
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
glRef.current = {
|
||||
gl,
|
||||
uTime: gl.getUniformLocation(prog, 'u_time'),
|
||||
uGlitch: gl.getUniformLocation(prog, 'u_glitch'),
|
||||
uAmp: gl.getUniformLocation(prog, 'u_amp'),
|
||||
uFreq: gl.getUniformLocation(prog, 'u_freq'),
|
||||
}
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
}
|
||||
}, [render])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height,
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '8px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Glass surface */}
|
||||
{/* 앰비언트 배경 글로우 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '6px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
overflow: 'hidden',
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
inset: 0,
|
||||
background: `radial-gradient(ellipse 70% 55% at 50% 100%, ${d3roPalette.accent.dim} 0%, transparent 70%)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 라이트 모드에서 WebGL 셰이더(다크 전용)를 반전하여 밝은 배경에 어울리게 조정
|
||||
...(isLight && { filter: 'invert(0.88) hue-rotate(180deg)', opacity: 0.9 }),
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
|
||||
{/* Glass reflection */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
{/* 그라디언트 스펙트럼 웨이브 — 하단 영역 */}
|
||||
<Box sx={{ position: 'absolute', left: 12, right: 12, bottom: 8, height: '38%' }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={amplitude} speed={frequency / 8} />
|
||||
</Box>
|
||||
|
||||
{/* Content overlay (phosphor text) */}
|
||||
{/* 상단 시인 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 플래시 오버레이 (글리치 호환) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: d3roPalette.accent.dim,
|
||||
opacity: flash ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 오버레이 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
|
@ -287,10 +101,9 @@ export function CrtDisplay({
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
color: d3roPalette.accent.amber,
|
||||
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
||||
color: d3roPalette.text.primary,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: d3roFontMono,
|
||||
fontFamily: d3roFontSans,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
165
packages/ui/src/components/ds/GradientWave.tsx
Normal file
165
packages/ui/src/components/ds/GradientWave.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/GradientWave.tsx
|
||||
// v2 시그니처 비주얼: 시안→블루→퍼플→마젠타 그라디언트 스펙트럼 웨이브.
|
||||
// canvas 2D — 테마 CSS 변수(--d3-gradient-wave1..4)를 읽어 테마 전환에 자동 반응.
|
||||
// 미러형(중앙선 기준 상하 대칭) 바 스펙트럼 + 소프트 글로우 + 유휴 애니메이션.
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
interface GradientWaveProps {
|
||||
/** 실시간 오디오 레벨 0~1 (없으면 유휴 애니메이션만) */
|
||||
audioLevel?: number
|
||||
/** 유휴 상태 기본 진폭 0~1 (기본 0.25) */
|
||||
idleAmplitude?: number
|
||||
/** 애니메이션 속도 배율 (기본 1) */
|
||||
speed?: number
|
||||
/** 바 폭 px (기본 3) */
|
||||
barWidth?: number
|
||||
/** 바 간격 px (기본 3) */
|
||||
barGap?: number
|
||||
/** 글로우 강도 px (기본 8, 0이면 비활성) */
|
||||
glow?: number
|
||||
/** 높이 (CSS 값, 기본 '100%') */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
/** :root에 주입된 웨이브 그라디언트 스톱을 읽는다 */
|
||||
function readWaveColors(): [string, string, string, string] {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const read = (name: string, fallback: string): string =>
|
||||
style.getPropertyValue(name).trim() || fallback
|
||||
return [
|
||||
read('--d3-gradient-wave1', '#22d3ee'),
|
||||
read('--d3-gradient-wave2', '#3b82f6'),
|
||||
read('--d3-gradient-wave3', '#8b5cf6'),
|
||||
read('--d3-gradient-wave4', '#e879f9'),
|
||||
]
|
||||
}
|
||||
|
||||
/** 결정적 의사난수 (바 인덱스별 고정 시드) */
|
||||
function seeded(i: number): number {
|
||||
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
export function GradientWave({
|
||||
audioLevel = 0,
|
||||
idleAmplitude = 0.25,
|
||||
speed = 1,
|
||||
barWidth = 3,
|
||||
barGap = 3,
|
||||
glow = 8,
|
||||
height = '100%',
|
||||
}: GradientWaveProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const levelRef = useRef(audioLevel)
|
||||
const smoothedRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
levelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let width = 0
|
||||
let heightPx = 0
|
||||
let colors = readWaveColors()
|
||||
let gradient: CanvasGradient | null = null
|
||||
let frame = 0
|
||||
const start = performance.now()
|
||||
|
||||
const rebuildGradient = (): void => {
|
||||
gradient = ctx.createLinearGradient(0, 0, width, 0)
|
||||
gradient.addColorStop(0, colors[0])
|
||||
gradient.addColorStop(0.38, colors[1])
|
||||
gradient.addColorStop(0.7, colors[2])
|
||||
gradient.addColorStop(1, colors[3])
|
||||
}
|
||||
|
||||
const resize = (): void => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
width = canvas.clientWidth
|
||||
heightPx = canvas.clientHeight
|
||||
canvas.width = Math.max(1, Math.round(width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(heightPx * dpr))
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
rebuildGradient()
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(canvas)
|
||||
resize()
|
||||
|
||||
const render = (): void => {
|
||||
frame += 1
|
||||
// 테마 전환 대응: 1초마다 색 재판독
|
||||
if (frame % 60 === 0) {
|
||||
const next = readWaveColors()
|
||||
if (next.join() !== colors.join()) {
|
||||
colors = next
|
||||
rebuildGradient()
|
||||
}
|
||||
}
|
||||
|
||||
// 레벨 스무딩 (상승 빠르게, 하강 느리게)
|
||||
const target = Math.min(1, levelRef.current)
|
||||
const k = target > smoothedRef.current ? 0.4 : 0.08
|
||||
smoothedRef.current += (target - smoothedRef.current) * k
|
||||
|
||||
ctx.clearRect(0, 0, width, heightPx)
|
||||
if (!gradient || width <= 0) {
|
||||
raf = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
const t = ((performance.now() - start) / 1000) * speed
|
||||
const step = barWidth + barGap
|
||||
const count = Math.max(1, Math.floor(width / step))
|
||||
const centerY = heightPx / 2
|
||||
const maxHalf = heightPx / 2 - 1
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
if (glow > 0) {
|
||||
ctx.shadowBlur = glow
|
||||
ctx.shadowColor = colors[1]
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = i * step
|
||||
const s = seeded(i)
|
||||
// 유휴: 느리게 흐르는 다중 사인 + 바별 시드 → 오가닉한 스펙트럼
|
||||
const idle =
|
||||
(Math.sin(t * 1.4 + i * 0.35) * 0.5 + 0.5) * 0.55 +
|
||||
(Math.sin(t * 0.7 + i * 0.11 + s * 6.28) * 0.5 + 0.5) * 0.45
|
||||
const envelope = idleAmplitude * (0.25 + idle * 0.75) + smoothedRef.current * (0.4 + s * 0.6)
|
||||
const half = Math.max(1.5, Math.min(1, envelope) * maxHalf)
|
||||
const radius = Math.min(barWidth / 2, half)
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, centerY - half, barWidth, half * 2, radius)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0
|
||||
raf = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [idleAmplitude, speed, barWidth, barGap, glow])
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height, minHeight: 0 }}>
|
||||
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/InstrumentPanel.tsx
|
||||
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
||||
// v2 "Midnight Glass": 히어로 글래스 패널 — 앰비언트 그라디언트 배경 + 헤어라인.
|
||||
// v1의 각인(engraving) props는 호환 유지하되 모서리 메타 라벨로 은은하게 렌더.
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface InstrumentPanelProps {
|
||||
children: React.ReactNode
|
||||
/** 상단 좌측 각인 */
|
||||
/** 상단 좌측 메타 라벨 */
|
||||
engravingLeft?: string
|
||||
/** 상단 우측 각인 */
|
||||
/** 상단 우측 메타 라벨 */
|
||||
engravingRight?: string
|
||||
/** 하단 중앙 각인 */
|
||||
/** 하단 중앙 메타 라벨 */
|
||||
engravingBottom?: string
|
||||
}
|
||||
|
||||
|
|
@ -27,30 +27,30 @@ export function InstrumentPanel({
|
|||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
bgcolor: d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
borderRadius: d3roRadius.outer,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
p: 3,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
// 메탈 노이즈는 CSS로 시뮬레이션
|
||||
boxShadow: d3roShadow.card,
|
||||
overflow: 'hidden',
|
||||
// 앰비언트 액센트 글로우 (좌상단 은은한 빛)
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: d3roRadius.outer,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
opacity: 0.04,
|
||||
mixBlendMode: 'overlay',
|
||||
borderRadius: 'inherit',
|
||||
background: `radial-gradient(ellipse 60% 40% at 18% 0%, ${d3roPalette.accent.dim} 0%, transparent 60%)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 각인 텍스트 */}
|
||||
{/* 모서리 메타 라벨 */}
|
||||
<Engraving sx={{ top: 12, left: 24 }}>{engravingLeft}</Engraving>
|
||||
<Engraving sx={{ top: 12, right: 24 }}>{engravingRight}</Engraving>
|
||||
<Engraving sx={{ bottom: 12, left: '50%', transform: 'translateX(-50%)' }}>{engravingBottom}</Engraving>
|
||||
|
||||
{/* 콘텐츠 (z-index 5로 노이즈 위) */}
|
||||
<Box sx={{ position: 'relative', zIndex: 5 }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
|
@ -59,20 +59,17 @@ export function InstrumentPanel({
|
|||
}
|
||||
|
||||
function Engraving({ children, sx }: { children: string; sx: Record<string, unknown> }): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
color: d3roPalette.text.engraving,
|
||||
textShadow: isLight
|
||||
? '0 -1px 0 rgba(0,0,0,0.1)'
|
||||
: '0 1px 0 rgba(255,255,255,0.08)',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
opacity: 0.7,
|
||||
fontWeight: 700,
|
||||
fontFamily: d3roFontMono,
|
||||
textTransform: 'uppercase',
|
||||
zIndex: 2,
|
||||
userSelect: 'none',
|
||||
...sx,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/Led.tsx
|
||||
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
||||
// v2 "Midnight Glass": 상태 도트 — 소프트 글로우, 활성 시 pulse
|
||||
// (레퍼런스의 "● READY" / "● 시스템 정상" 인디케이터)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
|
||||
|
||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
|
||||
// 'amber'는 v1 호환 별칭 — 현재 테마의 프라이머리 액센트 색으로 렌더
|
||||
amber: { bg: d3roPalette.accent.main, glow: d3roPalette.accent.glow },
|
||||
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
||||
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
||||
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
||||
blue: { bg: d3roPalette.tag.blue, glow: d3roPalette.tag.blueGlow },
|
||||
purple: { bg: d3roPalette.tag.purple, glow: d3roPalette.tag.purpleGlow },
|
||||
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
||||
}
|
||||
|
||||
|
|
@ -34,16 +38,14 @@ export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React
|
|||
borderRadius: '50%',
|
||||
bgcolor: c.bg,
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive
|
||||
? `inset 0 1px 2px rgba(255,255,255,0.5), 0 0 10px ${c.glow}`
|
||||
: 'inset 0 1px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.05)',
|
||||
transition: 'all 0.1s',
|
||||
boxShadow: isActive ? `0 0 ${size * 1.2}px ${c.glow}` : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(pulse && isActive
|
||||
? {
|
||||
animation: 'led-pulse 1.5s ease-in-out infinite',
|
||||
animation: 'led-pulse 1.6s ease-in-out infinite',
|
||||
'@keyframes led-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
'50%': { opacity: 0.45 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/MetalCard.tsx
|
||||
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
||||
// 토큰 적용: d3roShadow, d3roRadius
|
||||
// packages/ui/src/components/ds/MetalCard.tssx
|
||||
// v2 "Midnight Glass": 글래스모피즘 카드 — 반투명 표면 + 옅은 헤어라인 + 상단 시인
|
||||
// 토큰 적용: d3roPalette.glass, d3roShadow, d3roRadius
|
||||
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
|
||||
interface MetalCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** 카드 내부의 상승 표면 (카드 안의 카드) */
|
||||
inset?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -21,14 +22,27 @@ export function MetalCard({
|
|||
<Box
|
||||
{...boxProps}
|
||||
sx={{
|
||||
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
position: 'relative',
|
||||
bgcolor: inset ? d3roPalette.glass.raised : d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
||||
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: inset ? d3roShadow.inset : d3roShadow.card,
|
||||
p: inset ? '6px' : 3,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: inset ? 'none' : d3roShadow.card,
|
||||
p: inset ? 2 : 3,
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
|
||||
transition: 'border-color 0.2s ease',
|
||||
// 상단 시인(광택) 오버레이
|
||||
'&::before': inset
|
||||
? {}
|
||||
: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': inset ? {} : { borderColor: d3roPalette.glass.hairlineStrong },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhosphorText.tsx
|
||||
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
||||
// 확장: title/stat/body/compact/meta/engrave/micro/nano 변형 추가
|
||||
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
|
||||
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
|
||||
// (변형 API는 v1과 동일 — 전 페이지 호환)
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
|
||||
|
||||
type PhosphorVariant =
|
||||
| 'hero' | 'title' | 'value' | 'heading'
|
||||
|
|
@ -16,31 +17,27 @@ type PhosphorVariant =
|
|||
interface VariantDef {
|
||||
fontSize: string
|
||||
color: string
|
||||
glow: string
|
||||
fontWeight: number
|
||||
letterSpacing: string
|
||||
lineHeight: number
|
||||
textTransform?: 'uppercase' | 'none'
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
const amberGlowStrong = `0 0 8px ${d3roPalette.accent.amberGlow}`
|
||||
const amberGlowMedium = `0 0 6px rgba(242, 91, 41, 0.4)`
|
||||
const amberGlowSoft = `0 0 4px rgba(242, 91, 41, 0.3)`
|
||||
|
||||
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.accent.amber, glow: amberGlowStrong, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.accent.amber, glow: amberGlowMedium, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.accent.amber, glow: amberGlowSoft, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
||||
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase' },
|
||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
||||
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.engraving, glow: 'none', fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase' },
|
||||
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase' },
|
||||
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase' },
|
||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
||||
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase', mono: true },
|
||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
||||
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase', mono: true },
|
||||
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase', mono: true },
|
||||
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase', mono: true },
|
||||
}
|
||||
|
||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||
|
|
@ -54,11 +51,10 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
|
|||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
|
||||
fontSize: v.fontSize,
|
||||
fontWeight: v.fontWeight,
|
||||
color: v.color,
|
||||
textShadow: v.glow !== 'none' ? v.glow : 'none',
|
||||
letterSpacing: v.letterSpacing,
|
||||
lineHeight: v.lineHeight,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhysicalButton.tsx
|
||||
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
||||
// 토큰 적용: d3roShadow, d3roRadius, d3roTypo
|
||||
// v2 "Midnight Glass": 글래스 버튼 — 기본은 반투명 표면 + 헤어라인,
|
||||
// selected 시 액센트 그라디언트 + 글로우 (레퍼런스 프라이머리 버튼)
|
||||
|
||||
import { Button, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
selected?: boolean
|
||||
|
|
@ -16,27 +16,29 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
|
|||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
height: 44,
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
|
||||
border: 'none',
|
||||
borderRadius: d3roRadius.small,
|
||||
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontFamily: d3roFontMono,
|
||||
height: 42,
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.glass.raised,
|
||||
backgroundImage: selected ? d3roPalette.gradient.accent : 'none',
|
||||
border: `1px solid ${selected ? 'transparent' : d3roPalette.glass.hairline}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
color: selected ? '#fff' : d3roPalette.text.secondary,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.small.size,
|
||||
fontWeight: d3roTypo.small.weight,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised,
|
||||
transform: selected ? 'translateY(1px)' : 'none',
|
||||
transition: 'all 0.05s linear',
|
||||
boxShadow: selected ? d3roShadow.glowAccent : 'none',
|
||||
transition: 'filter 0.15s ease, border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonActive,
|
||||
filter: 'brightness(0.92)',
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.bg.cardHover,
|
||||
borderColor: selected ? 'transparent' : d3roPalette.glass.hairlineStrong,
|
||||
color: selected ? '#fff' : d3roPalette.text.primary,
|
||||
filter: selected ? 'brightness(1.08)' : 'none',
|
||||
},
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
textTransform: 'none',
|
||||
letterSpacing: d3roTypo.small.spacing,
|
||||
minWidth: 0,
|
||||
...sx,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/ScreenPanel.tsx
|
||||
// 시안 A: CRT 없는 순수 스크린 패널 — 인셋 베젤 + 글래스 반사 + 인광 텍스트용
|
||||
// 레퍼런스의 .display-module > .screen-glass 패턴
|
||||
// v2 "Midnight Glass": 딥 글래스 패널 — 카드 내부의 어두운 정보 표시 영역
|
||||
// (레퍼런스의 히어로 카드 내부 / "현재 백엔드" 내부 패널 패턴)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roShadow } from '../../theme'
|
||||
import { d3roPalette, d3roRadius } from '../../theme'
|
||||
|
||||
interface ScreenPanelProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -15,45 +14,26 @@ interface ScreenPanelProps {
|
|||
}
|
||||
|
||||
export function ScreenPanel({ children, height }: ScreenPanelProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '12px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
overflow: 'hidden',
|
||||
height,
|
||||
// 상단 시인
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 글래스 배경 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
// 상단 반사 (레퍼런스의 .screen-glass::after)
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 오버레이 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
|
|
|
|||
66
packages/ui/src/components/ds/StatRing.tsx
Normal file
66
packages/ui/src/components/ds/StatRing.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/StatRing.tsx
|
||||
// v2 신규: 컬러 그라디언트 링 아이콘 — 스탯 타일의 원형 링 (레퍼런스 4타일 패턴)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type RingColor = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'accent'
|
||||
|
||||
const RING_COLORS: Record<RingColor, { from: string; to: string; glow: string; icon: string }> = {
|
||||
blue: { from: '#22d3ee', to: '#3b82f6', glow: 'rgba(59,130,246,0.35)', icon: d3roPalette.tag.blue },
|
||||
purple: { from: '#8b5cf6', to: '#d946ef', glow: 'rgba(167,139,250,0.35)', icon: d3roPalette.tag.purple },
|
||||
green: { from: '#34d399', to: '#10b981', glow: 'rgba(52,211,153,0.35)', icon: d3roPalette.tag.green },
|
||||
orange: { from: '#fbbf24', to: '#f97316', glow: 'rgba(251,146,60,0.35)', icon: d3roPalette.tag.orange },
|
||||
red: { from: '#f87171', to: '#ef4444', glow: 'rgba(248,113,113,0.35)', icon: d3roPalette.tag.red },
|
||||
accent: { from: 'var(--d3-accent-light)', to: 'var(--d3-accent-dark)', glow: 'var(--d3-accent-dim)', icon: d3roPalette.accent.light },
|
||||
}
|
||||
|
||||
interface StatRingProps {
|
||||
/** 링 색 (기본 blue) */
|
||||
color?: RingColor
|
||||
/** 외경 px (기본 56) */
|
||||
size?: number
|
||||
/** 링 두께 px (기본 2) */
|
||||
thickness?: number
|
||||
/** 중앙 아이콘/콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function StatRing({
|
||||
color = 'blue',
|
||||
size = 56,
|
||||
thickness = 2,
|
||||
children,
|
||||
}: StatRingProps): React.ReactElement {
|
||||
const c = RING_COLORS[color]
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
p: `${thickness}px`,
|
||||
background: `conic-gradient(from 210deg, ${c.from}, ${c.to}, ${c.from})`,
|
||||
boxShadow: `0 0 ${size / 3.5}px ${c.glow}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: c.icon,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,3 +10,6 @@ export { PhosphorText } from './PhosphorText'
|
|||
export { MetalDial } from './MetalDial'
|
||||
export { ScreenPanel } from './ScreenPanel'
|
||||
export { ButtonGroup } from './ButtonGroup'
|
||||
// v2 "Midnight Glass" 신규
|
||||
export { GradientWave } from './GradientWave'
|
||||
export { StatRing } from './StatRing'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue