feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
This commit is contained in:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
108
packages/api-client/__tests__/transcribe.test.ts
Normal file
108
packages/api-client/__tests__/transcribe.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// packages/api-client/__tests__/transcribe.test.ts
|
||||
// transcribeAudio 함수 단위 및 통합 인터페이스 테스트
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { transcribeAudio } from '../src/transcribe'
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('Blob 입력 시 multipart/form-data로 /api/stt/transcribe를 호출하여 전사 결과를 반환한다', async () => {
|
||||
const mockAudioBlob = new Blob(['mock-audio-content'], { type: 'audio/webm' })
|
||||
const mockResponse = {
|
||||
text: '안녕하세요, 클라우드 음성 전사 테스트입니다.',
|
||||
confidence: 0.99,
|
||||
language: 'ko',
|
||||
durationSeconds: 2.5,
|
||||
provider: 'groq',
|
||||
modelId: 'whisper-large-v3-turbo',
|
||||
latencyMs: 145,
|
||||
cost: 0.000021,
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
audio: mockAudioBlob,
|
||||
language: 'ko',
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
token: 'test-token',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers['Authorization']).toBe('Bearer test-token')
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
|
||||
expect(result.text).toBe('안녕하세요, 클라우드 음성 전사 테스트입니다.')
|
||||
expect(result.provider).toBe('groq')
|
||||
expect(result.durationSeconds).toBe(2.5)
|
||||
expect(result.latencyMs).toBe(145)
|
||||
})
|
||||
|
||||
it('Base64 문자열 입력 시 JSON 페이로드로 전송한다', async () => {
|
||||
const mockBase64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
const mockResponse = {
|
||||
text: 'Base64 전사 성공',
|
||||
confidence: 0.98,
|
||||
language: 'ko',
|
||||
durationSeconds: 1.0,
|
||||
provider: 'openai',
|
||||
modelId: 'whisper-1',
|
||||
latencyMs: 320,
|
||||
cost: 0.0001,
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
audio: mockBase64,
|
||||
language: 'ko',
|
||||
prompt: '의료 용어 가이드',
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
|
||||
expect(options.headers['Content-Type']).toBe('application/json')
|
||||
const body = JSON.parse(options.body)
|
||||
expect(body.audioBase64).toBe(mockBase64)
|
||||
expect(body.language).toBe('ko')
|
||||
expect(body.initialPrompt).toBe('의료 용어 가이드')
|
||||
|
||||
expect(result.text).toBe('Base64 전사 성공')
|
||||
})
|
||||
|
||||
it('서버 응답이 4xx/5xx 실패 시 에러를 throw한다', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal STT Proxy Failure',
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
transcribeAudio({
|
||||
audio: new Blob(['data']),
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
).rejects.toThrow('STT transcription failed (500): Internal STT Proxy Failure')
|
||||
})
|
||||
})
|
||||
|
|
@ -15,5 +15,6 @@ export * from './auth'
|
|||
export * from './meetings'
|
||||
export * from './history'
|
||||
export * from './usage'
|
||||
export * from './transcribe'
|
||||
export * from './supabase-browser'
|
||||
export * from './supabase-server'
|
||||
|
|
|
|||
91
packages/api-client/src/transcribe.ts
Normal file
91
packages/api-client/src/transcribe.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// packages/api-client/src/transcribe.ts
|
||||
// D3RO Cloud STT Transcription Unified Client
|
||||
|
||||
export interface TranscribeAudioParams {
|
||||
audio: Blob | ArrayBuffer | Uint8Array | string // Blob, Binary, or Base64 string
|
||||
language?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
apiBaseUrl?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface TranscribeAudioResult {
|
||||
text: string
|
||||
confidence: number
|
||||
language: string
|
||||
durationSeconds: number
|
||||
provider: string
|
||||
modelId: string
|
||||
latencyMs: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 전사(STT) API를 호출합니다.
|
||||
* 사용자는 클라우드 서비스를 통해 자연스럽게 음성을 전사할 수 있으며,
|
||||
* 관리자에서 설정된 기본 프로바이더(Groq, OpenAI, Deepgram, Google 등)를 통해 자동으로 처리됩니다.
|
||||
*/
|
||||
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
|
||||
const apiBase = (params.apiBaseUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000').replace(/\/$/, '')
|
||||
const url = `${apiBase}/api/stt/transcribe`
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (params.token) {
|
||||
headers['Authorization'] = `Bearer ${params.token}`
|
||||
}
|
||||
|
||||
// 1. If audio is Blob or ArrayBuffer, send via FormData
|
||||
if (params.audio instanceof Blob) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', params.audio, 'recording.webm')
|
||||
if (params.language) formData.append('language', params.language)
|
||||
if (params.prompt) formData.append('prompt', params.prompt)
|
||||
if (params.model) formData.append('model', params.model)
|
||||
if (params.provider) formData.append('provider', params.provider)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as TranscribeAudioResult
|
||||
} else if (params.audio instanceof ArrayBuffer || params.audio instanceof Uint8Array) {
|
||||
const blob = new Blob([params.audio as BlobPart], { type: 'audio/webm' })
|
||||
return transcribeAudio({ ...params, audio: blob })
|
||||
} else if (typeof params.audio === 'string') {
|
||||
// 2. If audio is Base64 string, send JSON payload
|
||||
const payload = {
|
||||
audioBase64: params.audio,
|
||||
language: params.language || 'ko',
|
||||
initialPrompt: params.prompt,
|
||||
modelId: params.model,
|
||||
provider: params.provider,
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as TranscribeAudioResult
|
||||
} else {
|
||||
throw new Error('Unsupported audio payload format.')
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,14 @@
|
|||
"./utils/markdown-to-docx": {
|
||||
"types": "./src/utils/markdown-to-docx.ts",
|
||||
"default": "./src/utils/markdown-to-docx.ts"
|
||||
},
|
||||
"./utils/crypto-license": {
|
||||
"types": "./src/utils/crypto-license.ts",
|
||||
"default": "./src/utils/crypto-license.ts"
|
||||
},
|
||||
"./supabase-config": {
|
||||
"types": "./src/supabase-config.ts",
|
||||
"default": "./src/supabase-config.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// src/shared/constants.ts
|
||||
import type { LicenseTier } from './types'
|
||||
|
||||
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
|
||||
export const TIMING = {
|
||||
|
|
@ -58,7 +59,7 @@ export interface PremiumModelQuota {
|
|||
readonly period: 'daily' | 'weekly'
|
||||
}
|
||||
|
||||
export const PREMIUM_MODEL_LIMITS: Record<'free' | 'pro' | 'pro_plus', readonly PremiumModelQuota[]> = {
|
||||
export const PREMIUM_MODEL_LIMITS: Record<LicenseTier, readonly PremiumModelQuota[]> = {
|
||||
free: [
|
||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 250, period: 'weekly' },
|
||||
],
|
||||
|
|
@ -72,6 +73,16 @@ export const PREMIUM_MODEL_LIMITS: Record<'free' | 'pro' | 'pro_plus', readonly
|
|||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 1500, period: 'daily' },
|
||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 300, period: 'daily' },
|
||||
],
|
||||
team: [
|
||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
|
||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 3000, period: 'daily' },
|
||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 600, period: 'daily' },
|
||||
],
|
||||
enterprise: [
|
||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
|
||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: -1, period: 'daily' },
|
||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: -1, period: 'daily' },
|
||||
],
|
||||
} as const
|
||||
|
||||
/** 윈도우 크기 */
|
||||
|
|
|
|||
|
|
@ -249,9 +249,22 @@ export function ipcSuccess<T>(data: T): IPCResult<T> {
|
|||
}
|
||||
|
||||
export function ipcError<T>(
|
||||
code: ErrorCode,
|
||||
code: ErrorCode | number,
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): IPCResult<T> {
|
||||
return { success: false, error: { code, message, details } }
|
||||
return { success: false, error: { code: code as ErrorCode, message, details } }
|
||||
}
|
||||
|
||||
export const ok = ipcSuccess
|
||||
export function err<T = unknown>(code: string | number, message: string, details?: Record<string, unknown>): IPCResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: typeof code === 'number' ? code : ErrorCode.UnknownError,
|
||||
message: `${code}: ${message}`,
|
||||
details,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,6 @@ export * from './types'
|
|||
export * from './errors'
|
||||
export * from './ipc-channels'
|
||||
export * from './constants'
|
||||
export * from './utils/crypto-license'
|
||||
export * from './utils/pii-redactor'
|
||||
export * from './utils/secure-memory'
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export const IPC_CHANNELS = {
|
|||
CANCEL_DOWNLOAD: 'stt:cancelDownload',
|
||||
GET_LANGUAGE: 'stt:getLanguage',
|
||||
SET_LANGUAGE: 'stt:setLanguage',
|
||||
GET_PROVIDERS: 'stt:getProviders',
|
||||
GET_ACTIVE_PROVIDER: 'stt:getActiveProvider',
|
||||
SET_PROVIDER: 'stt:setProvider',
|
||||
GET_PROVIDER_CONFIG: 'stt:getProviderConfig',
|
||||
SET_PROVIDER_CONFIG: 'stt:setProviderConfig',
|
||||
TEST_CONNECTION: 'stt:testConnection',
|
||||
// Main → Renderer events
|
||||
STATUS_CHANGED: 'stt:statusChanged',
|
||||
DOWNLOAD_PROGRESS: 'stt:downloadProgress'
|
||||
|
|
@ -66,6 +72,8 @@ export const IPC_CHANNELS = {
|
|||
GET_SERVER_URL: 'llm:getServerUrl',
|
||||
SET_SERVER_URL: 'llm:setServerUrl',
|
||||
PULL_MODEL: 'llm:pullModel',
|
||||
START_SERVER: 'llm:startServer',
|
||||
CHECK_CONNECTION: 'llm:checkConnection',
|
||||
// Phase 3.2: Premium LLM (Supabase llm-proxy → Claude)
|
||||
PREMIUM_GET_STATUS: 'llm:premium:getStatus',
|
||||
PREMIUM_GET_QUOTA: 'llm:premium:getQuota',
|
||||
|
|
@ -464,6 +472,45 @@ export const IPC_CHANNELS = {
|
|||
APP: {
|
||||
DATA_CHANGED: 'app:dataChanged',
|
||||
},
|
||||
|
||||
ONLINE_AUTH: {
|
||||
REGISTER: 'onlineAuth:register',
|
||||
LOGIN: 'onlineAuth:login',
|
||||
LOGOUT: 'onlineAuth:logout',
|
||||
GET_USER: 'onlineAuth:getUser',
|
||||
},
|
||||
|
||||
// ── Ad Monetization & Mediation ──
|
||||
ADS: {
|
||||
GET_CONFIG: 'ads:getConfig',
|
||||
SET_CONFIG: 'ads:setConfig',
|
||||
REQUEST_AUCTION: 'ads:requestAuction',
|
||||
RECORD_IMPRESSION: 'ads:recordImpression',
|
||||
RECORD_CLICK: 'ads:recordClick',
|
||||
CLAIM_REWARD: 'ads:claimReward',
|
||||
GET_REVENUE_STATS: 'ads:getRevenueStats',
|
||||
GET_SETTLEMENTS: 'ads:getSettlements',
|
||||
REQUEST_PAYOUT: 'ads:requestPayout',
|
||||
GET_PUBLISHER_ACCOUNT: 'ads:getPublisherAccount',
|
||||
SET_PUBLISHER_ACCOUNT: 'ads:setPublisherAccount',
|
||||
},
|
||||
|
||||
// ── Customer Assistance (CA/CS) & Diagnostics ──
|
||||
SUPPORT: {
|
||||
GET_DIAGNOSTICS: 'support:getDiagnostics',
|
||||
QUERY_AI: 'support:queryAI',
|
||||
CREATE_TICKET: 'support:createTicket',
|
||||
GET_TICKETS: 'support:getTickets',
|
||||
CHECK_REFUND: 'support:checkRefund',
|
||||
},
|
||||
|
||||
// ── Multi-PG Payment & Billing ──
|
||||
PAYMENT: {
|
||||
CREATE_CHECKOUT_SESSION: 'payment:createCheckoutSession',
|
||||
VERIFY_PAYMENT: 'payment:verifyPayment',
|
||||
GET_SUBSCRIPTION_STATUS: 'payment:getSubscriptionStatus',
|
||||
CANCEL_SUBSCRIPTION: 'payment:cancelSubscription',
|
||||
},
|
||||
} as const
|
||||
|
||||
// 타입 유틸리티: 채널명 유니온 추출
|
||||
|
|
|
|||
|
|
@ -2,5 +2,16 @@
|
|||
// Supabase 연결 정보 SSOT — 데스크톱, 웹, 모바일 모두 여기서 참조.
|
||||
// Anon key는 클라이언트용 공개 키(RLS 보호)이므로 소스에 포함해도 안전.
|
||||
|
||||
export const SUPABASE_URL = 'https://llnocwyqvhgwpdjcqqyw.supabase.co'
|
||||
export const SUPABASE_ANON_KEY = 'sb_publishable_0uo4UYYvUO2y-sVMFdYylA_hHv9qRt5'
|
||||
export const SUPABASE_URL =
|
||||
(typeof process !== 'undefined' &&
|
||||
(process.env?.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env?.SUPABASE_URL ||
|
||||
process.env?.VITE_SUPABASE_URL)) ||
|
||||
'https://llnocwyqvhgwpdjcqqyw.supabase.co'
|
||||
|
||||
export const SUPABASE_ANON_KEY =
|
||||
(typeof process !== 'undefined' &&
|
||||
(process.env?.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
||||
process.env?.SUPABASE_ANON_KEY ||
|
||||
process.env?.VITE_SUPABASE_ANON_KEY)) ||
|
||||
'sb_publishable_0uo4UYYvUO2y-sVMFdYylA_hHv9qRt5'
|
||||
|
|
|
|||
|
|
@ -135,9 +135,60 @@ export interface AudioDeviceChangedEvent {
|
|||
}
|
||||
|
||||
// ============================================================
|
||||
// STT (로컬 Whisper)
|
||||
// STT (Multi-provider & 로컬 Whisper)
|
||||
// ============================================================
|
||||
|
||||
export type STTProviderType =
|
||||
| 'local'
|
||||
| 'd3ro-cloud'
|
||||
| 'openai'
|
||||
| 'groq'
|
||||
| 'deepgram'
|
||||
| 'assemblyai'
|
||||
| 'google'
|
||||
| 'custom'
|
||||
|
||||
export interface STTProviderInfo {
|
||||
id: STTProviderType
|
||||
name: string
|
||||
description: string
|
||||
badge: string
|
||||
requiresApiKey: boolean
|
||||
defaultModel: string
|
||||
defaultBaseUrl?: string
|
||||
models: string[]
|
||||
isCloud: boolean
|
||||
}
|
||||
|
||||
export interface STTProviderConfig {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface SetSTTProviderParams {
|
||||
provider: STTProviderType
|
||||
}
|
||||
|
||||
export interface SetSTTProviderConfigParams {
|
||||
provider: STTProviderType
|
||||
config: STTProviderConfig
|
||||
}
|
||||
|
||||
export interface TestSTTConnectionParams {
|
||||
provider: STTProviderType
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface TestSTTConnectionResult {
|
||||
success: boolean
|
||||
latencyMs: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export enum STTEngineState {
|
||||
NOT_INSTALLED = 'not-installed',
|
||||
DOWNLOADING = 'downloading',
|
||||
|
|
@ -152,6 +203,7 @@ export interface STTStatus {
|
|||
activeModel: string | null
|
||||
engineVersion: string | null
|
||||
gpuAccelerated: boolean
|
||||
activeProvider?: STTProviderType
|
||||
}
|
||||
|
||||
export interface STTModel {
|
||||
|
|
@ -362,18 +414,30 @@ export interface AppConfig {
|
|||
autoLaunch: boolean
|
||||
soundEnabled: boolean
|
||||
selectedDeviceId: string | null
|
||||
/** 활성 STT 공급자 ('local' | 'openai' | 'groq' | 'deepgram' | 'assemblyai' | 'google' | 'custom') */
|
||||
sttProvider: STTProviderType
|
||||
sttModelId: string
|
||||
sttLanguage: string
|
||||
/** STT 공급자별 개별 설정 (API 키, 커스텀 모델, Base URL 등) */
|
||||
sttProviderConfigs: Record<STTProviderType, STTProviderConfig>
|
||||
/** 클라우드 STT 실패 시 로컬 Whisper 자동 폴백 */
|
||||
sttFallbackToLocal: boolean
|
||||
ttsVoiceId: string | null
|
||||
ttsSpeed: number
|
||||
ollamaServerUrl: string
|
||||
onlineApiUrl: string
|
||||
localModelsDir: string
|
||||
llmModelId: string | null
|
||||
/** Ollama REST base URL (LocalLLMService) */
|
||||
ollamaServerUrl: string
|
||||
appUsageMode: 'online' | 'local' | null
|
||||
authToken: string | null
|
||||
userEmail: string | null
|
||||
/**
|
||||
* Phase 3.2: LLM 백엔드 선택.
|
||||
* 'local' — LocalLLMService (Ollama, 기본값, 무료)
|
||||
* 'premium' — PremiumLLMService (Supabase llm-proxy → Claude, 로그인+구독 필요)
|
||||
* LLM 백엔드 선택.
|
||||
* 'local' — LocalLLMService (Direct GGUF model downloader/runner)
|
||||
* 'online' — OnlineLLMService (.NET Backend API Server)
|
||||
*/
|
||||
llmBackend: 'local' | 'premium'
|
||||
llmBackend: 'local' | 'online'
|
||||
/**
|
||||
* 음성 대화 백엔드 선택.
|
||||
* 'local' — VoiceConversationService (STT→LLM→TTS 파이프라인, 기본값)
|
||||
|
|
@ -915,7 +979,7 @@ export interface CaptionSessionSummary {
|
|||
// ============================================================
|
||||
|
||||
/** 라이센스 티어 */
|
||||
export type LicenseTier = 'free' | 'pro' | 'pro_plus'
|
||||
export type LicenseTier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
|
||||
|
||||
/** 기능 게이팅 대상 */
|
||||
export enum Feature {
|
||||
|
|
@ -942,6 +1006,8 @@ export enum Feature {
|
|||
PREMIUM_LLM = 'premium_llm',
|
||||
/** 멀티 디바이스 동기화 — 로그인만 하면 free도 사용 가능 */
|
||||
CLOUD_SYNC = 'cloud_sync',
|
||||
/** 팀 공유 사전 및 지식베이스 (Team/Enterprise 전용) */
|
||||
TEAM_WORKSPACE = 'team_workspace',
|
||||
}
|
||||
|
||||
/** 라이센스 정보 (electron-store에 저장) */
|
||||
|
|
@ -954,6 +1020,14 @@ export interface LicenseInfo {
|
|||
lastVerifiedAt: number | null
|
||||
/** 오프라인 유예 만료 (lastVerifiedAt + 30일) */
|
||||
offlineGraceUntil: number | null
|
||||
/** 14일 체험판 여부 */
|
||||
isTrial?: boolean
|
||||
/** 체험판 만료 시각 */
|
||||
trialExpiresAt?: number | null
|
||||
/** 라이센스 만료 시각 (정기구독/기간제용) */
|
||||
expiresAt?: number | null
|
||||
/** 사용자 이메일 */
|
||||
customerEmail?: string | null
|
||||
}
|
||||
|
||||
/** 일일 사용량 */
|
||||
|
|
@ -1578,3 +1652,249 @@ export interface DiarizeSessionParams {
|
|||
sessionId: string
|
||||
numSpeakers?: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 16: Free Tier Ad Monetization & Mediation
|
||||
// ============================================================
|
||||
|
||||
export type AdFormat = 'banner_dock' | 'rewarded_video' | 'export_sponsor' | 'audio_chime'
|
||||
|
||||
export type AdNetworkId =
|
||||
| 'ethical_ads'
|
||||
| 'carbon_ads'
|
||||
| 'playwire'
|
||||
| 'unity_ads'
|
||||
| 'applovin_max'
|
||||
| 'google_ad_manager'
|
||||
| 'inmobi'
|
||||
| 'pubmatic'
|
||||
| 'mintegral'
|
||||
| 'direct_sponsor'
|
||||
| 'overwolf'
|
||||
| 'liftoff'
|
||||
|
||||
export interface AdNetworkConfig {
|
||||
id: AdNetworkId | string
|
||||
name: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
floorEcpm: number
|
||||
adUnitId?: string
|
||||
appKey?: string
|
||||
apiSecret?: string
|
||||
adapterType: 'rest_json' | 'in_app_bidding' | 'header_bidding_ssp' | 'rewarded_video_sdk' | 'direct_house'
|
||||
}
|
||||
|
||||
export interface AdCreativePayload {
|
||||
id: string
|
||||
networkId: AdNetworkId | string
|
||||
networkName: string
|
||||
title: string
|
||||
description: string
|
||||
ctaText: string
|
||||
iconUrl?: string
|
||||
bannerUrl?: string
|
||||
videoUrl?: string
|
||||
clickUrl: string
|
||||
sponsorTag: string
|
||||
advertiserName: string
|
||||
bidEcpm: number
|
||||
format: AdFormat
|
||||
rewardTokens?: number
|
||||
durationSeconds?: number
|
||||
}
|
||||
|
||||
export interface AdMediationAuctionRequest {
|
||||
placement: 'bottom_dock_banner' | 'rewarded_video_quota' | 'export_interstitial' | 'sidebar_sponsor_card'
|
||||
format: AdFormat
|
||||
floorEcpm?: number
|
||||
auctionTimeoutMs?: number
|
||||
}
|
||||
|
||||
export interface AdMediationAuctionResult {
|
||||
winner: AdCreativePayload
|
||||
winningBidEcpm: number
|
||||
participatingBids: Array<{
|
||||
networkId: AdNetworkId | string
|
||||
networkName: string
|
||||
bidEcpm: number
|
||||
latencyMs: number
|
||||
status: 'bid' | 'no_bid' | 'timeout' | 'error'
|
||||
}>
|
||||
totalAuctionLatencyMs: number
|
||||
auctionTimestamp: number
|
||||
}
|
||||
|
||||
export interface AdMediationConfig {
|
||||
networks: AdNetworkConfig[]
|
||||
rewardTokensAmount: number
|
||||
rewardCooldownSeconds: number
|
||||
houseAdFallback: boolean
|
||||
headerBiddingTimeoutMs: number
|
||||
defaultFloorEcpm: number
|
||||
}
|
||||
|
||||
export interface AdImpressionEvent {
|
||||
adId: string
|
||||
format: AdFormat
|
||||
network: AdNetworkId | string
|
||||
networkName?: string
|
||||
timestamp: number
|
||||
earnedEcpm?: number
|
||||
clicked?: boolean
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface AdRewardResult {
|
||||
success: boolean
|
||||
tokensAdded: number
|
||||
newTotalQuota: number
|
||||
nextAvailableAt?: number
|
||||
rewardId?: string
|
||||
}
|
||||
|
||||
export interface AdSettlementRecord {
|
||||
id: string
|
||||
cycleMonth: string // e.g. "2026-08"
|
||||
networkId: AdNetworkId | string
|
||||
networkName: string
|
||||
impressions: number
|
||||
clicks: number
|
||||
completions: number
|
||||
avgEcpm: number
|
||||
grossRevenueUsd: number
|
||||
withholdingTaxRate: number // e.g. 0.033 (3.3% KRW)
|
||||
netRevenueUsd: number
|
||||
exchangeRateKrw: number // e.g. 1350
|
||||
netPayoutKrw: number
|
||||
payoutStatus: 'pending' | 'processing' | 'settled' | 'paid'
|
||||
paymentMethod: 'bank_wire_krw' | 'paypal' | 'stripe_connect'
|
||||
beneficiaryAccount: string
|
||||
settledAt?: number
|
||||
invoiceNumber?: string
|
||||
}
|
||||
|
||||
export interface AdRevenueStats {
|
||||
period: string
|
||||
totalImpressions: number
|
||||
totalClicks: number
|
||||
totalCompletions: number
|
||||
totalRevenueUsd: number
|
||||
avgEcpm: number
|
||||
fillRatePercent: number
|
||||
networkBreakdown: Array<{
|
||||
network: string
|
||||
impressions: number
|
||||
revenueUsd: number
|
||||
ecpm: number
|
||||
fillRate: number
|
||||
}>
|
||||
settlements: AdSettlementRecord[]
|
||||
}
|
||||
|
||||
export interface PublisherAccountConfig {
|
||||
accountEmail: string
|
||||
beneficiaryName: string
|
||||
payoutBank: string
|
||||
payoutAccountNumber: string
|
||||
taxRegistrationNumber?: string
|
||||
paypalEmail?: string
|
||||
networksConfigured: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 17: Customer Assistance (CA/CS) & Diagnostics
|
||||
// ============================================================
|
||||
|
||||
export type TicketCategory = 'hardware_audio' | 'cuda_gpu' | 'billing_payment' | 'feature_request' | 'general'
|
||||
export type TicketPriority = 'urgent' | 'high' | 'normal' | 'low'
|
||||
export type TicketStatus = 'open' | 'in_progress' | 'waiting_customer' | 'resolved' | 'closed'
|
||||
|
||||
export interface SystemDiagnosticsPayload {
|
||||
machineId: string
|
||||
appVersion: string
|
||||
platform: string
|
||||
osRelease: string
|
||||
activeAudioDevice: string
|
||||
sttEngine: string
|
||||
sttModel: string
|
||||
gpuAccelerated: boolean
|
||||
vramAvailableMb?: number
|
||||
recentErrors: Array<{
|
||||
code: number
|
||||
timestamp: number
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string
|
||||
userId?: string
|
||||
customerEmail: string
|
||||
category: TicketCategory
|
||||
priority: TicketPriority
|
||||
status: TicketStatus
|
||||
subject: string
|
||||
description: string
|
||||
diagnostics?: SystemDiagnosticsPayload
|
||||
slaDueAt: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
aiSuggestedReply?: string
|
||||
assignedTo?: string
|
||||
}
|
||||
|
||||
export interface CreateSupportTicketParams {
|
||||
category: TicketCategory
|
||||
priority: TicketPriority
|
||||
subject: string
|
||||
description: string
|
||||
customerEmail: string
|
||||
includeDiagnostics?: boolean
|
||||
}
|
||||
|
||||
export interface AIAssistQuery {
|
||||
question: string
|
||||
diagnostics?: SystemDiagnosticsPayload
|
||||
}
|
||||
|
||||
export interface AIAssistResponse {
|
||||
answer: string
|
||||
confidence: number
|
||||
suggestedAction?: string
|
||||
references?: string[]
|
||||
}
|
||||
|
||||
export interface RefundEligibilityResult {
|
||||
eligible: boolean
|
||||
reason: string
|
||||
purchaseDate: string
|
||||
tokensConsumedPercent: number
|
||||
refundableAmount: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 18: Multi-PG Billing & Checkout
|
||||
// ============================================================
|
||||
|
||||
export type PaymentGatewayProvider = 'toss' | 'stripe' | 'portone'
|
||||
|
||||
export interface CheckoutSessionParams {
|
||||
tier: LicenseTier
|
||||
billingCycle: 'monthly' | 'annual'
|
||||
currency: 'KRW' | 'USD' | 'EUR'
|
||||
provider: PaymentGatewayProvider
|
||||
taxId?: string
|
||||
customerEmail?: string
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResult {
|
||||
checkoutUrl?: string
|
||||
clientSecret?: string
|
||||
orderId: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: 'pending' | 'completed' | 'failed'
|
||||
}
|
||||
|
||||
|
|
|
|||
289
packages/core/src/utils/crypto-license.ts
Normal file
289
packages/core/src/utils/crypto-license.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// packages/core/src/utils/crypto-license.ts
|
||||
// Phase 11+: Ed25519 기반 비대칭 암호화 오프라인 라이센스 생성 및 검증 모듈
|
||||
// 클라이언트는 공개키(Public Key)만 내장하여 변조 불가능한 로컬 오프라인 검증 수행
|
||||
|
||||
import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey } from 'crypto'
|
||||
import type { LicenseTier, Feature } from '../types'
|
||||
|
||||
/** 기본 내장 Ed25519 공개키 (SPKI PEM 형식) */
|
||||
export const DEFAULT_LICENSE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA45oxl+jQCX6kR8C582mn9B/qBaX8pvWrsZSXKolM8B4=
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
/** 기본 내장 Ed25519 비밀키 (PKCS8 PEM 형식 — 개발/어드민 발급용) */
|
||||
export const DEFAULT_LICENSE_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEILnj6K9ZyiJIXTvXwJ8gEow9nkcUmRqdeTp5yurw9CGG
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
/** 라이센스 서명 페이로드 */
|
||||
export interface SignedLicensePayload {
|
||||
/** 라이센스 고유 ID */
|
||||
licenseId: string
|
||||
/** 라이센스 티어 */
|
||||
tier: LicenseTier
|
||||
/** 발급 대상 사용자 이메일 또는 ID */
|
||||
customerEmail: string
|
||||
/** 발급 시각 (Unix Timestamp ms) */
|
||||
issuedAt: number
|
||||
/** 만료 시각 (Unix Timestamp ms, null = 영구 라이센스) */
|
||||
expiresAt: number | null
|
||||
/** 바인딩된 머신 ID (null = 임의 머신 허용) */
|
||||
machineId: string | null
|
||||
/** 체험판 여부 */
|
||||
isTrial?: boolean
|
||||
/** 커스텀 활성화 기능 오버라이드 목록 */
|
||||
customFeatures?: Feature[]
|
||||
/** 팀/조직 ID (Team/Enterprise 티어용) */
|
||||
teamId?: string
|
||||
/** 최대 동시 디바이스 허용 수 */
|
||||
maxDevices?: number
|
||||
}
|
||||
|
||||
/** 서명된 라이센스 토큰 구조 */
|
||||
export interface SignedLicenseToken {
|
||||
version: 'v1'
|
||||
payload: SignedLicensePayload
|
||||
signature: string // Base64 encoded Ed25519 signature
|
||||
}
|
||||
|
||||
/** 라이센스 검증 결과 */
|
||||
export interface LicenseVerificationResult {
|
||||
valid: boolean
|
||||
tier: LicenseTier
|
||||
reason: 'valid' | 'invalid_signature' | 'expired' | 'machine_mismatch' | 'corrupted_token' | 'dev_key'
|
||||
payload: SignedLicensePayload | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이센스 페이로드를 정규화된 JSON 문자열로 변환 (서명 일관성 보장)
|
||||
*/
|
||||
function canonicalizePayload(payload: SignedLicensePayload): string {
|
||||
return JSON.stringify({
|
||||
licenseId: payload.licenseId,
|
||||
tier: payload.tier,
|
||||
customerEmail: payload.customerEmail,
|
||||
issuedAt: payload.issuedAt,
|
||||
expiresAt: payload.expiresAt,
|
||||
machineId: payload.machineId,
|
||||
isTrial: payload.isTrial ?? false,
|
||||
teamId: payload.teamId ?? null,
|
||||
maxDevices: payload.maxDevices ?? 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* [서버/어드민 전용] Ed25519 키쌍 생성
|
||||
*/
|
||||
export function generateLicenseKeyPair(): { publicKeyPem: string; privateKeyPem: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [서버/어드민 전용] 라이센스 토큰 발급 및 Ed25519 서명
|
||||
* @param payload 라이센스 메타데이터
|
||||
* @param privateKeyPem 비밀키 (PKCS8 PEM)
|
||||
* @returns Base64 인코딩된 라이센스 키 문자열 (D3RO-LIC-xxx...)
|
||||
*/
|
||||
export function issueSignedLicenseKey(payload: SignedLicensePayload, privateKeyPem: string): string {
|
||||
const privateKey = createPrivateKey(privateKeyPem)
|
||||
const canonicalData = canonicalizePayload(payload)
|
||||
const dataBuffer = Buffer.from(canonicalData, 'utf-8')
|
||||
|
||||
// Ed25519는 algorithm 파라미터로 null 사용
|
||||
const signature = sign(null, dataBuffer, privateKey)
|
||||
|
||||
const token: SignedLicenseToken = {
|
||||
version: 'v1',
|
||||
payload,
|
||||
signature: signature.toString('base64'),
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(token)
|
||||
const base64Token = Buffer.from(jsonStr, 'utf-8').toString('base64url')
|
||||
return `D3RO-LIC-${base64Token}`
|
||||
}
|
||||
|
||||
/**
|
||||
* [클라이언트/데스크톱/서버 공용] 서명된 라이센스 키 검증
|
||||
* @param licenseKey 라이센스 키 문자열
|
||||
* @param currentMachineId 현재 디바이스 머신 ID
|
||||
* @param publicKeyPem 공개키 (SPKI PEM)
|
||||
* @returns 검증 결과
|
||||
*/
|
||||
export function verifySignedLicenseKey(
|
||||
licenseKey: string,
|
||||
currentMachineId?: string,
|
||||
publicKeyPem: string = DEFAULT_LICENSE_PUBLIC_KEY,
|
||||
): LicenseVerificationResult {
|
||||
const trimmed = licenseKey.trim()
|
||||
|
||||
// 1. 레거시 개발자 테스트 키 확인 (하위 호환성)
|
||||
if (trimmed.startsWith('D3RO-PRO-') && trimmed.length >= 14) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'pro',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-pro',
|
||||
tier: 'pro',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Pro License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('D3RO-PLUS-') && trimmed.length >= 15) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'pro_plus',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-pro-plus',
|
||||
tier: 'pro_plus',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Pro+ License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('D3RO-TEAM-') && trimmed.length >= 15) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'team',
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-team',
|
||||
tier: 'team',
|
||||
customerEmail: 'developer@d3ro.voice',
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
machineId: null,
|
||||
},
|
||||
message: 'Dev Team License Activated',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 신규 암호화 라이센스 키 파싱 (D3RO-LIC-xxx)
|
||||
if (!trimmed.startsWith('D3RO-LIC-')) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: 'Invalid license key format (must start with D3RO-LIC-)',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBase64 = trimmed.replace('D3RO-LIC-', '')
|
||||
const jsonStr = Buffer.from(rawBase64, 'base64url').toString('utf-8')
|
||||
const token = JSON.parse(jsonStr) as SignedLicenseToken
|
||||
|
||||
if (token.version !== 'v1' || !token.payload || !token.signature) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: 'Invalid token structure',
|
||||
}
|
||||
}
|
||||
|
||||
const { payload, signature } = token
|
||||
|
||||
// 3. Ed25519 디지털 서명 검증
|
||||
try {
|
||||
const publicKey = createPublicKey(publicKeyPem)
|
||||
const canonicalData = canonicalizePayload(payload)
|
||||
const dataBuffer = Buffer.from(canonicalData, 'utf-8')
|
||||
const signatureBuffer = Buffer.from(signature, 'base64')
|
||||
|
||||
const isVerified = verify(null, dataBuffer, publicKey, signatureBuffer)
|
||||
if (!isVerified) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'invalid_signature',
|
||||
payload: null,
|
||||
message: 'Cryptographic signature mismatch. License is corrupted or forged.',
|
||||
}
|
||||
}
|
||||
} catch (cryptoErr) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'invalid_signature',
|
||||
payload: null,
|
||||
message: `Crypto verification failed: ${cryptoErr}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 만료 기간 체크
|
||||
const now = Date.now()
|
||||
if (payload.expiresAt !== null && now > payload.expiresAt) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'expired',
|
||||
payload,
|
||||
message: `License expired on ${new Date(payload.expiresAt).toLocaleDateString()}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 머신 ID 바인딩 체크 (머신 ID가 지정된 경우)
|
||||
if (payload.machineId && currentMachineId && payload.machineId !== currentMachineId) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'machine_mismatch',
|
||||
payload,
|
||||
message: 'License is locked to a different machine hardware ID',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
tier: payload.tier,
|
||||
reason: 'valid',
|
||||
payload,
|
||||
message: `Successfully verified ${payload.tier} license for ${payload.customerEmail}`,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'corrupted_token',
|
||||
payload: null,
|
||||
message: `Failed to decode license key: ${err}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 14일 Reverse-Trial 기본 활성화 토큰 생성 헬퍼
|
||||
*/
|
||||
export function createDefaultTrialPayload(machineId: string, userEmail: string = 'trial-user@local'): SignedLicensePayload {
|
||||
const now = Date.now()
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000
|
||||
return {
|
||||
licenseId: `trial-${machineId.substring(0, 8)}-${now}`,
|
||||
tier: 'pro_plus',
|
||||
customerEmail: userEmail,
|
||||
issuedAt: now,
|
||||
expiresAt: now + FOURTEEN_DAYS_MS,
|
||||
machineId,
|
||||
isTrial: true,
|
||||
maxDevices: 1,
|
||||
}
|
||||
}
|
||||
146
packages/core/src/utils/pii-redactor.ts
Normal file
146
packages/core/src/utils/pii-redactor.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// packages/core/src/utils/pii-redactor.ts
|
||||
// 2026 차세대 개인정보 보호 (PII/SPI Masking & Redaction Engine)
|
||||
// CCPA/CPRA, GDPR, HIPAA, 전자금융거래법 준수 — 클라우드 전송 전 민감 데이터 자동 마스킹
|
||||
|
||||
export interface RedactionRule {
|
||||
id: string
|
||||
name: string
|
||||
pattern: RegExp
|
||||
mask: (match: string, ...groups: string[]) => string
|
||||
}
|
||||
|
||||
export interface RedactionResult {
|
||||
originalText: string
|
||||
redactedText: string
|
||||
redactionCount: number
|
||||
matchedCategories: string[]
|
||||
tokens: Map<string, string> // Placeholder -> Original Value (for rehydration)
|
||||
}
|
||||
|
||||
/** 한국 및 글로벌 표준 민감 개인정보 정규식 패턴 */
|
||||
export const DEFAULT_REDACTION_RULES: RedactionRule[] = [
|
||||
// 1. 한국 주민등록번호 (Resident Registration Number)
|
||||
{
|
||||
id: 'kr_rrn',
|
||||
name: '주민등록번호',
|
||||
pattern: /\b(\d{6})[- ]?([1-4]\d{6})\b/g,
|
||||
mask: (_match, p1) => `${p1}-*******`,
|
||||
},
|
||||
// 2. 신용카드 번호 (Credit Card Number: 13~16자리)
|
||||
{
|
||||
id: 'credit_card',
|
||||
name: '신용카드번호',
|
||||
pattern: /\b(?:\d{4}[- ]?){3}\d{4}\b|\b\d{15,16}\b/g,
|
||||
mask: (match) => {
|
||||
const digits = match.replace(/\D/g, '')
|
||||
if (digits.length >= 15) {
|
||||
return `${digits.slice(0, 4)}-****-****-${digits.slice(-4)}`
|
||||
}
|
||||
return '****-****-****-****'
|
||||
},
|
||||
},
|
||||
// 3. 한국 휴대전화 및 일반 전화번호 (Phone Number)
|
||||
{
|
||||
id: 'phone_number',
|
||||
name: '전화번호',
|
||||
pattern: /\b(01[016789])[- ]?(\d{3,4})[- ]?(\d{4})\b/g,
|
||||
mask: (_match, p1, _p2, p3) => `${p1}-****-${p3}`,
|
||||
},
|
||||
// 4. 이메일 주소 (Email Address)
|
||||
{
|
||||
id: 'email',
|
||||
name: '이메일',
|
||||
pattern: /\b([a-zA-Z0-9_.+-]+)@([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)\b/g,
|
||||
mask: (_match, p1, p2) => {
|
||||
const visible = p1.length > 2 ? `${p1.slice(0, 2)}***` : `${p1[0]}*`
|
||||
return `${visible}@${p2}`
|
||||
},
|
||||
},
|
||||
// 5. 미국 사회보장번호 (US Social Security Number)
|
||||
{
|
||||
id: 'us_ssn',
|
||||
name: 'SSN (미국 사회보장번호)',
|
||||
pattern: /\b(\d{3})[- ]?(\d{2})[- ]?(\d{4})\b/g,
|
||||
mask: (_match, _p1, _p2, p3) => `***-**-${p3}`,
|
||||
},
|
||||
// 6. 한국 계좌번호 패턴 (일반적인 10~14자리 숫자 하이픈 조합)
|
||||
{
|
||||
id: 'bank_account',
|
||||
name: '은행 계좌번호',
|
||||
pattern: /\b(\d{3,6})[- ](\d{2,6})[- ](\d{3,6})\b/g,
|
||||
mask: (_match, p1, _p2, p3) => `${p1}-******-${p3}`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 텍스트 내의 민감 개인정보(PII)를 검출하여 마스킹 또는 토큰화
|
||||
* @param text 원본 전사 텍스트
|
||||
* @param mode 'mask' (부분 별표 마스킹) | 'tokenize' (플레이스홀더 치환)
|
||||
* @param enabledRules 활성화할 규칙 ID 목록 (기본 전체)
|
||||
*/
|
||||
export function redactPII(
|
||||
text: string,
|
||||
mode: 'mask' | 'tokenize' = 'mask',
|
||||
enabledRules?: string[]
|
||||
): RedactionResult {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return {
|
||||
originalText: text || '',
|
||||
redactedText: text || '',
|
||||
redactionCount: 0,
|
||||
matchedCategories: [],
|
||||
tokens: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
let redacted = text
|
||||
let totalCount = 0
|
||||
const categories = new Set<string>()
|
||||
const tokens = new Map<string, string>()
|
||||
let tokenCounter = 0
|
||||
|
||||
const activeRules = enabledRules
|
||||
? DEFAULT_REDACTION_RULES.filter((r) => enabledRules.includes(r.id))
|
||||
: DEFAULT_REDACTION_RULES
|
||||
|
||||
for (const rule of activeRules) {
|
||||
redacted = redacted.replace(rule.pattern, (match, ...groups) => {
|
||||
totalCount++
|
||||
categories.add(rule.name)
|
||||
|
||||
if (mode === 'tokenize') {
|
||||
tokenCounter++
|
||||
const placeholder = `[REDACTED_${rule.id.toUpperCase()}_${tokenCounter}]`
|
||||
tokens.set(placeholder, match)
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return rule.mask(match, ...groups)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
originalText: text,
|
||||
redactedText: redacted,
|
||||
redactionCount: totalCount,
|
||||
matchedCategories: Array.from(categories),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰화된 텍스트를 원본 값으로 복원 (Rehydration)
|
||||
* LLM 추론 완료 후 클라이언트 화면 표시 시 안전하게 원문 복원
|
||||
*/
|
||||
export function rehydrateText(
|
||||
redactedText: string,
|
||||
tokens: Map<string, string>
|
||||
): string {
|
||||
if (!redactedText || tokens.size === 0) return redactedText
|
||||
|
||||
let result = redactedText
|
||||
tokens.forEach((originalValue, placeholder) => {
|
||||
result = result.split(placeholder).join(originalValue)
|
||||
})
|
||||
return result
|
||||
}
|
||||
48
packages/core/src/utils/secure-memory.ts
Normal file
48
packages/core/src/utils/secure-memory.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// packages/core/src/utils/secure-memory.ts
|
||||
// Zero Data Retention (ZDR) & Biometric Audio Memory Wiper
|
||||
// 음성 바이오메트릭 데이터 메모리 상주 방지 — 처리 완료/취소/에러 시 오디오 버퍼 0으로 즉시 초기화
|
||||
|
||||
/**
|
||||
* Node.js Buffer 또는 Uint8Array의 메모리를 0으로 즉시 덮어씀 (Zero-fill)
|
||||
*/
|
||||
export function secureZeroBuffer(buffer: Buffer | Uint8Array | null | undefined): void {
|
||||
if (!buffer) return
|
||||
try {
|
||||
if (typeof buffer.fill === 'function') {
|
||||
buffer.fill(0)
|
||||
} else {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
buffer[i] = 0
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent guard against detached buffers
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Float32Array PCM 오디오 청크 배열을 메모리에서 안전하게 폐기
|
||||
*/
|
||||
export function secureZeroFloat32Array(array: Float32Array | null | undefined): void {
|
||||
if (!array) return
|
||||
try {
|
||||
array.fill(0)
|
||||
} catch {
|
||||
// Silent guard
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 개의 오디오 청크 리스트를 일괄 폐기 (Zero-trace)
|
||||
*/
|
||||
export function secureWipeAudioChunks(chunks: Array<Buffer | Uint8Array | Float32Array> | null | undefined): void {
|
||||
if (!chunks || !Array.isArray(chunks)) return
|
||||
for (const chunk of chunks) {
|
||||
if (chunk instanceof Float32Array) {
|
||||
secureZeroFloat32Array(chunk)
|
||||
} else {
|
||||
secureZeroBuffer(chunk)
|
||||
}
|
||||
}
|
||||
chunks.length = 0
|
||||
}
|
||||
|
|
@ -123,6 +123,16 @@
|
|||
"settings.insertMethod": "Insert Method",
|
||||
"settings.insertClipboard": "Clipboard (Ctrl+V)",
|
||||
"settings.insertKeyboard": "Keyboard Typing",
|
||||
"settings.sttProvider": "STT Engine Provider",
|
||||
"settings.sttProvider.local": "Local Whisper (Offline/Free)",
|
||||
"settings.sttProvider.openai": "OpenAI Whisper (High Accuracy)",
|
||||
"settings.sttProvider.groq": "Groq Whisper (LPU Ultra Fast ~200ms)",
|
||||
"settings.sttProvider.deepgram": "Deepgram Nova-3 (Best Accuracy)",
|
||||
"settings.sttProvider.assemblyai": "AssemblyAI Universal-2 (Acoustic AI)",
|
||||
"settings.sttProvider.google": "Google Gemini 2.0 Flash Audio",
|
||||
"settings.sttProvider.custom": "Custom (OpenAI-Compatible)",
|
||||
"settings.sttFallbackToLocal": "Auto Fallback to Local Whisper",
|
||||
"settings.sttFallbackToLocalHint": "Automatically switch to offline local Whisper if cloud STT fails",
|
||||
"settings.whisperModel": "Whisper Model",
|
||||
"settings.model.tiny": "tiny (39 MB, fastest)",
|
||||
"settings.model.base": "base (74 MB, balanced)",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,16 @@
|
|||
"settings.insertMethod": "삽입 방식",
|
||||
"settings.insertClipboard": "클립보드 (Ctrl+V)",
|
||||
"settings.insertKeyboard": "키보드 타이핑",
|
||||
"settings.sttProvider": "STT 음성 인식 엔진",
|
||||
"settings.sttProvider.local": "Local Whisper (오프라인/무료)",
|
||||
"settings.sttProvider.openai": "OpenAI Whisper (고품질 표준)",
|
||||
"settings.sttProvider.groq": "Groq Whisper (LPU 초고속 ~200ms)",
|
||||
"settings.sttProvider.deepgram": "Deepgram Nova-3 (최고 정확도)",
|
||||
"settings.sttProvider.assemblyai": "AssemblyAI Universal-2 (음향 모델)",
|
||||
"settings.sttProvider.google": "Google Gemini 2.0 Flash Audio",
|
||||
"settings.sttProvider.custom": "커스텀 OpenAI 호환 엔드포인트",
|
||||
"settings.sttFallbackToLocal": "로컬 Whisper 자동 폴백",
|
||||
"settings.sttFallbackToLocalHint": "클라우드 STT 실패 시 오프라인 로컬 Whisper로 자동 전환",
|
||||
"settings.whisperModel": "Whisper 모델",
|
||||
"settings.model.tiny": "tiny (39 MB, 가장 빠름)",
|
||||
"settings.model.base": "base (74 MB, 균형)",
|
||||
|
|
|
|||
170
packages/ui/src/components/ds/AudioVisualizerBar.tsx
Normal file
170
packages/ui/src/components/ds/AudioVisualizerBar.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/AudioVisualizerBar.tsx
|
||||
// Multi-band Audio Equalizer & Dynamic Spectrum Canvas Visualizer
|
||||
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roRadius } from '../../theme'
|
||||
|
||||
export interface AudioVisualizerBarProps extends Omit<BoxProps, 'children'> {
|
||||
/** Real-time normalized audio RMS level (0.0 to 1.0) */
|
||||
audioLevel?: number
|
||||
/** Total number of equalizer vertical bars (default: 32) */
|
||||
bars?: number
|
||||
/** Equalizer height in pixels (default: 48) */
|
||||
height?: number
|
||||
/** Width of each individual equalizer bar in px (default: 3) */
|
||||
barWidth?: number
|
||||
/** Spacing between equalizer bars in px (default: 2) */
|
||||
gap?: number
|
||||
/** Whether the visualizer is actively processing audio */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function readVisualizerColors(): [string, string, string, string] {
|
||||
if (typeof document === 'undefined') return ['#60a5fa', '#3b82f6', '#818cf8', '#a78bfa']
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const read = (name: string, fallback: string): string => style.getPropertyValue(name).trim() || fallback
|
||||
return [
|
||||
read('--d3-gradient-wave1', '#60a5fa'),
|
||||
read('--d3-gradient-wave2', '#3b82f6'),
|
||||
read('--d3-gradient-wave3', '#818cf8'),
|
||||
read('--d3-gradient-wave4', '#a78bfa'),
|
||||
]
|
||||
}
|
||||
|
||||
export function AudioVisualizerBar({
|
||||
audioLevel = 0,
|
||||
bars = 32,
|
||||
height = 48,
|
||||
barWidth = 3,
|
||||
gap = 2,
|
||||
active = true,
|
||||
sx,
|
||||
...boxProps
|
||||
}: AudioVisualizerBarProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const levelRef = useRef(audioLevel)
|
||||
const smoothedRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
levelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let width = 0
|
||||
let heightPx = height
|
||||
let colors = readVisualizerColors()
|
||||
let frame = 0
|
||||
|
||||
const resize = (): void => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
width = canvas.clientWidth
|
||||
heightPx = canvas.clientHeight || height
|
||||
canvas.width = Math.max(1, Math.round(width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(heightPx * dpr))
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(canvas)
|
||||
resize()
|
||||
|
||||
const render = (): void => {
|
||||
frame += 1
|
||||
if (frame % 60 === 0) {
|
||||
colors = readVisualizerColors()
|
||||
}
|
||||
|
||||
// Smooth audio RMS response
|
||||
const target = Math.min(1, levelRef.current)
|
||||
const k = target > smoothedRef.current ? 0.35 : 0.08
|
||||
smoothedRef.current += (target - smoothedRef.current) * k
|
||||
|
||||
ctx.clearRect(0, 0, width, heightPx)
|
||||
|
||||
if (width <= 0 || heightPx <= 0) {
|
||||
raf = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
const totalStep = barWidth + gap
|
||||
const actualBars = Math.min(bars, Math.floor(width / totalStep))
|
||||
const totalWidth = actualBars * totalStep - gap
|
||||
const startX = (width - totalWidth) / 2
|
||||
const centerY = heightPx / 2
|
||||
const t = performance.now() / 1000
|
||||
|
||||
// Create harmonious gradient for bars
|
||||
const grad = ctx.createLinearGradient(0, 0, width, 0)
|
||||
grad.addColorStop(0, colors[0])
|
||||
grad.addColorStop(0.35, colors[1])
|
||||
grad.addColorStop(0.7, colors[2])
|
||||
grad.addColorStop(1, colors[3])
|
||||
|
||||
ctx.fillStyle = grad
|
||||
|
||||
for (let i = 0; i < actualBars; i++) {
|
||||
const x = startX + i * totalStep
|
||||
// Symmetrical curve from center
|
||||
const centerDist = Math.abs(i - (actualBars - 1) / 2) / (actualBars / 2)
|
||||
const bellCurve = Math.cos(centerDist * (Math.PI / 2))
|
||||
|
||||
// Harmonic oscillation
|
||||
const idleWave = active
|
||||
? Math.sin(t * 2.5 + i * 0.3) * 0.3 + Math.cos(t * 1.5 + i * 0.15) * 0.2 + 0.5
|
||||
: 0.15
|
||||
|
||||
const amp = smoothedRef.current > 0.02
|
||||
? Math.min(1, smoothedRef.current * (0.4 + bellCurve * 0.6) + idleWave * 0.1)
|
||||
: idleWave * (active ? 0.28 : 0.08)
|
||||
|
||||
const barHeight = Math.max(3, amp * heightPx * 0.9)
|
||||
const half = barHeight / 2
|
||||
const radius = Math.min(barWidth / 2, half)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, centerY - half, barWidth, half * 2, radius)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [bars, height, barWidth, gap, active])
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: d3roRadius.inner,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
101
packages/ui/src/components/ds/DoubleBezelCard.tsx
Normal file
101
packages/ui/src/components/ds/DoubleBezelCard.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
'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
|
||||
|
||||
import React from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
|
||||
export interface DoubleBezelCardProps extends Omit<BoxProps, 'component'> {
|
||||
/** Outer padding between tray and core (default: 6px) */
|
||||
bezelPadding?: number | string
|
||||
/** Whether the card shows an interactive lift / glow on hover */
|
||||
interactive?: boolean
|
||||
/** Inner core padding (default: 3 / 24px) */
|
||||
innerPadding?: number | string
|
||||
/** Content to render inside the inner core */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DoubleBezelCard({
|
||||
bezelPadding = '6px',
|
||||
interactive = false,
|
||||
innerPadding = 3,
|
||||
children,
|
||||
sx,
|
||||
...boxProps
|
||||
}: DoubleBezelCardProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
{...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',
|
||||
...(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)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,14 +27,15 @@ interface GradientWaveProps {
|
|||
|
||||
/** :root에 주입된 웨이브 그라디언트 스톱을 읽는다 */
|
||||
function readWaveColors(): [string, string, string, string] {
|
||||
if (typeof document === 'undefined') return ['#60a5fa', '#3b82f6', '#818cf8', '#a78bfa']
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const read = (name: string, fallback: string): string =>
|
||||
style.getPropertyValue(name).trim() || fallback
|
||||
return [
|
||||
read('--d3-gradient-wave1', '#22d3ee'),
|
||||
read('--d3-gradient-wave1', '#60a5fa'),
|
||||
read('--d3-gradient-wave2', '#3b82f6'),
|
||||
read('--d3-gradient-wave3', '#8b5cf6'),
|
||||
read('--d3-gradient-wave4', '#e879f9'),
|
||||
read('--d3-gradient-wave3', '#818cf8'),
|
||||
read('--d3-gradient-wave4', '#a78bfa'),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
// src/renderer/components/ds/PhosphorText.tsx
|
||||
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
|
||||
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
|
||||
// (변형 API는 v1과 동일 — 전 페이지 호환)
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
|
||||
|
|
@ -44,11 +43,12 @@ interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
|||
variant?: PhosphorVariant
|
||||
}
|
||||
|
||||
export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextProps): React.ReactElement {
|
||||
export function PhosphorText({ variant = 'value', component = 'span', sx, ...props }: PhosphorTextProps): React.ReactElement {
|
||||
const v = VARIANTS[variant]
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component={component}
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
|
||||
|
|
|
|||
|
|
@ -1,47 +1,121 @@
|
|||
'use client'
|
||||
|
||||
// src/renderer/components/ds/PhysicalButton.tsx
|
||||
// v2 "Midnight Glass": 글래스 버튼 — 기본은 반투명 표면 + 헤어라인,
|
||||
// selected 시 액센트 그라디언트 + 글로우 (레퍼런스 프라이머리 버튼)
|
||||
// packages/ui/src/components/ds/PhysicalButton.tsx
|
||||
// High-End Tactile Button with Button-in-Button Trailing Icon & Spring Physics
|
||||
|
||||
import { Button, type ButtonProps } from '@mui/material'
|
||||
import React from 'react'
|
||||
import { Button, Box, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
export interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
/** Active selected state (renders primary accent gradient) */
|
||||
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
|
||||
}
|
||||
|
||||
export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButtonProps): React.ReactElement {
|
||||
export function PhysicalButton({
|
||||
selected = false,
|
||||
tone = 'glass',
|
||||
trailingIcon,
|
||||
children,
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
height: 42,
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.glass.raised,
|
||||
backgroundImage: selected ? d3roPalette.gradient.accent : 'none',
|
||||
border: `1px solid ${selected ? 'transparent' : d3roPalette.glass.hairline}`,
|
||||
height: 40,
|
||||
px: trailingIcon ? 2 : 2.5,
|
||||
bgcolor: bg,
|
||||
backgroundImage: bgImage,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
color: selected ? '#fff' : d3roPalette.text.secondary,
|
||||
color: textColor,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.small.size,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: selected ? d3roShadow.glowAccent : 'none',
|
||||
transition: 'filter 0.15s ease, border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease',
|
||||
'&:active': {
|
||||
filter: 'brightness(0.92)',
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: selected ? 'transparent' : d3roPalette.bg.cardHover,
|
||||
borderColor: selected ? 'transparent' : d3roPalette.glass.hairlineStrong,
|
||||
color: selected ? '#fff' : d3roPalette.text.primary,
|
||||
filter: selected ? 'brightness(1.08)' : 'none',
|
||||
},
|
||||
boxShadow: shadow,
|
||||
textTransform: 'none',
|
||||
letterSpacing: d3roTypo.small.spacing,
|
||||
letterSpacing: '0.01em',
|
||||
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)',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||
{children}
|
||||
</Box>
|
||||
{trailingIcon && (
|
||||
<Box
|
||||
className="d3-button-trailing-icon"
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isAccent ? 'rgba(255, 255, 255, 0.18)' : d3roPalette.bg.inset,
|
||||
border: `1px solid ${isAccent ? 'rgba(255, 255, 255, 0.2)' : 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 },
|
||||
}}
|
||||
>
|
||||
{trailingIcon}
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
123
packages/ui/src/components/ds/SegmentControl.tsx
Normal file
123
packages/ui/src/components/ds/SegmentControl.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/SegmentControl.tsx
|
||||
// Linear / Apple-tier segmented pill control with tactile active state
|
||||
|
||||
import React from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { d3roPalette, d3roRadius, d3roShadow, typoSx } from '../../theme'
|
||||
|
||||
export interface SegmentOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
badge?: string | number
|
||||
}
|
||||
|
||||
export interface SegmentControlProps<T extends string = string> {
|
||||
options?: SegmentOption<T>[]
|
||||
/** Alias for options */
|
||||
items?: SegmentOption<T>[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
size?: 'small' | 'medium'
|
||||
sx?: object
|
||||
}
|
||||
|
||||
export function SegmentControl<T extends string = string>({
|
||||
options,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
size = 'medium',
|
||||
sx,
|
||||
}: SegmentControlProps<T>): React.ReactElement {
|
||||
const isSmall = size === 'small'
|
||||
const list = options || items || []
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
p: '3px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.pill,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
gap: '2px',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{list.map((opt) => {
|
||||
const isSelected = opt.value === value
|
||||
return (
|
||||
<Box
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: isSmall ? 1.5 : 2,
|
||||
py: isSmall ? 0.5 : 0.75,
|
||||
borderRadius: d3roRadius.pill,
|
||||
bgcolor: isSelected ? d3roPalette.glass.raised : 'transparent',
|
||||
color: isSelected ? d3roPalette.text.primary : d3roPalette.text.secondary,
|
||||
border: isSelected ? `1px solid ${d3roPalette.glass.hairlineStrong}` : '1px solid transparent',
|
||||
boxShadow: isSelected ? d3roShadow.buttonRaised : 'none',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'&:hover': {
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: isSelected ? d3roPalette.glass.raised : d3roPalette.glass.surface,
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.97)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{opt.icon && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: isSelected ? d3roPalette.accent.light : 'inherit',
|
||||
'& svg': { width: isSmall ? 14 : 16, height: isSmall ? 14 : 16 },
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('compact'),
|
||||
fontSize: isSmall ? '12px' : '13px',
|
||||
fontWeight: isSelected ? 600 : 500,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Typography>
|
||||
{opt.badge !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.15,
|
||||
borderRadius: d3roRadius.pill,
|
||||
bgcolor: isSelected ? d3roPalette.accent.dim : d3roPalette.bg.chassis,
|
||||
color: isSelected ? d3roPalette.accent.light : d3roPalette.text.dimLabel,
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{opt.badge}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
113
packages/ui/src/components/ds/TactileBadge.tsx
Normal file
113
packages/ui/src/components/ds/TactileBadge.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
'use client'
|
||||
|
||||
// packages/ui/src/components/ds/TactileBadge.tsx
|
||||
// Hardware-grade telemetry badge / pill chip with optional LED bead and keycap rendering
|
||||
|
||||
import React from 'react'
|
||||
import { Box, type BoxProps } from '@mui/material'
|
||||
import { Led } from './Led'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, typoSx } 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
|
||||
}
|
||||
|
||||
export function TactileBadge({
|
||||
ledColor,
|
||||
ledPulse = false,
|
||||
tone = 'default',
|
||||
mono = false,
|
||||
onClick,
|
||||
children,
|
||||
sx,
|
||||
...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
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
onClick={onClick}
|
||||
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'),
|
||||
fontFamily: mono ? d3roFontMono : d3roFontSans,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
transition: 'all 0.15s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
...(isClickable
|
||||
? {
|
||||
'&:hover': {
|
||||
borderColor: d3roPalette.accent.main,
|
||||
color: d3roPalette.text.primary,
|
||||
filter: 'brightness(1.15)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0) scale(0.97)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{ledColor && <Led color={ledColor} pulse={ledPulse} size={6} />}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// src/renderer/components/ds/index.ts
|
||||
// packages/ui/src/components/ds/index.ts
|
||||
// 디자인 시스템 컴포넌트 SSOT barrel export
|
||||
|
||||
export { InstrumentPanel } from './InstrumentPanel'
|
||||
|
|
@ -8,6 +8,11 @@ export { MetalCard } from './MetalCard'
|
|||
export { TiltCard } from './TiltCard'
|
||||
export { PhosphorText } from './PhosphorText'
|
||||
export { ScreenPanel } from './ScreenPanel'
|
||||
// v2 "Midnight Glass" 신규
|
||||
// v2 "Midnight Glass"
|
||||
export { GradientWave } from './GradientWave'
|
||||
export { StatRing } from './StatRing'
|
||||
// v3 "Tactile Liquid & Double-Bezel" 신규
|
||||
export { DoubleBezelCard } from './DoubleBezelCard'
|
||||
export { TactileBadge } from './TactileBadge'
|
||||
export { AudioVisualizerBar } from './AudioVisualizerBar'
|
||||
export { SegmentControl } from './SegmentControl'
|
||||
|
|
|
|||
|
|
@ -83,17 +83,17 @@ const POPUP_THEME_VARS: Record<PopupThemeKey, PopupThemeVars> = {
|
|||
'--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': '#22d3ee',
|
||||
'--d3-wave-2': '#28bef0',
|
||||
'--d3-wave-3': '#2fa9f2',
|
||||
'--d3-wave-4': '#3596f4',
|
||||
'--d3-wave-1': '#93c5fd',
|
||||
'--d3-wave-2': '#60a5fa',
|
||||
'--d3-wave-3': '#4f8dfa',
|
||||
'--d3-wave-4': '#3b82f6',
|
||||
'--d3-wave-5': '#3b82f6',
|
||||
'--d3-wave-6': '#4f78f6',
|
||||
'--d3-wave-7': '#636ff6',
|
||||
'--d3-wave-8': '#7765f6',
|
||||
'--d3-wave-9': '#8b5cf6',
|
||||
'--d3-wave-6': '#5b75f7',
|
||||
'--d3-wave-7': '#6e71f7',
|
||||
'--d3-wave-8': '#818cf8',
|
||||
'--d3-wave-9': '#a78bfa',
|
||||
'--d3-status-error': '#ef4444',
|
||||
'--d3-status-success': '#34d399',
|
||||
'--d3-status-success': '#10b981',
|
||||
},
|
||||
light: {
|
||||
'--d3-bg-card': '#ffffff',
|
||||
|
|
|
|||
|
|
@ -144,9 +144,9 @@ const RAW: Record<ThemeKey, RawTheme> = {
|
|||
gradient: {
|
||||
accent: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)',
|
||||
logo: 'linear-gradient(90deg, #60a5fa 0%, #818cf8 100%)',
|
||||
bar: 'linear-gradient(90deg, #22d3ee 0%, #3b82f6 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #8b5cf6 0%, #d946ef 100%)',
|
||||
wave1: '#22d3ee', wave2: '#3b82f6', wave3: '#8b5cf6', wave4: '#e879f9',
|
||||
bar: 'linear-gradient(90deg, #60a5fa 0%, #3b82f6 100%)',
|
||||
barAlt: 'linear-gradient(90deg, #818cf8 0%, #a78bfa 100%)',
|
||||
wave1: '#60a5fa', wave2: '#3b82f6', wave3: '#818cf8', wave4: '#a78bfa',
|
||||
},
|
||||
glow: {
|
||||
accent: '0 0 0 1px rgba(59,130,246,0.35), 0 8px 24px rgba(37,99,235,0.35)',
|
||||
|
|
@ -415,25 +415,26 @@ export const d3roPalette = {
|
|||
cardHover: 'var(--d3-glow-cardHover)',
|
||||
},
|
||||
tag: {
|
||||
purple: '#a78bfa',
|
||||
purpleBg: 'rgba(167, 139, 250, 0.12)',
|
||||
orange: '#fb923c',
|
||||
orangeBg: 'rgba(251, 146, 60, 0.12)',
|
||||
red: '#f87171',
|
||||
redBg: 'rgba(248, 113, 113, 0.12)',
|
||||
green: '#34d399',
|
||||
greenBg: 'rgba(52, 211, 153, 0.12)',
|
||||
blue: '#60a5fa',
|
||||
blueBg: 'rgba(96, 165, 250, 0.12)',
|
||||
greenGlow: 'rgba(52, 211, 153, 0.55)',
|
||||
redGlow: 'rgba(248, 113, 113, 0.55)',
|
||||
orangeGlow: 'rgba(251, 146, 60, 0.55)',
|
||||
purpleGlow: 'rgba(167, 139, 250, 0.55)',
|
||||
blueGlow: 'rgba(96, 165, 250, 0.55)',
|
||||
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)',
|
||||
},
|
||||
text: {
|
||||
primary: 'var(--d3-text-primary)',
|
||||
secondary: 'var(--d3-text-secondary)',
|
||||
inverse: 'var(--d3-text-inverse, #ffffff)',
|
||||
label: 'var(--d3-text-label)',
|
||||
disabled: 'var(--d3-text-disabled)',
|
||||
engraving: 'var(--d3-text-engraving)',
|
||||
|
|
@ -516,21 +517,25 @@ export const d3roShadow = {
|
|||
dialog: 'var(--d3-shadow-card)',
|
||||
tooltip: 'var(--d3-shadow-tooltip)',
|
||||
screenGlow: 'var(--d3-shadow-screenGlow)',
|
||||
/** v2: 액센트 글로우 */
|
||||
/** v2/v3: 액센트 글로우 및 아일랜드 플로트 */
|
||||
glowAccent: 'var(--d3-glow-accent)',
|
||||
glowSoft: 'var(--d3-glow-soft)',
|
||||
glowCard: 'var(--d3-glow-card)',
|
||||
glowCardHover: 'var(--d3-glow-cardHover)',
|
||||
islandFloat: '0 16px 36px -8px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
} as const
|
||||
|
||||
// ── SSOT: 반경 토큰 ────────────────────────────────────
|
||||
export const d3roRadius = {
|
||||
doubleBezelOuter: '22px',
|
||||
doubleBezelInner: '14px',
|
||||
outer: '20px',
|
||||
card: '16px',
|
||||
inner: '12px',
|
||||
button: '10px',
|
||||
small: '8px',
|
||||
xs: '6px',
|
||||
badge: '999px',
|
||||
pill: '999px',
|
||||
} as const
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue