From 2428ede03d5cc42eddbf10d8a1628b31e6e3b89d Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 18:26:03 +0900 Subject: [PATCH] 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. --- design.md | 67 ++ packages/ui/package.json | 4 + .../ui/src/components/ds/DoubleBezelCard.tsx | 78 +- .../ui/src/components/ds/GradientWave.tsx | 4 +- packages/ui/src/components/ds/Led.tsx | 32 +- packages/ui/src/components/ds/MetalCard.tsx | 45 +- .../ui/src/components/ds/PhosphorText.tsx | 50 +- .../ui/src/components/ds/PhysicalButton.tsx | 141 ++- packages/ui/src/components/ds/ScreenPanel.tsx | 34 +- packages/ui/src/components/ds/StatRing.tsx | 51 +- .../ui/src/components/ds/TactileBadge.tsx | 105 +- packages/ui/src/components/ds/TiltCard.tsx | 171 +--- packages/ui/src/theme-raw.ts | 448 +++++++++ packages/ui/src/theme-vars.ts | 309 +----- packages/ui/src/theme.ts | 945 ++++-------------- 15 files changed, 969 insertions(+), 1515 deletions(-) create mode 100644 packages/ui/src/theme-raw.ts diff --git a/design.md b/design.md index 98b4703..b618e9e 100644 --- a/design.md +++ b/design.md @@ -217,3 +217,70 @@ - 빌드 경로 `base: '/'`(언어 하위 경로에서도 자산 경로가 맞도록). - 검증: 본문 3,600~7,700자/언어, 하이드레이션 경고 0, 10개 언어 320/1440 넘침 0, axe 0, Lighthouse(모바일) SEO·접근성·권장사항 100. - 하지 않은 것: 언어별 별도 콘텐츠(번역 외 추가 페이지)·블로그, 가짜 후기·평점, 자동 언어 리다이렉트. + +## 2026-09-28 전면 개편 — 데스크톱·팝업·모바일·공유 DS ("기록이 주인공인 조용한 계기") + +전체 경로 + 리디자인 감사. 방향(swiss-minimal × dark-instrument, 탈볼드, Pretendard, blue)은 계승하고, +정보 구조·토큰 v4·팝업·모바일 내비를 새로 정했다. 물결별 분리 커밋(① 토큰·DS → ② 데스크톱 구조 → +③ 받아쓰기 팝업 → ④ 모바일 → ⑤ 통합 검색). + +### 브리프 (사용자 인터뷰 확정) +- 무엇을: 데스크톱(메인 앱·팝업 6종)·모바일 RN·공유 DS의 시각 언어 + 정보 구조·흐름 전면 개편. +- 누구에게: 1순위 회의 많은 직장인 → 2순위 지식노동자·개발자 → 3순위 일반 사용자. + 목표: 기록·회의 결과를 쉽게 찾고 다시 쓰며, 받아쓰기가 끊김 없이 흐른다. +- 제약: 톤 = **조용한 정밀 도구**(사용자가 "극적인 정밀 기기(CRT·메탈·LED)"를 명시적으로 기각). + 1급 = 기록·메모, 회의. 모바일 = 독립 완결형(1급 구조 동일). 기존 스택·기능 보존, 10개 로케일, 6테마. +- 우선순위: 구조(기능 산만) → 받아쓰기 흐름 → 기록 재사용 → 두 앱 일치. +- 기본 언어: ko-KR. 새 카피는 한국어, 영어는 기술 용어·키 이름만. + +### 감사 (개편 전, 증거: 1280×800 dark/light 캡처 + 코드 감사 4건) +- 하드 게이트 위반: 데스크톱 사이드바 Box onClick(키보드 불가) / "100% LOCAL PRIVACY · ZERO CLOUD SYNC"(v1.9.0 기기 간 + 동기화와 모순) / 받아쓰기 Esc 취소 없음·처리 중 클릭 시 텍스트 소실 / 삭제 7종 확인·되돌림 없음 / + 대비 미달 토큰 약 100곳(muted 1.42:1, dimLabel 2.72:1) / 감소 모션 전무. +- 산만함: 진입점 데스크톱 약 45개·모바일 27개. 회의는 기록에 저장되지 않고 통합 검색 없음. "메모"가 세 의미. +- 시각: 모든 카드가 글래스+글로우+그라디언트 링 → 같은 무게. 한글 라벨에 모노 폰트 → "누 름 유 지"처럼 벌어짐 + (모노 폰트에 한글 글리프가 없다). 팝업 radius 999/16/10 혼재, 팔레트 웹·네이티브 3벌. + +### 레퍼런스 (관찰은 scratch 아닌 이 기록에 요약) +- R1 구조(같은 시장): 클로바노트(노트+폴더, 요약·메모 영역 분리, 복사 2종), 다글로(보드, 요약/전사 탭, 모바일 가운데 +). +- R1 구조(목적): Granola(사용자 노트 검정·AI 회색, 근거 인용 🔍), Otter(Summary/Transcript 2탭, View in transcript). +- R1' 받아쓰기: Wispr Flow(화면 하단 작은 상태 바, 날짜별 기록+검색+hover 복사), Superwhisper(로딩/녹음/처리/완료 상태색, ESC 취소). +- R2 톤: Linear(요소마다 무게 다르게, 사이드바 몇 단계 어둡게, 크롬의 파랑 최소), Apple HIG(탭 바는 이동용, 빈번한 상호작용에 모션 자제). +- 가져온 원리: 요약 먼저·전사 근거 링크 / 재사용 행동 한 곳, 복사는 요약·전사 분리 / 받아쓰기는 날짜별 기록 / + 녹음 중 작은 상시 표시 + 상태색 분리 / 탭 바는 이동, 녹음은 플로팅 버튼. + +### 정보 구조 (두 앱 공통) +- 1급: **홈(오늘) · 기록 · 회의** — 같은 순서·같은 이름. 통합 검색(기록+회의, 데스크톱 Ctrl+K). +- 2급 "도구": AI 대화 · 명령어 · 사전 · 지식 · 템플릿 · 팀 · 음성 액션 · 데이터. 설정은 데스크톱 전체 화면. +- '메모 태그' → 기록 안의 '태그' 필터. 회의 안 메모는 '회의 메모'. 대시보드 통계는 홈 한 줄 요약. +- 모바일 하단: **탭 4(홈·기록·회의·더보기) + 떠 있는 녹음 버튼**. 녹음 버튼을 누르면 위로 시작 메뉴 + (받아쓰기·회의 녹음·파일 가져오기)가 펼쳐진다(사용자 지시, M3 FAB 메뉴 패턴). 탭 바에 행동을 두지 않는다(HIG). +- 기록↔회의: 데이터는 분리 유지(스키마 변경 없음), 통합 검색 + 상호 링크(사용자 선택). + +### 토큰 v4 (SSOT: `packages/ui/src/theme-raw.ts` — MUI·DOM 의존 없음) +- 구조: 테마마다 기본값(표면 7·텍스트 3단+disabled·경계 3·액센트 7·그림자 2)만 적고 `deriveRaw()`가 v2 호환 + 키(glass/gradient/glow/crt/label/inactive/dimLabel/muted)를 **평면 값으로 파생**. 컴포넌트 수백 곳을 고치지 않고 이펙트를 끈다. + glow.* = none, glass.sheen = none, blur = 0px, gradient.* = 단색, shadow.card/chassis/inset/button* = none. +- 텍스트 3단: tertiary 는 app/card/elevated/sidebar 모든 표면에서 4.5:1 이상(dark #7e8eb0 4.62, light #56647c, + nord #a6b0c4, solarized #a1aca9 4.70 + secondary #b7bdb6, catppuccin #939cbf, dracula #a1a7c1). +- accent.solid/solidHover/ink: 채운 버튼. dark·light #2563eb + 흰 글자 5.17:1(이전 #3b82f6 3.68:1 미달), + solarized #8a6800 5.17:1(이전 3.21), nord·catppuccin·dracula 는 밝은 채움 + 어두운 잉크. light accent.light #1d4ed8(글자용). +- 그림자는 떠 있는 층에만: `d3roShadow.overlay`(다이얼로그·메뉴·팝오버·플로팅 버튼), tooltip. +- radius: card 10 · outer 12 · inner 8 · button 8 · small 6 · xs 4 (정밀 도구 — 작고 일정). MUI shape 16→6 + (sx borderRadius:2 가 32px 이던 버그 해소). +- 타입: hero 32→28, title 24→22, meta 11/500/0.02em→12/400/0, label·engrave·micro 11/500/**자간 0**, + nano 9→10(라틴 수치 전용). 모든 PhosphorText 변형 산세리프·대문자 변환 없음. +- 컨트롤 36/28, 모션 100/160/240/400ms + easeOut. `d3roTransition()`, `d3roFocusRing` 헬퍼 추가. +- MUI: ripple 끔, 전역 `:focus-visible` 링, 전역 prefers-reduced-motion, Card = 평면+1px, Chip·palette 상태색 = 테마별 STATUS + (고정 tag 는 점·아이콘 전용), Backdrop blur 제거. +- 팝업: `theme-vars.ts`가 같은 원시값에서 파생(손 복제 폐기). 정의 안 된 채 쓰이던 `--d3-overlay-strong`, + `--d3-accent-glow-dim` 정의. 파형 9단 = 액센트 단색. 'system' 테마 해석 함수 `resolvePopupThemeKey` 추가. +- DS: MetalCard·DoubleBezelCard·ScreenPanel 평면화, TiltCard = MetalCard(틸트 제거), PhysicalButton 톤 5종 평면 + (accent 만 채움, 600→500, 떠오름 제거, 액센트 잉크 테마 반응), TactileBadge 자간 0·500·STATUS·키보드 조작, + Led 글로우 제거·상태색 STATUS, StatRing 다색 원뿔 → 1px 원, GradientWave 기본 유휴 진폭 0(대기 중 파형이 "살아있는 척" + 움직이던 거짓 신호 제거)·글로우 0. + +### 의도적으로 하지 않은 것 (물결 ①) +- **옛 토큰 키 삭제 안 함.** glass/gradient/glow/crt 키는 @deprecated 로 남긴다(웹·관리자·데스크톱 수백 곳). 새 코드는 쓰지 않는다. +- **6테마 액센트 통일 안 함.** nord·catppuccin·dracula·solarized 는 사용자가 고른 테마라 고유 액센트 유지, 대비만 보정. +- **`--d3-text-inverse` 정의 안 함.** 웹 앱 17곳이 제목 색으로 오용 중 — 값을 정하면 라이트 테마 제목이 사라진다. 웹 정리 때 함께. diff --git a/packages/ui/package.json b/packages/ui/package.json index 5bc146f..db92dac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" diff --git a/packages/ui/src/components/ds/DoubleBezelCard.tsx b/packages/ui/src/components/ds/DoubleBezelCard.tsx index 4f8ad01..f669aa4 100644 --- a/packages/ui/src/components/ds/DoubleBezelCard.tsx +++ b/packages/ui/src/components/ds/DoubleBezelCard.tsx @@ -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 { - /** 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 */} - - {children} - + {children} ) } diff --git a/packages/ui/src/components/ds/GradientWave.tsx b/packages/ui/src/components/ds/GradientWave.tsx index 51e3160..733f6be 100644 --- a/packages/ui/src/components/ds/GradientWave.tsx +++ b/packages/ui/src/components/ds/GradientWave.tsx @@ -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(null) diff --git a/packages/ui/src/components/ds/Led.tsx b/packages/ui/src/components/ds/Led.tsx index 39a615b..111f66b 100644 --- a/packages/ui/src/components/ds/Led.tsx +++ b/packages/ui/src/components/ds/Led.tsx @@ -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 = { - // '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 = { + // '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 ( { - /** 카드 내부의 상승 표면 (카드 안의 카드) */ + /** 카드 내부의 우묵한 영역 */ 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, }} > diff --git a/packages/ui/src/components/ds/PhosphorText.tsx b/packages/ui/src/components/ds/PhosphorText.tsx index 47634d5..71cfbdd 100644 --- a/packages/ui/src/components/ds/PhosphorText.tsx +++ b/packages/ui/src/components/ds/PhosphorText.tsx @@ -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 = { - 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 { @@ -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, }} /> diff --git a/packages/ui/src/components/ds/PhysicalButton.tsx b/packages/ui/src/components/ds/PhysicalButton.tsx index 7c2cc55..890ea36 100644 --- a/packages/ui/src/components/ds/PhysicalButton.tsx +++ b/packages/ui/src/components/ds/PhysicalButton.tsx @@ -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 { - /** 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 (