feat(ui): flat "quiet instrument" token v4 with a single raw-value SSOT
- Add theme-raw.ts (no MUI/DOM deps) as the one source of theme values. Each theme declares only surfaces, 3-tier text, borders, accent and overlay shadow; legacy glass/gradient/glow/crt keys are derived as flat values so existing call sites lose effects without edits. - Tertiary text now clears 4.5:1 on every surface in all six themes (label/inactive/dimLabel/muted alias it). Filled buttons use accent.solid + accent.ink (5.17:1 on dark/light, was 3.68:1). - theme-vars.ts derives popup CSS from the same raw values instead of a hand copy, and defines the previously undefined --d3-overlay-strong and --d3-accent-glow-dim. - MUI: no ripple, global focus-visible ring and reduced-motion rule, flat Card, status colors per theme, shape radius 16 -> 6. - DS: flatten MetalCard/DoubleBezelCard/ScreenPanel/TiltCard, tone-based PhysicalButton (500 weight, no lift), TactileBadge with zero tracking and keyboard support, glow-free Led/StatRing, idle wave amplitude 0. - PhosphorText drops mono/uppercase variants: mono has no Hangul glyphs, which spaced out Korean labels. - design.md: record the 2026-09-28 redesign brief, audit, IA and tokens.
This commit is contained in:
parent
c3367bc611
commit
2428ede03d
15 changed files with 969 additions and 1515 deletions
|
|
@ -15,6 +15,10 @@
|
|||
"types": "./src/theme.ts",
|
||||
"default": "./src/theme.ts"
|
||||
},
|
||||
"./theme-raw": {
|
||||
"types": "./src/theme-raw.ts",
|
||||
"default": "./src/theme-raw.ts"
|
||||
},
|
||||
"./theme-vars": {
|
||||
"types": "./src/theme-vars.ts",
|
||||
"default": "./src/theme-vars.ts"
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/DoubleBezelCard.tsx
|
||||
// High-End Agency / Awwwards-Tier Double-Bezel (Doppelrand) Enclosure Component
|
||||
// Outer Shell: Precision machined tray with outer radius & ambient hairline
|
||||
// Inner Core: Floating glass plate with concentric inner radius, specular highlight & blur
|
||||
// v4 "조용한 계기": 이중 베젤을 걷어낸 단일 카드. 이름과 props 는 호환을 위해 유지한다.
|
||||
// bezelPadding 은 무시된다(바깥 틀이 없다). interactive 는 선·배경 변화만 — 떠오르지 않는다.
|
||||
|
||||
import React from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { d3roPalette, d3roRadius, d3roTransition } from '../../theme'
|
||||
|
||||
export interface DoubleBezelCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** Outer padding between tray and core (default: 6px) */
|
||||
/** @deprecated v4: 바깥 틀이 없어 쓰이지 않는다 */
|
||||
bezelPadding?: number | string
|
||||
/** Whether the card shows an interactive lift / glow on hover */
|
||||
/** 누를 수 있는 카드 — hover 시 선과 배경만 바뀐다 */
|
||||
interactive?: boolean
|
||||
/** Inner core padding (default: 3 / 24px) */
|
||||
/** 안쪽 여백 (기본 3 = 24px) */
|
||||
innerPadding?: number | string
|
||||
/** Content to render inside the inner core */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DoubleBezelCard({
|
||||
bezelPadding = '6px',
|
||||
bezelPadding: _bezelPadding,
|
||||
interactive = false,
|
||||
innerPadding = 3,
|
||||
children,
|
||||
|
|
@ -33,69 +31,21 @@ export function DoubleBezelCard({
|
|||
{...boxProps}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: d3roRadius.doubleBezelOuter,
|
||||
p: bezelPadding,
|
||||
bgcolor: d3roPalette.glass.raised,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: interactive ? d3roShadow.card : 'none',
|
||||
transition: 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease, border-color 0.3s ease',
|
||||
borderRadius: d3roRadius.card,
|
||||
p: innerPadding,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
transition: d3roTransition('border-color', 'background-color'),
|
||||
...(interactive
|
||||
? {
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: d3roPalette.glass.hairlineStrong,
|
||||
boxShadow: d3roShadow.glowCardHover,
|
||||
transform: 'translateY(-2px)',
|
||||
'& .d3-inner-core': {
|
||||
bgcolor: d3roPalette.bg.cardHover,
|
||||
},
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px) scale(0.99)',
|
||||
},
|
||||
'&:hover': { borderColor: d3roPalette.border.strong, bgcolor: d3roPalette.bg.cardHover },
|
||||
}
|
||||
: {}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{/* Inner Core Container */}
|
||||
<Box
|
||||
className="d3-inner-core"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: d3roRadius.doubleBezelInner,
|
||||
bgcolor: d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
boxShadow: `${d3roShadow.inset}, ${d3roShadow.glowCard}`,
|
||||
p: innerPadding,
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.25s ease',
|
||||
// Top Sheen + Specular highlight
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// 1px Gradient Hairline Rim
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
padding: '1px',
|
||||
background: d3roPalette.glass.borderGradient,
|
||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', zIndex: 1 }}>{children}</Box>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ function seeded(i: number): number {
|
|||
|
||||
export function GradientWave({
|
||||
audioLevel = 0,
|
||||
idleAmplitude = 0.25,
|
||||
idleAmplitude = 0,
|
||||
speed = 1,
|
||||
barWidth = 3,
|
||||
barGap = 3,
|
||||
glow = 8,
|
||||
glow = 0,
|
||||
height = '100%',
|
||||
}: GradientWaveProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/Led.tsx
|
||||
// v2 "Midnight Glass": 상태 도트 — 소프트 글로우, 활성 시 pulse
|
||||
// (레퍼런스의 "● READY" / "● 시스템 정상" 인디케이터)
|
||||
// packages/ui/src/components/ds/Led.tsx
|
||||
// v4 "조용한 계기": 상태 점. 번짐(글로우) 없음. 색만으로 상태를 말하지 않는다 — 옆에 글자를 둔다.
|
||||
// pulse 는 진행 중(녹음·연결 중)에만. 감소 모션에서는 전역 규칙으로 멈춘다.
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
|
||||
|
||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||
// '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' },
|
||||
const LED_COLORS: Record<LedColor, string> = {
|
||||
// 'amber'는 v1 호환 별칭 — 현재 테마의 액센트
|
||||
amber: d3roPalette.accent.main,
|
||||
green: d3roPalette.status.success,
|
||||
red: d3roPalette.status.danger,
|
||||
orange: d3roPalette.status.warning,
|
||||
blue: d3roPalette.accent.main,
|
||||
purple: d3roPalette.tag.purple,
|
||||
off: d3roPalette.text.disabled,
|
||||
}
|
||||
|
||||
interface LedProps {
|
||||
|
|
@ -27,25 +27,23 @@ interface LedProps {
|
|||
}
|
||||
|
||||
export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React.ReactElement {
|
||||
const c = LED_COLORS[color]
|
||||
const isActive = color !== 'off'
|
||||
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.bg,
|
||||
bgcolor: LED_COLORS[color],
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive ? `0 0 ${size * 1.2}px ${c.glow}` : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(pulse && isActive
|
||||
? {
|
||||
animation: 'led-pulse 1.6s ease-in-out infinite',
|
||||
'@keyframes led-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.45 },
|
||||
'50%': { opacity: 0.4 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/MetalCard.tsx
|
||||
// v2 "Midnight Glass": 글래스모피즘 카드 —
|
||||
// 그라디언트 헤어라인 보더(좌상단 발광 → 우하단 소멸) + 앰비언트 글로우 + 상단 시인.
|
||||
// 보더는 mask 링 기법(::after) — 반투명 표면 위에서도 정확한 1px 그라디언트 링.
|
||||
// v4 "조용한 계기": 평평한 카드 — 표면 한 단 + 1px 선. 그림자·블러·광택 없음.
|
||||
// inset: 카드 안의 한 단 들어간 영역 (카드 안의 카드가 아니라 우묵한 판).
|
||||
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { d3roPalette, d3roRadius, d3roTransition } from '../../theme'
|
||||
|
||||
interface MetalCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** 카드 내부의 상승 표면 (카드 안의 카드) */
|
||||
/** 카드 내부의 우묵한 영역 */
|
||||
inset?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -24,41 +23,11 @@ export function MetalCard({
|
|||
{...boxProps}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: inset ? d3roPalette.glass.raised : d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
||||
border: inset ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
|
||||
boxShadow: inset ? 'none' : d3roShadow.glowCard,
|
||||
border: `1px solid ${inset ? d3roPalette.border.subtle : d3roPalette.border.default}`,
|
||||
p: inset ? 2 : 3,
|
||||
overflow: 'hidden',
|
||||
transition: 'box-shadow 0.25s ease',
|
||||
// 상단 시인 + 앰비언트 라디얼 (theme sheen 토큰이 레이어 결합)
|
||||
'&::before': inset
|
||||
? {}
|
||||
: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// 그라디언트 헤어라인 링 — padding 1px + mask exclude로 링만 남김
|
||||
'&::after': inset
|
||||
? {}
|
||||
: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
padding: '1px',
|
||||
background: d3roPalette.glass.borderGradient,
|
||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': inset ? {} : { boxShadow: d3roShadow.glowCardHover },
|
||||
transition: d3roTransition('border-color', 'background-color'),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhosphorText.tsx
|
||||
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
|
||||
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
|
||||
// packages/ui/src/components/ds/PhosphorText.tsx
|
||||
// v4 "조용한 계기": 타이포 변형 13종 — 전부 산세리프(Pretendard), 대문자 변환 없음.
|
||||
// 위계는 크기와 명도(text 3단)로 만든다. 한글 라벨에 모노·양수 자간을 쓰지 않는다
|
||||
// (모노 폰트에 한글 글리프가 없어 "누 름 유 지"처럼 벌어졌다 — 2026-09-28 실측).
|
||||
// 수치는 tabular-nums 로 흔들리지 않게.
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo } from '../../theme'
|
||||
|
||||
type PhosphorVariant =
|
||||
| 'hero' | 'title' | 'value' | 'heading'
|
||||
|
|
@ -19,24 +21,30 @@ interface VariantDef {
|
|||
fontWeight: number
|
||||
letterSpacing: string
|
||||
lineHeight: number
|
||||
textTransform?: 'uppercase' | 'none'
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
const def = (key: keyof typeof d3roTypo, color: string): VariantDef => ({
|
||||
fontSize: d3roTypo[key].size,
|
||||
fontWeight: d3roTypo[key].weight,
|
||||
letterSpacing: d3roTypo[key].spacing,
|
||||
lineHeight: d3roTypo[key].line,
|
||||
color,
|
||||
})
|
||||
|
||||
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
||||
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 },
|
||||
hero: def('hero', d3roPalette.text.primary),
|
||||
title: def('title', d3roPalette.text.primary),
|
||||
value: def('value', d3roPalette.text.primary),
|
||||
heading: def('heading', d3roPalette.text.primary),
|
||||
body: def('body', d3roPalette.text.secondary),
|
||||
compact: def('compact', d3roPalette.text.primary),
|
||||
small: def('small', d3roPalette.text.tertiary),
|
||||
meta: def('meta', d3roPalette.text.tertiary),
|
||||
label: def('label', d3roPalette.text.tertiary),
|
||||
dim: { ...def('label', d3roPalette.text.tertiary), fontWeight: d3roTypo.body.weight },
|
||||
engrave: def('engrave', d3roPalette.text.tertiary),
|
||||
micro: def('micro', d3roPalette.text.tertiary),
|
||||
nano: def('nano', d3roPalette.text.tertiary),
|
||||
}
|
||||
|
||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||
|
|
@ -51,14 +59,14 @@ export function PhosphorText({ variant = 'value', component = 'span', sx, ...pro
|
|||
component={component}
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: v.fontSize,
|
||||
fontWeight: v.fontWeight,
|
||||
color: v.color,
|
||||
letterSpacing: v.letterSpacing,
|
||||
lineHeight: v.lineHeight,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
textTransform: v.textTransform ?? 'none',
|
||||
textTransform: 'none',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,68 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/PhysicalButton.tsx
|
||||
// High-End Tactile Button with Button-in-Button Trailing Icon & Spring Physics
|
||||
// v4 "조용한 계기": 버튼 한 종류, 톤 다섯. 채움은 accent 톤 하나뿐이고 나머지는 선과 글자로 구분한다.
|
||||
// 움직임은 색 변화뿐 — 떠오르거나 튀지 않는다.
|
||||
|
||||
import React from 'react'
|
||||
import { Button, Box, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roRadius, d3roTypo, d3roControl, d3roTransition } from '../../theme'
|
||||
|
||||
export interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
/** Active selected state (renders primary accent gradient) */
|
||||
/** 선택된 상태 (accent 톤으로 렌더) */
|
||||
selected?: boolean
|
||||
/** Visual button tone */
|
||||
/** 시각 톤 */
|
||||
tone?: 'accent' | 'glass' | 'danger' | 'success' | 'ghost'
|
||||
/** Trailing icon rendered in a nested circular capsule (Button-in-Button pattern) */
|
||||
/** 글자 뒤 아이콘 */
|
||||
trailingIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
interface ToneStyle {
|
||||
bg: string
|
||||
bgHover: string
|
||||
color: string
|
||||
colorHover: string
|
||||
border: string
|
||||
}
|
||||
|
||||
const TONES: Record<'accent' | 'glass' | 'danger' | 'success' | 'ghost', ToneStyle> = {
|
||||
accent: {
|
||||
bg: d3roPalette.accent.solid,
|
||||
bgHover: d3roPalette.accent.solidHover,
|
||||
color: d3roPalette.accent.ink,
|
||||
colorHover: d3roPalette.accent.ink,
|
||||
border: 'transparent',
|
||||
},
|
||||
glass: {
|
||||
bg: 'transparent',
|
||||
bgHover: d3roPalette.surface.overlaySoft,
|
||||
color: d3roPalette.text.primary,
|
||||
colorHover: d3roPalette.text.primary,
|
||||
border: d3roPalette.border.strong,
|
||||
},
|
||||
danger: {
|
||||
bg: 'transparent',
|
||||
bgHover: d3roPalette.status.dangerBg,
|
||||
color: d3roPalette.status.danger,
|
||||
colorHover: d3roPalette.status.danger,
|
||||
border: d3roPalette.border.strong,
|
||||
},
|
||||
success: {
|
||||
bg: 'transparent',
|
||||
bgHover: d3roPalette.status.successBg,
|
||||
color: d3roPalette.status.success,
|
||||
colorHover: d3roPalette.status.success,
|
||||
border: d3roPalette.border.strong,
|
||||
},
|
||||
ghost: {
|
||||
bg: 'transparent',
|
||||
bgHover: d3roPalette.surface.overlaySoft,
|
||||
color: d3roPalette.text.secondary,
|
||||
colorHover: d3roPalette.text.primary,
|
||||
border: 'transparent',
|
||||
},
|
||||
}
|
||||
|
||||
export function PhysicalButton({
|
||||
selected = false,
|
||||
tone = 'glass',
|
||||
|
|
@ -24,71 +71,34 @@ export function PhysicalButton({
|
|||
sx,
|
||||
...props
|
||||
}: PhysicalButtonProps): React.ReactElement {
|
||||
const isAccent = selected || tone === 'accent'
|
||||
const isDanger = tone === 'danger'
|
||||
const isSuccess = tone === 'success'
|
||||
|
||||
let bg: string = d3roPalette.glass.raised
|
||||
let bgImage: string = 'none'
|
||||
let textColor: string = d3roPalette.text.primary
|
||||
let borderColor: string = d3roPalette.glass.hairline
|
||||
let shadow: string = 'none'
|
||||
|
||||
if (isAccent) {
|
||||
bg = 'transparent'
|
||||
bgImage = d3roPalette.gradient.accent
|
||||
textColor = d3roPalette.text.inverse
|
||||
borderColor = 'transparent'
|
||||
shadow = d3roShadow.glowAccent
|
||||
} else if (isDanger) {
|
||||
bg = d3roPalette.tag.redBg
|
||||
textColor = d3roPalette.tag.red
|
||||
borderColor = d3roPalette.tag.redGlow
|
||||
} else if (isSuccess) {
|
||||
bg = d3roPalette.tag.greenBg
|
||||
textColor = d3roPalette.tag.green
|
||||
borderColor = d3roPalette.tag.greenGlow
|
||||
}
|
||||
const t = TONES[selected ? 'accent' : tone]
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
height: 40,
|
||||
px: trailingIcon ? 2 : 2.5,
|
||||
bgcolor: bg,
|
||||
backgroundImage: bgImage,
|
||||
border: `1px solid ${borderColor}`,
|
||||
height: d3roControl.h,
|
||||
px: 1.75,
|
||||
bgcolor: t.bg,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${t.border}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
color: textColor,
|
||||
color: t.color,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: shadow,
|
||||
fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
textTransform: 'none',
|
||||
letterSpacing: '0.01em',
|
||||
letterSpacing: 0,
|
||||
minWidth: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.25,
|
||||
transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: isAccent ? undefined : d3roPalette.bg.cardHover,
|
||||
borderColor: isAccent ? 'transparent' : d3roPalette.glass.hairlineStrong,
|
||||
color: isAccent ? d3roPalette.text.inverse : d3roPalette.text.primary,
|
||||
filter: isAccent ? 'brightness(1.08)' : 'none',
|
||||
boxShadow: isAccent ? d3roShadow.glowAccent : d3roShadow.card,
|
||||
transform: 'translateY(-1px)',
|
||||
'& .d3-button-trailing-icon': {
|
||||
transform: 'translateX(2px)',
|
||||
},
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0) scale(0.98)',
|
||||
filter: 'brightness(0.95)',
|
||||
},
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: d3roTransition('background-color', 'color', 'border-color'),
|
||||
'&:hover': { bgcolor: t.bgHover, color: t.colorHover, boxShadow: 'none' },
|
||||
'&.Mui-disabled': { opacity: 0.5, color: t.color, borderColor: t.border },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
|
@ -97,21 +107,10 @@ export function PhysicalButton({
|
|||
</Box>
|
||||
{trailingIcon && (
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden
|
||||
className="d3-button-trailing-icon"
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.bg.inset,
|
||||
border: `1px solid ${isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.border.subtle}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isAccent ? d3roPalette.text.inverse : d3roPalette.text.secondary,
|
||||
transition: 'transform 0.2s ease',
|
||||
flexShrink: 0,
|
||||
'& svg': { width: 14, height: 14 },
|
||||
}}
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', opacity: 0.8, '& svg': { width: 14, height: 14 } }}
|
||||
>
|
||||
{trailingIcon}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/ScreenPanel.tsx
|
||||
// v2 "Midnight Glass": 딥 글래스 패널 — 카드 내부의 어두운 정보 표시 영역
|
||||
// (레퍼런스의 히어로 카드 내부 / "현재 백엔드" 내부 패널 패턴)
|
||||
// packages/ui/src/components/ds/ScreenPanel.tsx
|
||||
// v4 "조용한 계기": 카드 안의 우묵한 표시 영역 — 한 단 어두운 판 + 1px 선. 광택 없음.
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roRadius } from '../../theme'
|
||||
|
|
@ -18,34 +17,17 @@ export function ScreenPanel({ children, height }: ScreenPanelProps): React.React
|
|||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
overflow: 'hidden',
|
||||
height,
|
||||
// 상단 시인
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,41 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/StatRing.tsx
|
||||
// v2 신규: 컬러 그라디언트 링 아이콘 — 스탯 타일의 원형 링 (레퍼런스 4타일 패턴)
|
||||
// packages/ui/src/components/ds/StatRing.tsx
|
||||
// v4 "조용한 계기": 아이콘 자리. 다색 원뿔 그라디언트 링·글로우를 걷어내고 1px 원 + 한 색 아이콘으로.
|
||||
// color 는 호환용 — accent 만 강조색, 나머지는 모두 보조 텍스트색(색으로 항목을 구분하지 않는다).
|
||||
|
||||
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: 'var(--d3-gradient-wave1)', to: 'var(--d3-gradient-wave2)', glow: d3roPalette.tag.blueBg, icon: d3roPalette.tag.blue },
|
||||
purple: { from: 'var(--d3-gradient-wave3)', to: 'var(--d3-gradient-wave4)', glow: d3roPalette.tag.purpleBg, icon: d3roPalette.tag.purple },
|
||||
green: { from: d3roPalette.tag.green, to: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow, icon: d3roPalette.tag.green },
|
||||
orange: { from: d3roPalette.tag.orange, to: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow, icon: d3roPalette.tag.orange },
|
||||
red: { from: d3roPalette.tag.red, to: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow, 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) */
|
||||
/** @deprecated v4: 선은 1px 고정 */
|
||||
thickness?: number
|
||||
/** 중앙 아이콘/콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function StatRing({
|
||||
color = 'blue',
|
||||
size = 56,
|
||||
thickness = 2,
|
||||
children,
|
||||
}: StatRingProps): React.ReactElement {
|
||||
const c = RING_COLORS[color]
|
||||
export function StatRing({ color = 'blue', size = 56, children }: StatRingProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
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}`,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: color === 'accent' ? d3roPalette.accent.light : d3roPalette.text.secondary,
|
||||
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>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/TactileBadge.tsx
|
||||
// Hardware-grade telemetry badge / pill chip with optional LED bead and keycap rendering
|
||||
// v4 "조용한 계기": 작은 상태 표지. 채움은 옅게, 글자는 상태 역할색(테마 반응, 대비 유지).
|
||||
// 한글이 들어가므로 자간 0, 웨이트 500. mono 는 라틴 수치·키 이름에만.
|
||||
|
||||
import React from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { Led } from './Led'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, typoSx } from '../../theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTransition, d3roFocusRing } from '../../theme'
|
||||
|
||||
export interface TactileBadgeProps extends Omit<BoxProps, 'component'> {
|
||||
/** Optional LED indicator color */
|
||||
/** 앞에 붙는 상태 점 */
|
||||
ledColor?: 'green' | 'amber' | 'red' | 'blue' | 'orange' | 'purple' | 'off'
|
||||
/** Whether the LED should pulse */
|
||||
/** 상태 점 깜빡임 (감소 모션에서는 멈춘다) */
|
||||
ledPulse?: boolean
|
||||
/** Semantic tone for border and background */
|
||||
/** 의미 톤 */
|
||||
tone?: 'default' | 'accent' | 'success' | 'warning' | 'error' | 'mono'
|
||||
/** Whether to use monospace font */
|
||||
/** 라틴 수치·키 이름일 때만 */
|
||||
mono?: boolean
|
||||
/** Interactive clickable chip */
|
||||
/** 누를 수 있는 칩 */
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
/** Content to display */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
default: { color: d3roPalette.text.secondary, bg: 'transparent', border: d3roPalette.border.default },
|
||||
accent: { color: d3roPalette.accent.light, bg: d3roPalette.accent.dim, border: 'transparent' },
|
||||
success: { color: d3roPalette.status.success, bg: d3roPalette.status.successBg, border: 'transparent' },
|
||||
warning: { color: d3roPalette.status.warning, bg: d3roPalette.status.warningBg, border: 'transparent' },
|
||||
error: { color: d3roPalette.status.danger, bg: d3roPalette.status.dangerBg, border: 'transparent' },
|
||||
mono: { color: d3roPalette.text.primary, bg: d3roPalette.bg.inset, border: d3roPalette.border.default },
|
||||
} as const
|
||||
|
||||
export function TactileBadge({
|
||||
ledColor,
|
||||
ledPulse = false,
|
||||
|
|
@ -34,73 +43,47 @@ export function TactileBadge({
|
|||
...boxProps
|
||||
}: TactileBadgeProps): React.ReactElement {
|
||||
const isClickable = Boolean(onClick)
|
||||
|
||||
let textColor: string = d3roPalette.text.secondary
|
||||
let borderColor: string = d3roPalette.glass.hairlineStrong
|
||||
let bgColor: string = d3roPalette.glass.raised
|
||||
|
||||
switch (tone) {
|
||||
case 'accent':
|
||||
textColor = d3roPalette.accent.light
|
||||
borderColor = d3roPalette.accent.dim
|
||||
bgColor = d3roPalette.accent.dim
|
||||
break
|
||||
case 'success':
|
||||
textColor = d3roPalette.tag.green
|
||||
borderColor = d3roPalette.tag.greenBg
|
||||
bgColor = d3roPalette.tag.greenBg
|
||||
break
|
||||
case 'warning':
|
||||
textColor = d3roPalette.tag.orange
|
||||
borderColor = d3roPalette.tag.orangeBg
|
||||
bgColor = d3roPalette.tag.orangeBg
|
||||
break
|
||||
case 'error':
|
||||
textColor = d3roPalette.tag.red
|
||||
borderColor = d3roPalette.tag.redBg
|
||||
bgColor = d3roPalette.tag.redBg
|
||||
break
|
||||
case 'mono':
|
||||
textColor = d3roPalette.text.primary
|
||||
borderColor = d3roPalette.border.default
|
||||
bgColor = d3roPalette.bg.inset
|
||||
break
|
||||
}
|
||||
const t = TONES[tone]
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
onClick={onClick}
|
||||
{...(isClickable
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.click()
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.85,
|
||||
px: 1.25,
|
||||
py: 0.45,
|
||||
borderRadius: d3roRadius.badge,
|
||||
border: `1px solid ${borderColor}`,
|
||||
bgcolor: bgColor,
|
||||
color: textColor,
|
||||
...typoSx('meta'),
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
height: 22,
|
||||
borderRadius: d3roRadius.small,
|
||||
border: `1px solid ${t.border}`,
|
||||
bgcolor: t.bg,
|
||||
color: t.color,
|
||||
fontFamily: mono ? d3roFontMono : d3roFontSans,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
transition: 'all 0.15s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
transition: d3roTransition('border-color', 'color'),
|
||||
...(isClickable
|
||||
? {
|
||||
'&:hover': {
|
||||
borderColor: d3roPalette.accent.main,
|
||||
color: d3roPalette.text.primary,
|
||||
filter: 'brightness(1.15)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0) scale(0.97)',
|
||||
},
|
||||
'&:hover': { borderColor: d3roPalette.border.strong, color: d3roPalette.text.primary },
|
||||
'&:focus-visible': d3roFocusRing,
|
||||
}
|
||||
: {}),
|
||||
...sx,
|
||||
|
|
|
|||
|
|
@ -1,175 +1,20 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/TiltCard.tsx
|
||||
// v2 "Midnight Glass": 3D 틸트 + 커서 추종 빛 반사(specular highlight) 카드.
|
||||
// MetalCard의 글래스 표면(동일 토큰)에 마우스 추적 3D 기울임 + 커서 위치에서
|
||||
// 빛이 반사되는 듯한 스페큘러 하이라이트를 더한다. 라이브러리 없이 네이티브
|
||||
// pointer 이벤트 + CSS 변수로 구현(리렌더 0). prefers-reduced-motion 완전 존중.
|
||||
//
|
||||
// 구조: 루트 Box(transform/perspective/표면/blur/링/시인/overflow) +
|
||||
// ::before(앰비언트 시인) + ::after(그라디언트 헤어라인 링) +
|
||||
// 자식 span.d3-tilt-spotlight(커서 추종 스페큘러, z1) + 자식 콘텐츠(z2).
|
||||
// v4 "조용한 계기": 3D 틸트·커서 반사를 걷어냈다. 이름과 props 는 호환용 — 평평한 MetalCard 로 렌더한다.
|
||||
// 근거: 하루 종일 켜 두는 도구에서 커서를 따라 기우는 카드는 정보가 아니라 소음이다(design.md 2026-09-28).
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { type BoxProps } from '@mui/material'
|
||||
import { MetalCard } from './MetalCard'
|
||||
|
||||
interface TiltCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** 최대 기울기 각도(deg). 히어로 8, 작은 타일 6 권장. */
|
||||
/** @deprecated v4: 틸트 없음 */
|
||||
maxTilt?: number
|
||||
/** 커서 추종 스페큘러 하이라이트(빛 반사) 표시 여부. */
|
||||
/** @deprecated v4: 반사 없음 */
|
||||
spotlight?: boolean
|
||||
/** 카드 콘텐츠 */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D 틸트 + 스페큘러 상호작용 엔진.
|
||||
* React state를 쓰지 않고 ref의 CSS 변수를 직접 갱신해 60fps 합성.
|
||||
* prefers-reduced-motion 시 틸트/추종을 모두 비활성화한다.
|
||||
*/
|
||||
function useTiltCard(maxTilt: number) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const reduceRef = useRef(false)
|
||||
|
||||
// OS 설정 변경(런타임 토글)에도 즉시 반응하도록 구독.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('matchMedia' in window)) return
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
reduceRef.current = mq.matches
|
||||
const handler = (e: MediaQueryListEvent): void => {
|
||||
reduceRef.current = e.matches
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (reduceRef.current) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const px = (e.clientX - rect.left) / rect.width // 0..1
|
||||
const py = (e.clientY - rect.top) / rect.height // 0..1
|
||||
// rotateY: 좌우 / rotateX: 상하(반전 → 커서 쪽으로 기울임)
|
||||
el.style.setProperty('--d3-tilt-ry', `${((px - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
|
||||
el.style.setProperty('--d3-tilt-rx', `${(-(py - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
|
||||
el.style.setProperty('--d3-tilt-mx', `${(px * 100).toFixed(1)}%`)
|
||||
el.style.setProperty('--d3-tilt-my', `${(py * 100).toFixed(1)}%`)
|
||||
el.setAttribute('data-tilting', 'true')
|
||||
},
|
||||
[maxTilt],
|
||||
)
|
||||
|
||||
const onPointerLeave = useCallback((): void => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// 변수를 중립으로 리셋한 뒤 data 속성 제거 → 복귀 트랜지션 동작.
|
||||
// (리셋하지 않으면 마지막 각도에 고정됨)
|
||||
el.style.setProperty('--d3-tilt-rx', '0deg')
|
||||
el.style.setProperty('--d3-tilt-ry', '0deg')
|
||||
el.style.setProperty('--d3-tilt-mx', '50%')
|
||||
el.style.setProperty('--d3-tilt-my', '50%')
|
||||
el.removeAttribute('data-tilting')
|
||||
}, [])
|
||||
|
||||
return { ref, onPointerMove, onPointerLeave }
|
||||
}
|
||||
|
||||
export function TiltCard({
|
||||
children,
|
||||
maxTilt = 8,
|
||||
spotlight = true,
|
||||
onPointerMove,
|
||||
onPointerLeave,
|
||||
sx,
|
||||
...boxProps
|
||||
}: TiltCardProps): React.ReactElement {
|
||||
const tilt = useTiltCard(maxTilt)
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={tilt.ref}
|
||||
{...boxProps}
|
||||
onPointerMove={(e) => {
|
||||
onPointerMove?.(e)
|
||||
tilt.onPointerMove(e)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
onPointerLeave?.(e)
|
||||
tilt.onPointerLeave()
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.glass.surface,
|
||||
backdropFilter: `blur(${d3roPalette.glass.blur})`,
|
||||
borderRadius: d3roRadius.card,
|
||||
boxShadow: d3roShadow.glowCard,
|
||||
p: 3,
|
||||
overflow: 'hidden',
|
||||
// perspective() 단일 요소 틸트. 항상 non-none → 안정적 스태킹 컨텍스트.
|
||||
transform:
|
||||
'perspective(900px) rotateX(var(--d3-tilt-rx,0deg)) rotateY(var(--d3-tilt-ry,0deg))',
|
||||
transition: 'transform 0.5s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s ease',
|
||||
// 앰비언트 시인 (정적) — MetalCard와 동일
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
background: d3roPalette.glass.sheen,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// 그라디언트 헤어라인 링 (mask 링 기법) — MetalCard와 동일
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
padding: '1px',
|
||||
background: d3roPalette.glass.borderGradient,
|
||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': { boxShadow: d3roShadow.glowCardHover },
|
||||
// 트래킹 중: transform 트랜지션 제거(즉시 반응) + 합성 레이어 승격
|
||||
'&[data-tilting="true"]': {
|
||||
transition: 'box-shadow 0.25s ease',
|
||||
willChange: 'transform',
|
||||
},
|
||||
// 스페큘러 reveal (Emotion이 plain class를 리라이트하지 않으므로 리터럴 className 사용)
|
||||
'&:hover .d3-tilt-spotlight': { opacity: 1 },
|
||||
// reduced-motion: 틸트·트랜지션·추종 모두 정지
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
'& .d3-tilt-spotlight': { transition: 'none' },
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{spotlight && (
|
||||
<Box
|
||||
component="span"
|
||||
className="d3-tilt-spotlight"
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.25s ease',
|
||||
mixBlendMode: 'screen',
|
||||
background: `radial-gradient(circle 200px at var(--d3-tilt-mx,50%) var(--d3-tilt-my,50%), ${d3roPalette.glass.specular}, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'relative', zIndex: 2 }}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
export function TiltCard({ maxTilt: _maxTilt, spotlight: _spotlight, children, ...rest }: TiltCardProps): React.ReactElement {
|
||||
return <MetalCard {...rest}>{children}</MetalCard>
|
||||
}
|
||||
|
|
|
|||
448
packages/ui/src/theme-raw.ts
Normal file
448
packages/ui/src/theme-raw.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
// packages/ui/src/theme-raw.ts
|
||||
// D3RO 디자인 토큰 원시값 SSOT — v4 "조용한 계기" (2026-09-28).
|
||||
// MUI·DOM 의존이 없다. 렌더러 theme.ts 와 팝업/main 의 theme-vars.ts 가 모두 여기서 값을 받는다.
|
||||
//
|
||||
// v4 원칙 (루트 design.md "2026-09-28 전면 개편"):
|
||||
// - 표면은 평평하다. 위계는 크기·명도·1px 선으로 만든다. 글래스·글로우·그라디언트는 쓰지 않는다.
|
||||
// - 테마마다 적는 것은 기본값(표면·텍스트 3단·경계·액센트)뿐이다. v2 의 glass/gradient/glow 키는
|
||||
// 하위 호환을 위해 남기되 deriveRaw() 가 평면 값으로 파생한다 — 컴포넌트 수백 곳을 고치지 않고 이펙트를 끈다.
|
||||
// - 텍스트 3단(primary/secondary/tertiary)은 모든 표면(app/card/elevated/sidebar)에서 4.5:1 이상.
|
||||
// v2 의 label/inactive/dimLabel/muted 는 tertiary 의 별칭이다. disabled 만 대비 예외.
|
||||
// - accent.solid 는 채운 버튼 배경, accent.ink 는 그 위 글자 — 두 값의 대비 4.5:1 이상.
|
||||
|
||||
export type ThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula'
|
||||
|
||||
/** 테마가 직접 정하는 값. 이것만 테마별로 적는다. */
|
||||
interface ThemeBase {
|
||||
bg: { app: string; card: string; cardHover: string; elevated: string; input: string; sidebar: string; inset: string }
|
||||
text: { primary: string; secondary: string; tertiary: string; disabled: string; hover: string }
|
||||
border: { subtle: string; default: string; strong: string }
|
||||
accent: {
|
||||
main: string
|
||||
/** 어두운 표면 위 강조 글자 (라이트 테마에서는 더 진한 값) */
|
||||
light: string
|
||||
dark: string
|
||||
dim: string
|
||||
/** 채운 버튼 배경 — ink 와 4.5:1 이상 */
|
||||
solid: string
|
||||
solidHover: string
|
||||
ink: string
|
||||
}
|
||||
elevation: { overlay: string; tooltip: string }
|
||||
}
|
||||
|
||||
/** v2 호환 전체 구조 — CSS 변수 이름이 이 구조에서 나온다. */
|
||||
export interface RawTheme {
|
||||
bg: ThemeBase['bg'] & { chassis: string; crtBezel: string; crtGlass: string }
|
||||
text: {
|
||||
primary: string
|
||||
secondary: string
|
||||
tertiary: string
|
||||
label: string
|
||||
disabled: string
|
||||
engraving: string
|
||||
inactive: string
|
||||
dimLabel: string
|
||||
muted: string
|
||||
hover: string
|
||||
}
|
||||
border: ThemeBase['border']
|
||||
shadow: {
|
||||
card: string
|
||||
buttonBase: string
|
||||
chassis: string
|
||||
inset: string
|
||||
insetDeep: string
|
||||
buttonRaised: string
|
||||
buttonPressed: string
|
||||
screenGlow: string
|
||||
tooltip: string
|
||||
overlay: string
|
||||
}
|
||||
accent: ThemeBase['accent'] & { glow: string; crtPhosphor: string; crtPhosphorDim: string }
|
||||
glass: {
|
||||
surface: string
|
||||
raised: string
|
||||
hairline: string
|
||||
hairlineStrong: string
|
||||
sheen: string
|
||||
borderGradient: string
|
||||
blur: string
|
||||
specular: string
|
||||
}
|
||||
gradient: {
|
||||
accent: string
|
||||
logo: string
|
||||
bar: string
|
||||
barAlt: string
|
||||
wave1: string
|
||||
wave2: string
|
||||
wave3: string
|
||||
wave4: string
|
||||
}
|
||||
glow: { accent: string; soft: string; card: string; cardHover: string }
|
||||
}
|
||||
|
||||
const solidFill = (color: string): string => `linear-gradient(${color}, ${color})`
|
||||
|
||||
/** 기본값 → v2 호환 구조. 이펙트 키는 전부 평면 값으로 떨어진다. */
|
||||
function deriveRaw(b: ThemeBase): RawTheme {
|
||||
const none = 'none'
|
||||
return {
|
||||
bg: { ...b.bg, chassis: b.bg.card, crtBezel: b.bg.inset, crtGlass: b.bg.inset },
|
||||
text: {
|
||||
primary: b.text.primary,
|
||||
secondary: b.text.secondary,
|
||||
tertiary: b.text.tertiary,
|
||||
label: b.text.tertiary,
|
||||
inactive: b.text.tertiary,
|
||||
dimLabel: b.text.tertiary,
|
||||
muted: b.text.tertiary,
|
||||
disabled: b.text.disabled,
|
||||
engraving: b.bg.app,
|
||||
hover: b.text.hover,
|
||||
},
|
||||
border: b.border,
|
||||
shadow: {
|
||||
card: none,
|
||||
buttonBase: none,
|
||||
chassis: none,
|
||||
inset: none,
|
||||
insetDeep: none,
|
||||
buttonRaised: none,
|
||||
buttonPressed: none,
|
||||
screenGlow: none,
|
||||
tooltip: b.elevation.tooltip,
|
||||
overlay: b.elevation.overlay,
|
||||
},
|
||||
accent: { ...b.accent, glow: b.accent.dim, crtPhosphor: b.accent.light, crtPhosphorDim: b.accent.dark },
|
||||
glass: {
|
||||
surface: b.bg.card,
|
||||
raised: b.bg.elevated,
|
||||
hairline: b.border.default,
|
||||
hairlineStrong: b.border.strong,
|
||||
sheen: none,
|
||||
borderGradient: solidFill(b.border.default),
|
||||
blur: '0px',
|
||||
specular: 'transparent',
|
||||
},
|
||||
gradient: {
|
||||
accent: solidFill(b.accent.solid),
|
||||
logo: solidFill(b.text.primary),
|
||||
bar: solidFill(b.accent.main),
|
||||
barAlt: solidFill(b.accent.light),
|
||||
wave1: b.accent.light,
|
||||
wave2: b.accent.main,
|
||||
wave3: b.accent.main,
|
||||
wave4: b.accent.light,
|
||||
},
|
||||
glow: { accent: none, soft: none, card: none, cardHover: none },
|
||||
}
|
||||
}
|
||||
|
||||
const BASE: Record<ThemeKey, ThemeBase> = {
|
||||
dark: {
|
||||
bg: { app: '#0a0e1c', card: '#111a30', cardHover: '#152039', elevated: '#1a2540', input: '#0d1526', sidebar: '#080b17', inset: '#0a111f' },
|
||||
text: { primary: '#eef2fb', secondary: '#93a4c8', tertiary: '#7e8eb0', disabled: '#3c4763', hover: '#c9d5f2' },
|
||||
border: { subtle: 'rgba(148,180,255,0.07)', default: 'rgba(148,180,255,0.12)', strong: 'rgba(148,180,255,0.20)' },
|
||||
accent: { main: '#3b82f6', light: '#60a5fa', dark: '#2563eb', dim: 'rgba(59,130,246,0.14)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
|
||||
elevation: { overlay: '0 12px 32px rgba(3,7,18,0.55)', tooltip: '0 8px 24px rgba(3,7,18,0.5)' },
|
||||
},
|
||||
light: {
|
||||
bg: { app: '#f2f5fb', card: '#ffffff', cardHover: '#f7f9fd', elevated: '#eef2f9', input: '#ffffff', sidebar: '#e9eef7', inset: '#e4e9f2' },
|
||||
text: { primary: '#111827', secondary: '#4b5a75', tertiary: '#56647c', disabled: '#b5bfd1', hover: '#2b3a55' },
|
||||
border: { subtle: 'rgba(30,58,138,0.07)', default: 'rgba(30,58,138,0.12)', strong: 'rgba(30,58,138,0.22)' },
|
||||
accent: { main: '#2563eb', light: '#1d4ed8', dark: '#1e40af', dim: 'rgba(37,99,235,0.10)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
|
||||
elevation: { overlay: '0 12px 32px rgba(30,58,138,0.14)', tooltip: '0 8px 24px rgba(30,58,138,0.10)' },
|
||||
},
|
||||
// https://www.nordtheme.com/
|
||||
nord: {
|
||||
bg: { app: '#242933', card: '#2e3440', cardHover: '#343b49', elevated: '#3b4252', input: '#2a303c', sidebar: '#20242d', inset: '#232830' },
|
||||
text: { primary: '#eceff4', secondary: '#c2cad8', tertiary: '#a6b0c4', disabled: '#4c566a', hover: '#e5e9f0' },
|
||||
border: { subtle: 'rgba(216,222,233,0.06)', default: 'rgba(216,222,233,0.11)', strong: 'rgba(216,222,233,0.18)' },
|
||||
accent: { main: '#88c0d0', light: '#a3d0de', dark: '#6aacbe', dim: 'rgba(136,192,208,0.14)', solid: '#88c0d0', solidHover: '#9ccbd8', ink: '#2e3440' },
|
||||
elevation: { overlay: '0 12px 32px rgba(10,13,18,0.5)', tooltip: '0 8px 24px rgba(10,13,18,0.45)' },
|
||||
},
|
||||
// https://ethanschoonover.com/solarized/
|
||||
solarized: {
|
||||
bg: { app: '#00212b', card: '#073642', cardHover: '#0a3e4c', elevated: '#0d4250', input: '#03303c', sidebar: '#001c25', inset: '#001f28' },
|
||||
text: { primary: '#eee8d5', secondary: '#b7bdb6', tertiary: '#a1aca9', disabled: '#586e75', hover: '#d6d9cf' },
|
||||
border: { subtle: 'rgba(131,148,150,0.08)', default: 'rgba(131,148,150,0.14)', strong: 'rgba(131,148,150,0.22)' },
|
||||
accent: { main: '#b58900', light: '#d4a017', dark: '#8a6800', dim: 'rgba(181,137,0,0.14)', solid: '#8a6800', solidHover: '#7a5c00', ink: '#ffffff' },
|
||||
elevation: { overlay: '0 12px 32px rgba(0,10,13,0.55)', tooltip: '0 8px 24px rgba(0,10,13,0.5)' },
|
||||
},
|
||||
// https://catppuccin.com/ (Mocha)
|
||||
catppuccin: {
|
||||
bg: { app: '#181825', card: '#1e1e2e', cardHover: '#242438', elevated: '#313244', input: '#191926', sidebar: '#14141f', inset: '#11111b' },
|
||||
text: { primary: '#cdd6f4', secondary: '#a8b2d8', tertiary: '#939cbf', disabled: '#585b70', hover: '#e6e9f8' },
|
||||
border: { subtle: 'rgba(205,214,244,0.05)', default: 'rgba(205,214,244,0.10)', strong: 'rgba(205,214,244,0.17)' },
|
||||
accent: { main: '#cba6f7', light: '#dfc0ff', dark: '#a67fd4', dim: 'rgba(203,166,247,0.14)', solid: '#cba6f7', solidHover: '#d7b9fa', ink: '#11111b' },
|
||||
elevation: { overlay: '0 12px 32px rgba(5,5,12,0.55)', tooltip: '0 8px 24px rgba(5,5,12,0.5)' },
|
||||
},
|
||||
// https://draculatheme.com/
|
||||
dracula: {
|
||||
bg: { app: '#1e2029', card: '#282a36', cardHover: '#2e3040', elevated: '#383a4a', input: '#232530', sidebar: '#1a1c24', inset: '#1b1c25' },
|
||||
text: { primary: '#f8f8f2', secondary: '#b6bdd9', tertiary: '#a1a7c1', disabled: '#6272a4', hover: '#ffffff' },
|
||||
border: { subtle: 'rgba(248,248,242,0.05)', default: 'rgba(248,248,242,0.10)', strong: 'rgba(248,248,242,0.17)' },
|
||||
accent: { main: '#bd93f9', light: '#d4b8ff', dark: '#9a70e0', dim: 'rgba(189,147,249,0.14)', solid: '#bd93f9', solidHover: '#caa6fa', ink: '#1e2029' },
|
||||
elevation: { overlay: '0 12px 32px rgba(8,9,13,0.55)', tooltip: '0 8px 24px rgba(8,9,13,0.5)' },
|
||||
},
|
||||
}
|
||||
|
||||
export const RAW: Record<ThemeKey, RawTheme> = {
|
||||
dark: deriveRaw(BASE.dark),
|
||||
light: deriveRaw(BASE.light),
|
||||
nord: deriveRaw(BASE.nord),
|
||||
solarized: deriveRaw(BASE.solarized),
|
||||
catppuccin: deriveRaw(BASE.catppuccin),
|
||||
dracula: deriveRaw(BASE.dracula),
|
||||
}
|
||||
|
||||
export const THEME_KEYS = Object.keys(RAW) as ThemeKey[]
|
||||
|
||||
export function isThemeKey(value: string): value is ThemeKey {
|
||||
return (THEME_KEYS as string[]).includes(value)
|
||||
}
|
||||
|
||||
// ── 시맨틱 상태 역할 (액센트와 분리 — 기능 우선) ─────────────
|
||||
// 다크는 밝은 상태색, 라이트는 어두운 상태색. 텍스트로 쓸 수 있는 대비를 유지한다.
|
||||
export interface StatusRole {
|
||||
success: string
|
||||
successBg: string
|
||||
warning: string
|
||||
warningBg: string
|
||||
danger: string
|
||||
dangerBg: string
|
||||
info: string
|
||||
infoBg: string
|
||||
}
|
||||
|
||||
export const STATUS: Record<ThemeKey, StatusRole> = {
|
||||
dark: {
|
||||
success: '#34d399', successBg: 'rgba(52,211,153,0.12)',
|
||||
warning: '#fbbf24', warningBg: 'rgba(251,191,36,0.12)',
|
||||
danger: '#f87171', dangerBg: 'rgba(248,113,113,0.12)',
|
||||
info: '#60a5fa', infoBg: 'rgba(96,165,250,0.12)',
|
||||
},
|
||||
light: {
|
||||
success: '#047857', successBg: 'rgba(4,120,87,0.10)',
|
||||
warning: '#b45309', warningBg: 'rgba(180,83,9,0.10)',
|
||||
danger: '#b91c1c', dangerBg: 'rgba(185,28,28,0.10)',
|
||||
info: '#1d4ed8', infoBg: 'rgba(29,78,216,0.10)',
|
||||
},
|
||||
nord: {
|
||||
success: '#a3be8c', successBg: 'rgba(163,190,140,0.14)',
|
||||
warning: '#ebcb8b', warningBg: 'rgba(235,203,139,0.14)',
|
||||
danger: '#d08770', dangerBg: 'rgba(208,135,112,0.14)',
|
||||
info: '#88c0d0', infoBg: 'rgba(136,192,208,0.14)',
|
||||
},
|
||||
solarized: {
|
||||
success: '#a4b82a', successBg: 'rgba(133,153,0,0.16)',
|
||||
warning: '#d4a017', warningBg: 'rgba(181,137,0,0.16)',
|
||||
danger: '#ef6461', dangerBg: 'rgba(220,50,47,0.16)',
|
||||
info: '#4ea3de', infoBg: 'rgba(38,139,210,0.16)',
|
||||
},
|
||||
catppuccin: {
|
||||
success: '#a6e3a1', successBg: 'rgba(166,227,161,0.14)',
|
||||
warning: '#f9e2af', warningBg: 'rgba(249,226,175,0.14)',
|
||||
danger: '#f38ba8', dangerBg: 'rgba(243,139,168,0.14)',
|
||||
info: '#89b4fa', infoBg: 'rgba(137,180,250,0.14)',
|
||||
},
|
||||
dracula: {
|
||||
success: '#50fa7b', successBg: 'rgba(80,250,123,0.14)',
|
||||
warning: '#f1fa8c', warningBg: 'rgba(241,250,140,0.14)',
|
||||
danger: '#ff6e6e', dangerBg: 'rgba(255,85,85,0.14)',
|
||||
info: '#8be9fd', infoBg: 'rgba(139,233,253,0.14)',
|
||||
},
|
||||
}
|
||||
|
||||
// ── 테마 불변 태그색 (카테고리 구분용 — 텍스트 대비를 보장하지 않는다) ──
|
||||
// 상태를 말할 때는 STATUS 를 쓴다. tag 는 아이콘·점·범례처럼 글자가 아닌 곳에만.
|
||||
export const TAG = {
|
||||
purple: '#8b5cf6',
|
||||
purpleBg: 'rgba(139, 92, 246, 0.12)',
|
||||
orange: '#f59e0b',
|
||||
orangeBg: 'rgba(245, 158, 11, 0.12)',
|
||||
red: '#ef4444',
|
||||
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||
green: '#10b981',
|
||||
greenBg: 'rgba(16, 185, 129, 0.12)',
|
||||
blue: '#3b82f6',
|
||||
blueBg: 'rgba(59, 130, 246, 0.12)',
|
||||
greenGlow: 'rgba(16, 185, 129, 0.35)',
|
||||
redGlow: 'rgba(239, 68, 68, 0.35)',
|
||||
orangeGlow: 'rgba(245, 158, 11, 0.35)',
|
||||
purpleGlow: 'rgba(139, 92, 246, 0.35)',
|
||||
blueGlow: 'rgba(59, 130, 246, 0.35)',
|
||||
cyan: '#06b6d4',
|
||||
cyanBg: 'rgba(6, 182, 212, 0.12)',
|
||||
cyanGlow: 'rgba(6, 182, 212, 0.35)',
|
||||
} as const
|
||||
|
||||
// ── 간격 리듬 (4px 그리드) ─────────────────────────────────
|
||||
export const SPACE = { s1: '4px', s2: '8px', s3: '12px', s4: '16px', s5: '24px', s6: '40px', s7: '64px', s8: '96px' } as const
|
||||
|
||||
// ── 컨트롤 치수 (버튼처럼 생긴 것은 전부 같은 치수) ──────────
|
||||
export const CONTROL = { h: '36px', hSm: '28px', padX: '14px', padXSm: '10px' } as const
|
||||
|
||||
// ── 웨이트는 세 개 ─────────────────────────────────────────
|
||||
export const WEIGHT = { body: 400, medium: 500, strong: 600 } as const
|
||||
|
||||
// ── 모션 문법 — 상태 변화 피드백만. 등장 연출은 쓰지 않는다 ──
|
||||
export const MOTION = {
|
||||
instant: '100ms',
|
||||
quick: '160ms',
|
||||
normal: '240ms',
|
||||
slow: '400ms',
|
||||
easeOut: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
easeIn: 'cubic-bezier(0.64, 0, 0.78, 0)',
|
||||
easeSoft: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
} as const
|
||||
|
||||
// ── 반경 — 정밀 도구: 작고 일정하게 ──────────────────────────
|
||||
export const RADIUS = {
|
||||
doubleBezelOuter: '12px',
|
||||
doubleBezelInner: '10px',
|
||||
outer: '12px',
|
||||
card: '10px',
|
||||
inner: '8px',
|
||||
button: '8px',
|
||||
small: '6px',
|
||||
xs: '4px',
|
||||
badge: '999px',
|
||||
pill: '999px',
|
||||
} as const
|
||||
|
||||
// 한글 우선 폰트 스택 — Pretendard(한/영 조화) → 플랫폼 한글 시스템 폰트 폴백.
|
||||
export const FONT_SANS = [
|
||||
'"Pretendard Variable"', 'Pretendard', '-apple-system', 'BlinkMacSystemFont',
|
||||
'"Apple SD Gothic Neo"', '"Noto Sans KR"', '"Segoe UI"', 'Roboto',
|
||||
'"Helvetica Neue"', 'Arial', '"Malgun Gothic"', 'sans-serif',
|
||||
].join(',')
|
||||
|
||||
/** 숫자·코드·키 이름(라틴) 전용. 한글이 섞일 수 있는 문자열에는 쓰지 않는다 — 한글 글리프가 없어 자간이 벌어진다. */
|
||||
export const FONT_MONO = [
|
||||
'ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Menlo', 'Consolas',
|
||||
'"Liberation Mono"', 'monospace',
|
||||
].join(',')
|
||||
|
||||
// ── CSS Custom Properties ────────────────────────────────────
|
||||
export function buildCssVars(key: ThemeKey): Record<string, string> {
|
||||
const r = RAW[key]
|
||||
const st = STATUS[key]
|
||||
return {
|
||||
'--d3-bg-app': r.bg.app,
|
||||
'--d3-bg-card': r.bg.card,
|
||||
'--d3-bg-cardHover': r.bg.cardHover,
|
||||
'--d3-bg-elevated': r.bg.elevated,
|
||||
'--d3-bg-input': r.bg.input,
|
||||
'--d3-bg-sidebar': r.bg.sidebar,
|
||||
'--d3-bg-inset': r.bg.inset,
|
||||
'--d3-bg-chassis': r.bg.chassis,
|
||||
'--d3-bg-crtBezel': r.bg.crtBezel,
|
||||
'--d3-bg-crtGlass': r.bg.crtGlass,
|
||||
'--d3-text-primary': r.text.primary,
|
||||
'--d3-text-secondary': r.text.secondary,
|
||||
'--d3-text-tertiary': r.text.tertiary,
|
||||
'--d3-text-label': r.text.label,
|
||||
'--d3-text-disabled': r.text.disabled,
|
||||
'--d3-text-engraving': r.text.engraving,
|
||||
'--d3-text-inactive': r.text.inactive,
|
||||
'--d3-text-dimLabel': r.text.dimLabel,
|
||||
'--d3-text-muted': r.text.muted,
|
||||
'--d3-text-hover': r.text.hover,
|
||||
'--d3-border-subtle': r.border.subtle,
|
||||
'--d3-border-default': r.border.default,
|
||||
'--d3-border-strong': r.border.strong,
|
||||
'--d3-shadow-card': r.shadow.card,
|
||||
'--d3-shadow-buttonBase': r.shadow.buttonBase,
|
||||
'--d3-shadow-chassis': r.shadow.chassis,
|
||||
'--d3-shadow-inset': r.shadow.inset,
|
||||
'--d3-shadow-insetDeep': r.shadow.insetDeep,
|
||||
'--d3-shadow-buttonRaised': r.shadow.buttonRaised,
|
||||
'--d3-shadow-buttonPressed': r.shadow.buttonPressed,
|
||||
'--d3-shadow-screenGlow': r.shadow.screenGlow,
|
||||
'--d3-shadow-tooltip': r.shadow.tooltip,
|
||||
'--d3-shadow-overlay': r.shadow.overlay,
|
||||
'--d3-accent-main': r.accent.main,
|
||||
'--d3-accent-dim': r.accent.dim,
|
||||
'--d3-accent-glow': r.accent.glow,
|
||||
'--d3-accent-light': r.accent.light,
|
||||
'--d3-accent-dark': r.accent.dark,
|
||||
'--d3-accent-solid': r.accent.solid,
|
||||
'--d3-accent-solidHover': r.accent.solidHover,
|
||||
'--d3-accent-crtPhosphor': r.accent.crtPhosphor,
|
||||
'--d3-accent-crtPhosphorDim': r.accent.crtPhosphorDim,
|
||||
'--d3-accent-ink': r.accent.ink,
|
||||
'--d3-glass-surface': r.glass.surface,
|
||||
'--d3-glass-raised': r.glass.raised,
|
||||
'--d3-glass-hairline': r.glass.hairline,
|
||||
'--d3-glass-hairlineStrong': r.glass.hairlineStrong,
|
||||
'--d3-glass-sheen': r.glass.sheen,
|
||||
'--d3-glass-borderGradient': r.glass.borderGradient,
|
||||
'--d3-glass-blur': r.glass.blur,
|
||||
'--d3-glass-specular': r.glass.specular,
|
||||
'--d3-gradient-accent': r.gradient.accent,
|
||||
'--d3-gradient-logo': r.gradient.logo,
|
||||
'--d3-gradient-bar': r.gradient.bar,
|
||||
'--d3-gradient-barAlt': r.gradient.barAlt,
|
||||
'--d3-gradient-wave1': r.gradient.wave1,
|
||||
'--d3-gradient-wave2': r.gradient.wave2,
|
||||
'--d3-gradient-wave3': r.gradient.wave3,
|
||||
'--d3-gradient-wave4': r.gradient.wave4,
|
||||
'--d3-glow-accent': r.glow.accent,
|
||||
'--d3-glow-soft': r.glow.soft,
|
||||
'--d3-glow-card': r.glow.card,
|
||||
'--d3-glow-cardHover': r.glow.cardHover,
|
||||
'--d3-status-success': st.success,
|
||||
'--d3-status-successBg': st.successBg,
|
||||
'--d3-status-warning': st.warning,
|
||||
'--d3-status-warningBg': st.warningBg,
|
||||
'--d3-status-danger': st.danger,
|
||||
'--d3-status-dangerBg': st.dangerBg,
|
||||
'--d3-status-info': st.info,
|
||||
'--d3-status-infoBg': st.infoBg,
|
||||
'--d3-space-1': SPACE.s1,
|
||||
'--d3-space-2': SPACE.s2,
|
||||
'--d3-space-3': SPACE.s3,
|
||||
'--d3-space-4': SPACE.s4,
|
||||
'--d3-space-5': SPACE.s5,
|
||||
'--d3-space-6': SPACE.s6,
|
||||
'--d3-space-7': SPACE.s7,
|
||||
'--d3-space-8': SPACE.s8,
|
||||
'--d3-control-h': CONTROL.h,
|
||||
'--d3-control-h-sm': CONTROL.hSm,
|
||||
'--d3-control-pad-x': CONTROL.padX,
|
||||
'--d3-control-pad-x-sm': CONTROL.padXSm,
|
||||
'--d3-weight-body': String(WEIGHT.body),
|
||||
'--d3-weight-medium': String(WEIGHT.medium),
|
||||
'--d3-weight-strong': String(WEIGHT.strong),
|
||||
'--d3-dur-instant': MOTION.instant,
|
||||
'--d3-dur-quick': MOTION.quick,
|
||||
'--d3-dur-normal': MOTION.normal,
|
||||
'--d3-dur-slow': MOTION.slow,
|
||||
'--d3-ease-out': MOTION.easeOut,
|
||||
'--d3-ease-in': MOTION.easeIn,
|
||||
'--d3-ease-soft': MOTION.easeSoft,
|
||||
'--d3-radius-card': RADIUS.card,
|
||||
'--d3-radius-inner': RADIUS.inner,
|
||||
'--d3-radius-button': RADIUS.button,
|
||||
'--d3-radius-small': RADIUS.small,
|
||||
'--d3-tag-purple': TAG.purple,
|
||||
'--d3-tag-orange': TAG.orange,
|
||||
'--d3-tag-red': TAG.red,
|
||||
'--d3-tag-green': TAG.green,
|
||||
'--d3-tag-blue': TAG.blue,
|
||||
'--d3-tag-cyan': TAG.cyan,
|
||||
'--d3-tag-cyanBg': TAG.cyanBg,
|
||||
'--d3-bg-inset-soft': 'color-mix(in srgb, var(--d3-bg-inset) 70%, transparent)',
|
||||
'--d3-bg-card-soft': 'color-mix(in srgb, var(--d3-bg-card) 70%, transparent)',
|
||||
'--d3-bg-raised-soft': 'color-mix(in srgb, var(--d3-bg-elevated) 80%, transparent)',
|
||||
'--d3-scrim': key === 'light' ? 'rgba(17,24,39,0.32)' : 'rgba(0,0,0,0.6)',
|
||||
// 표면 위 옅은 오버레이(hover·선택) — 텍스트 색에서 파생해 라이트 테마에서도 보인다
|
||||
'--d3-overlay-soft': 'color-mix(in srgb, var(--d3-text-primary) 6%, transparent)',
|
||||
'--d3-overlay-strong': 'color-mix(in srgb, var(--d3-text-primary) 12%, transparent)',
|
||||
'--d3-font-sans': FONT_SANS,
|
||||
'--d3-font-mono': FONT_MONO,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,283 +1,56 @@
|
|||
// src/shared/theme-vars.ts
|
||||
// packages/ui/src/theme-vars.ts
|
||||
// 팝업 윈도우용 CSS 변수 맵.
|
||||
// main process (WindowManager)에서 insertCSS로 팝업에 테마를 주입할 때 사용.
|
||||
// renderer의 theme.ts RAW와 동기화 유지 필요.
|
||||
// Node.js/Electron main 환경에서 import 가능 (DOM API 없음).
|
||||
// 값은 theme-raw.ts(렌더러와 같은 원시값)에서 파생한다 — 손으로 복제하지 않는다.
|
||||
// Node.js/Electron main 환경에서 import 가능 (MUI·DOM 의존 없음).
|
||||
|
||||
export type PopupThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula'
|
||||
import { RAW, STATUS, buildCssVars, isThemeKey, type ThemeKey } from './theme-raw'
|
||||
|
||||
interface PopupThemeVars {
|
||||
/** 카드 배경 (팝업 본체) */
|
||||
'--d3-bg-card': string
|
||||
/** 앱 배경 (recording-tip 반투명 배경) */
|
||||
'--d3-bg-tip': string
|
||||
/** 주요 텍스트 */
|
||||
'--d3-text-primary': string
|
||||
/** 보조 텍스트 */
|
||||
'--d3-text-secondary': string
|
||||
/** 비활성 텍스트 (번호, 시간 등) */
|
||||
'--d3-text-inactive': string
|
||||
/** 힌트/최약 텍스트 */
|
||||
'--d3-text-muted': string
|
||||
/** dimLabel 텍스트 (command-popup 제목) */
|
||||
'--d3-text-dimLabel': string
|
||||
/** 기본 테두리 */
|
||||
'--d3-border-default': string
|
||||
/** 미묘한 테두리 (구분선, hover) */
|
||||
'--d3-border-subtle': string
|
||||
/** 강한 테두리 (progress background) */
|
||||
'--d3-border-strong': string
|
||||
/** 악센트 색상 (웨이브바, 선택 바) */
|
||||
'--d3-accent-main': string
|
||||
/** 악센트 dim (active 배경) */
|
||||
'--d3-accent-dim': string
|
||||
/** 팝업 박스 섀도 */
|
||||
'--d3-shadow-popup': string
|
||||
/** 결과 팝업 배경 (불투명) */
|
||||
'--d3-bg-result': string
|
||||
/** 결과 팝업 테두리 */
|
||||
'--d3-border-result': string
|
||||
/** 결과 팝업 텍스트 */
|
||||
'--d3-text-result': string
|
||||
/** 결과 팝업 버튼 색상 */
|
||||
'--d3-action-btn': string
|
||||
/** 결과 팝업 버튼 hover 배경 */
|
||||
'--d3-action-btn-hover-bg': string
|
||||
/** 결과 팝업 버튼 hover 색상 */
|
||||
'--d3-action-btn-hover': string
|
||||
/** recording-tip 9단계 wave-bar 색상 (wave1~4 보간) */
|
||||
'--d3-wave-1': string
|
||||
'--d3-wave-2': string
|
||||
'--d3-wave-3': string
|
||||
'--d3-wave-4': string
|
||||
'--d3-wave-5': string
|
||||
'--d3-wave-6': string
|
||||
'--d3-wave-7': string
|
||||
'--d3-wave-8': string
|
||||
'--d3-wave-9': string
|
||||
/** 에러 상태 색상 (recording-tip error-icon) */
|
||||
'--d3-status-error': string
|
||||
/** 성공 상태 색상 (result-popup copied 상태) */
|
||||
'--d3-status-success': string
|
||||
}
|
||||
|
||||
const POPUP_THEME_VARS: Record<PopupThemeKey, PopupThemeVars> = {
|
||||
dark: {
|
||||
'--d3-bg-card': '#111a30',
|
||||
'--d3-bg-tip': 'rgba(10,14,28,0.85)',
|
||||
'--d3-text-primary': '#eef2fb',
|
||||
'--d3-text-secondary': '#93a4c8',
|
||||
'--d3-text-inactive': '#7385ab',
|
||||
'--d3-text-muted': '#2b3550',
|
||||
'--d3-text-dimLabel': '#4e5f85',
|
||||
'--d3-border-default': 'rgba(148,180,255,0.12)',
|
||||
'--d3-border-subtle': 'rgba(148,180,255,0.07)',
|
||||
'--d3-border-strong': 'rgba(148,180,255,0.20)',
|
||||
'--d3-accent-main': '#3b82f6',
|
||||
'--d3-accent-dim': 'rgba(59,130,246,0.14)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(3,7,18,0.5)',
|
||||
'--d3-bg-result': '#111a30',
|
||||
'--d3-border-result': 'rgba(148,180,255,0.12)',
|
||||
'--d3-text-result': '#eef2fb',
|
||||
'--d3-action-btn': '#60a5fa',
|
||||
'--d3-action-btn-hover-bg': 'rgba(59,130,246,0.14)',
|
||||
'--d3-action-btn-hover': '#93a4c8',
|
||||
// wave-bar 9단계: theme.ts RAW.dark.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#93c5fd',
|
||||
'--d3-wave-2': '#60a5fa',
|
||||
'--d3-wave-3': '#4f8dfa',
|
||||
'--d3-wave-4': '#3b82f6',
|
||||
'--d3-wave-5': '#3b82f6',
|
||||
'--d3-wave-6': '#5b75f7',
|
||||
'--d3-wave-7': '#6e71f7',
|
||||
'--d3-wave-8': '#818cf8',
|
||||
'--d3-wave-9': '#a78bfa',
|
||||
'--d3-status-error': '#ef4444',
|
||||
'--d3-status-success': '#10b981',
|
||||
},
|
||||
light: {
|
||||
'--d3-bg-card': '#ffffff',
|
||||
'--d3-bg-tip': 'rgba(242,245,251,0.92)',
|
||||
'--d3-text-primary': '#111827',
|
||||
'--d3-text-secondary': '#4b5a75',
|
||||
'--d3-text-inactive': '#8593ab',
|
||||
'--d3-text-muted': '#c3cddd',
|
||||
'--d3-text-dimLabel': '#5a6f96',
|
||||
'--d3-border-default': 'rgba(30,58,138,0.12)',
|
||||
'--d3-border-subtle': 'rgba(30,58,138,0.07)',
|
||||
'--d3-border-strong': 'rgba(30,58,138,0.20)',
|
||||
'--d3-accent-main': '#2563eb',
|
||||
'--d3-accent-dim': 'rgba(37,99,235,0.10)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(30,58,138,0.12)',
|
||||
'--d3-bg-result': '#ffffff',
|
||||
'--d3-border-result': 'rgba(30,58,138,0.12)',
|
||||
'--d3-text-result': '#111827',
|
||||
'--d3-action-btn': '#3b82f6',
|
||||
'--d3-action-btn-hover-bg': 'rgba(37,99,235,0.10)',
|
||||
'--d3-action-btn-hover': '#4b5a75',
|
||||
// theme.ts RAW.light.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#06b6d4',
|
||||
'--d3-wave-2': '#1cbff8',
|
||||
'--d3-wave-3': '#32bbfc',
|
||||
'--d3-wave-4': '#3b82f6',
|
||||
'--d3-wave-5': '#4966eb',
|
||||
'--d3-wave-6': '#574ae0',
|
||||
'--d3-wave-7': '#6c2edd',
|
||||
'--d3-wave-8': '#8112da',
|
||||
'--d3-wave-9': '#d946ef',
|
||||
'--d3-status-error': '#ef4444',
|
||||
'--d3-status-success': '#34d399',
|
||||
},
|
||||
nord: {
|
||||
'--d3-bg-card': '#3b4252',
|
||||
'--d3-bg-tip': 'rgba(46,52,64,0.92)',
|
||||
'--d3-text-primary': 'rgba(236,239,244,0.87)',
|
||||
'--d3-text-secondary': 'rgba(216,222,233,0.6)',
|
||||
'--d3-text-inactive': 'rgba(216,222,233,0.35)',
|
||||
'--d3-text-muted': 'rgba(216,222,233,0.25)',
|
||||
'--d3-text-dimLabel': '#5e81ac',
|
||||
'--d3-border-default': 'rgba(216,222,233,0.10)',
|
||||
'--d3-border-subtle': 'rgba(216,222,233,0.06)',
|
||||
'--d3-border-strong': 'rgba(216,222,233,0.16)',
|
||||
'--d3-accent-main': '#88c0d0',
|
||||
'--d3-accent-dim': 'rgba(136,192,208,0.08)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
||||
'--d3-bg-result': '#434c5e',
|
||||
'--d3-border-result': 'rgba(216,222,233,0.10)',
|
||||
'--d3-text-result': 'rgba(236,239,244,0.87)',
|
||||
'--d3-action-btn': 'rgba(216,222,233,0.4)',
|
||||
'--d3-action-btn-hover-bg': 'rgba(216,222,233,0.08)',
|
||||
'--d3-action-btn-hover': 'rgba(216,222,233,0.7)',
|
||||
// theme.ts RAW.nord.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#8fbcbb',
|
||||
'--d3-wave-2': '#8dbdcd',
|
||||
'--d3-wave-3': '#8bbea0',
|
||||
'--d3-wave-4': '#88c0d0',
|
||||
'--d3-wave-5': '#85c6d0',
|
||||
'--d3-wave-6': '#a3be8c',
|
||||
'--d3-wave-7': '#b48ead',
|
||||
'--d3-wave-8': '#bf88a8',
|
||||
'--d3-wave-9': '#d08770',
|
||||
'--d3-status-error': '#bf616a',
|
||||
'--d3-status-success': '#a3be8c',
|
||||
},
|
||||
solarized: {
|
||||
'--d3-bg-card': '#073642',
|
||||
'--d3-bg-tip': 'rgba(0,43,54,0.92)',
|
||||
'--d3-text-primary': 'rgba(238,232,213,0.87)',
|
||||
'--d3-text-secondary': 'rgba(147,161,161,0.7)',
|
||||
'--d3-text-inactive': 'rgba(147,161,161,0.4)',
|
||||
'--d3-text-muted': 'rgba(147,161,161,0.25)',
|
||||
'--d3-text-dimLabel': '#7a6c2e',
|
||||
'--d3-border-default': 'rgba(131,148,150,0.13)',
|
||||
'--d3-border-subtle': 'rgba(131,148,150,0.08)',
|
||||
'--d3-border-strong': 'rgba(131,148,150,0.20)',
|
||||
'--d3-accent-main': '#b58900',
|
||||
'--d3-accent-dim': 'rgba(181,137,0,0.08)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
||||
'--d3-bg-result': '#0d4250',
|
||||
'--d3-border-result': 'rgba(131,148,150,0.13)',
|
||||
'--d3-text-result': 'rgba(238,232,213,0.87)',
|
||||
'--d3-action-btn': 'rgba(147,161,161,0.4)',
|
||||
'--d3-action-btn-hover-bg': 'rgba(131,148,150,0.10)',
|
||||
'--d3-action-btn-hover': 'rgba(147,161,161,0.7)',
|
||||
// theme.ts RAW.solarized.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#2aa198',
|
||||
'--d3-wave-2': '#2693cb',
|
||||
'--d3-wave-3': '#268bd2',
|
||||
'--d3-wave-4': '#268bd2',
|
||||
'--d3-wave-5': '#4676d9',
|
||||
'--d3-wave-6': '#6c71c4',
|
||||
'--d3-wave-7': '#6c71c4',
|
||||
'--d3-wave-8': '#9a33d3',
|
||||
'--d3-wave-9': '#d33682',
|
||||
'--d3-status-error': '#dc322f',
|
||||
'--d3-status-success': '#859900',
|
||||
},
|
||||
catppuccin: {
|
||||
'--d3-bg-card': '#313244',
|
||||
'--d3-bg-tip': 'rgba(30,30,46,0.92)',
|
||||
'--d3-text-primary': 'rgba(205,214,244,0.87)',
|
||||
'--d3-text-secondary': 'rgba(186,194,222,0.7)',
|
||||
'--d3-text-inactive': 'rgba(166,173,200,0.4)',
|
||||
'--d3-text-muted': 'rgba(166,173,200,0.25)',
|
||||
'--d3-text-dimLabel': '#585b70',
|
||||
'--d3-border-default': 'rgba(205,214,244,0.08)',
|
||||
'--d3-border-subtle': 'rgba(205,214,244,0.04)',
|
||||
'--d3-border-strong': 'rgba(205,214,244,0.14)',
|
||||
'--d3-accent-main': '#cba6f7',
|
||||
'--d3-accent-dim': 'rgba(203,166,247,0.08)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
||||
'--d3-bg-result': '#3a3a54',
|
||||
'--d3-border-result': 'rgba(205,214,244,0.08)',
|
||||
'--d3-text-result': 'rgba(205,214,244,0.87)',
|
||||
'--d3-action-btn': 'rgba(166,173,200,0.4)',
|
||||
'--d3-action-btn-hover-bg': 'rgba(205,214,244,0.06)',
|
||||
'--d3-action-btn-hover': 'rgba(205,214,244,0.7)',
|
||||
// theme.ts RAW.catppuccin.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#89dceb',
|
||||
'--d3-wave-2': '#89cff4',
|
||||
'--d3-wave-3': '#89c2f9',
|
||||
'--d3-wave-4': '#89b4fa',
|
||||
'--d3-wave-5': '#b4a9f2',
|
||||
'--d3-wave-6': '#df9eea',
|
||||
'--d3-wave-7': '#cba6f7',
|
||||
'--d3-wave-8': '#e5b6f6',
|
||||
'--d3-wave-9': '#f5c2e7',
|
||||
'--d3-status-error': '#f38ba8',
|
||||
'--d3-status-success': '#a6e3a1',
|
||||
},
|
||||
dracula: {
|
||||
'--d3-bg-card': '#44475a',
|
||||
'--d3-bg-tip': 'rgba(40,42,54,0.92)',
|
||||
'--d3-text-primary': 'rgba(248,248,242,0.87)',
|
||||
'--d3-text-secondary': 'rgba(169,176,208,0.7)',
|
||||
'--d3-text-inactive': 'rgba(169,176,208,0.4)',
|
||||
'--d3-text-muted': 'rgba(169,176,208,0.25)',
|
||||
'--d3-text-dimLabel': '#6272a4',
|
||||
'--d3-border-default': 'rgba(248,248,242,0.08)',
|
||||
'--d3-border-subtle': 'rgba(248,248,242,0.04)',
|
||||
'--d3-border-strong': 'rgba(248,248,242,0.14)',
|
||||
'--d3-accent-main': '#bd93f9',
|
||||
'--d3-accent-dim': 'rgba(189,147,249,0.08)',
|
||||
'--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)',
|
||||
'--d3-bg-result': '#4f5266',
|
||||
'--d3-border-result': 'rgba(248,248,242,0.08)',
|
||||
'--d3-text-result': 'rgba(248,248,242,0.87)',
|
||||
'--d3-action-btn': 'rgba(169,176,208,0.4)',
|
||||
'--d3-action-btn-hover-bg': 'rgba(248,248,242,0.06)',
|
||||
'--d3-action-btn-hover': 'rgba(248,248,242,0.7)',
|
||||
// theme.ts RAW.dracula.gradient.wave1-4 보간
|
||||
'--d3-wave-1': '#8be9fd',
|
||||
'--d3-wave-2': '#a6fbff',
|
||||
'--d3-wave-3': '#c1fdff',
|
||||
'--d3-wave-4': '#bd93f9',
|
||||
'--d3-wave-5': '#d485ff',
|
||||
'--d3-wave-6': '#eb77ff',
|
||||
'--d3-wave-7': '#ff79c6',
|
||||
'--d3-wave-8': '#ff88c4',
|
||||
'--d3-wave-9': '#ffb86c',
|
||||
'--d3-status-error': '#ff5555',
|
||||
'--d3-status-success': '#50fa7b',
|
||||
},
|
||||
}
|
||||
export type PopupThemeKey = ThemeKey
|
||||
|
||||
/**
|
||||
* 팝업용 CSS 변수 맵 조회.
|
||||
* ThemeMode('system' | 'light' | 'dark') 또는 테마 키를 받아 해당 변수 맵 반환.
|
||||
* 'system' 또는 알 수 없는 값은 'dark'로 폴백.
|
||||
* 팝업 CSS 가 쓰는 전용 별칭. 렌더러 공통 변수(buildCssVars) 위에 얹는다.
|
||||
* v4: 팝업도 평면 — 반투명 배경·다색 파형을 쓰지 않는다.
|
||||
*/
|
||||
export function getPopupThemeVars(themeKey: string): PopupThemeVars {
|
||||
const key = themeKey as PopupThemeKey
|
||||
return POPUP_THEME_VARS[key] ?? POPUP_THEME_VARS.dark
|
||||
function popupAliases(key: ThemeKey): Record<string, string> {
|
||||
const r = RAW[key]
|
||||
const st = STATUS[key]
|
||||
const wave: Record<string, string> = {}
|
||||
for (let i = 1; i <= 9; i++) wave[`--d3-wave-${i}`] = r.accent.main
|
||||
return {
|
||||
'--d3-bg-tip': r.bg.card,
|
||||
'--d3-shadow-popup': r.shadow.overlay,
|
||||
'--d3-bg-result': r.bg.card,
|
||||
'--d3-border-result': r.border.default,
|
||||
'--d3-text-result': r.text.primary,
|
||||
'--d3-action-btn': r.text.secondary,
|
||||
'--d3-action-btn-hover-bg': 'var(--d3-overlay-soft)',
|
||||
'--d3-action-btn-hover': r.text.primary,
|
||||
'--d3-accent-glow-dim': r.accent.dim,
|
||||
'--d3-status-error': st.danger,
|
||||
...wave,
|
||||
}
|
||||
}
|
||||
|
||||
/** ThemeMode('system' | 'auto' | 테마 키)를 팝업 테마 키로. 시스템 모드는 호출자가 prefersDark 를 넘긴다. */
|
||||
export function resolvePopupThemeKey(themeKey: string, prefersDark = true): PopupThemeKey {
|
||||
if (isThemeKey(themeKey)) return themeKey
|
||||
if (themeKey === 'system' || themeKey === 'auto') return prefersDark ? 'dark' : 'light'
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
/** 팝업용 CSS 변수 맵 조회. 알 수 없는 값은 'dark'. */
|
||||
export function getPopupThemeVars(themeKey: string, prefersDark = true): Record<string, string> {
|
||||
const key = resolvePopupThemeKey(themeKey, prefersDark)
|
||||
return { ...buildCssVars(key), ...popupAliases(key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업 BrowserWindow에 insertCSS로 주입할 CSS 문자열 반환.
|
||||
* `:root { --d3-bg-card: #242427; ... }`
|
||||
* `:root { --d3-bg-card: #111a30; ... }`
|
||||
*/
|
||||
export function buildPopupThemeCss(themeKey: string): string {
|
||||
const vars = getPopupThemeVars(themeKey)
|
||||
export function buildPopupThemeCss(themeKey: string, prefersDark = true): string {
|
||||
const vars = getPopupThemeVars(themeKey, prefersDark)
|
||||
const declarations = Object.entries(vars)
|
||||
.map(([prop, value]) => ` ${prop}: ${value};`)
|
||||
.join('\n')
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue