feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -2,23 +2,27 @@
|
|||
// createD3roSupabaseClient 팩토리 유닛 테스트
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createD3roSupabaseClient, isClientConfigured } from '../src/client'
|
||||
import {
|
||||
createD3roSupabaseClient,
|
||||
isClientConfigured,
|
||||
SupabaseConfigurationError,
|
||||
} from '../src/client'
|
||||
|
||||
describe('createD3roSupabaseClient', () => {
|
||||
it('URL과 key가 없으면 placeholder 클라이언트를 반환한다', () => {
|
||||
const client = createD3roSupabaseClient({ url: undefined, anonKey: undefined })
|
||||
expect(client).toBeDefined()
|
||||
// auth, from 등 메서드가 존재해야 함
|
||||
expect(typeof client.auth.getSession).toBe('function')
|
||||
expect(typeof client.from).toBe('function')
|
||||
it('URL과 key가 없으면 설정 오류로 즉시 실패한다', () => {
|
||||
expect(() => createD3roSupabaseClient({ url: undefined, anonKey: undefined }))
|
||||
.toThrow(SupabaseConfigurationError)
|
||||
})
|
||||
|
||||
it('URL만 있고 key가 없으면 placeholder 클라이언트를 반환한다', () => {
|
||||
const client = createD3roSupabaseClient({
|
||||
it('URL이나 key 중 하나만 있거나 공백이면 설정 오류로 즉시 실패한다', () => {
|
||||
expect(() => createD3roSupabaseClient({
|
||||
url: 'https://real.supabase.co',
|
||||
anonKey: undefined
|
||||
})
|
||||
expect(client).toBeDefined()
|
||||
anonKey: undefined,
|
||||
})).toThrow(SupabaseConfigurationError)
|
||||
expect(() => createD3roSupabaseClient({
|
||||
url: ' ',
|
||||
anonKey: 'anon-key',
|
||||
})).toThrow(SupabaseConfigurationError)
|
||||
})
|
||||
|
||||
it('URL과 key가 모두 있으면 실제 클라이언트를 생성한다', () => {
|
||||
|
|
@ -60,5 +64,6 @@ describe('isClientConfigured', () => {
|
|||
it('빈 문자열은 false로 처리된다', () => {
|
||||
expect(isClientConfigured({ url: '', anonKey: 'k' })).toBe(false)
|
||||
expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: '' })).toBe(false)
|
||||
expect(isClientConfigured({ url: ' ', anonKey: 'k' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,108 +1,82 @@
|
|||
// packages/api-client/__tests__/transcribe.test.ts
|
||||
// transcribeAudio 함수 단위 및 통합 인터페이스 테스트
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { transcribeAudio } from '../src/transcribe'
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
const session = {
|
||||
supabaseUrl: 'https://project.supabase.co',
|
||||
anonKey: 'anon-key',
|
||||
token: 'user-token',
|
||||
}
|
||||
|
||||
it('Blob 입력 시 multipart/form-data로 /api/stt/transcribe를 호출하여 전사 결과를 반환한다', async () => {
|
||||
const mockAudioBlob = new Blob(['mock-audio-content'], { type: 'audio/webm' })
|
||||
const mockResponse = {
|
||||
text: '안녕하세요, 클라우드 음성 전사 테스트입니다.',
|
||||
function payload(transcript = '안녕하세요') {
|
||||
return {
|
||||
transcript,
|
||||
confidence: 0.99,
|
||||
language_code: 'ko',
|
||||
duration_seconds: 2.5,
|
||||
provider: 'groq',
|
||||
}
|
||||
}
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('uses only the authenticated Supabase quota gateway', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload() }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
...session,
|
||||
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
||||
language: 'ko',
|
||||
})
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://project.supabase.co/functions/v1/stt-proxy')
|
||||
expect(options.headers).toEqual({ Authorization: 'Bearer user-token', apikey: 'anon-key' })
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
expect((options.body as FormData).get('language_code')).toBe('ko')
|
||||
expect(result).toEqual({
|
||||
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,
|
||||
latencyMs: expect.any(Number),
|
||||
})
|
||||
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,
|
||||
})
|
||||
it('converts binary and base64 audio to multipart without a JSON bypass', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload('binary') }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await transcribeAudio({
|
||||
audio: mockBase64,
|
||||
language: 'ko',
|
||||
prompt: '의료 용어 가이드',
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
await transcribeAudio({ ...session, audio: Uint8Array.from([0, 1, 2, 253, 254, 255]) })
|
||||
await transcribeAudio({ ...session, audio: 'AAEC/f7/' })
|
||||
|
||||
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 전사 성공')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of fetchMock.mock.calls) {
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
expect((options.body as FormData).get('audio')).toBeInstanceOf(Blob)
|
||||
}
|
||||
})
|
||||
|
||||
it('서버 응답이 4xx/5xx 실패 시 에러를 throw한다', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal STT Proxy Failure',
|
||||
})
|
||||
)
|
||||
it('requires token, anon key and a safe gateway origin before network access', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
transcribeAudio({
|
||||
audio: new Blob(['data']),
|
||||
apiBaseUrl: 'http://localhost:5000',
|
||||
})
|
||||
).rejects.toThrow('STT transcription failed (500): Internal STT Proxy Failure')
|
||||
await expect(transcribeAudio({ ...session, token: '', audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('authenticated Supabase session')
|
||||
await expect(transcribeAudio({ ...session, supabaseUrl: 'http://public.example.com', audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('configuration is invalid')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not expose upstream bodies and rejects malformed success payloads', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, text: async () => 'secret upstream body' })))
|
||||
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('STT gateway request failed (503).')
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ transcript: 'x' }) })))
|
||||
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
|
||||
.rejects.toThrow('invalid response')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/api-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -20,25 +20,37 @@ export interface CreateClientOptions {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase 클라이언트 생성.
|
||||
* url 또는 anonKey가 비어있으면 "dummy" 클라이언트를 반환 — 모든 쿼리는 빈 결과.
|
||||
* 이렇게 해야 env가 세팅되지 않은 상태에서도 빌드/SSR이 실패하지 않는다.
|
||||
*/
|
||||
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
|
||||
const { url, anonKey, auth } = options
|
||||
export class SupabaseConfigurationError extends Error {
|
||||
readonly code = 'SUPABASE_CONFIGURATION_MISSING'
|
||||
|
||||
constructor() {
|
||||
super('Supabase public URL and anonymous key are required.')
|
||||
this.name = 'SupabaseConfigurationError'
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSupabasePublicConfig(options: CreateClientOptions): {
|
||||
url: string
|
||||
anonKey: string
|
||||
} {
|
||||
const url = options.url?.trim()
|
||||
const anonKey = options.anonKey?.trim()
|
||||
|
||||
if (!url || !anonKey) {
|
||||
// Env가 없으면 fallback URL로 생성. 실제 호출은 런타임에 실패하지만
|
||||
// 빌드/타입체크는 통과. 앱 상단에서 isClientConfigured()로 체크해야 함.
|
||||
return createClient<Database>('https://placeholder.supabase.co', 'placeholder-anon-key', {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false
|
||||
}
|
||||
})
|
||||
throw new SupabaseConfigurationError()
|
||||
}
|
||||
|
||||
return { url, anonKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase 클라이언트 생성.
|
||||
* 필수 공개 설정이 없으면 가짜 백엔드로 진행하지 않고 즉시 실패한다.
|
||||
*/
|
||||
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
|
||||
const { auth } = options
|
||||
const { url, anonKey } = requireSupabasePublicConfig(options)
|
||||
|
||||
return createClient<Database>(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: auth?.persistSession ?? true,
|
||||
|
|
@ -52,5 +64,5 @@ export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupa
|
|||
* Env가 실제로 세팅되었는지 확인. UI에서 "Supabase 미설정" 경고 표시용.
|
||||
*/
|
||||
export function isClientConfigured(options: CreateClientOptions): boolean {
|
||||
return Boolean(options.url && options.anonKey)
|
||||
return Boolean(options.url?.trim() && options.anonKey?.trim())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,25 @@
|
|||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from './types'
|
||||
import { requireSupabasePublicConfig } from './client'
|
||||
|
||||
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
const { url, anonKey } = requireSupabasePublicConfig({
|
||||
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
})
|
||||
|
||||
cachedClient = createBrowserClient<Database>(url, key)
|
||||
cachedClient = createBrowserClient<Database>(url, anonKey)
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
export function isSupabaseConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import type { Database } from './types'
|
||||
import { requireSupabasePublicConfig } from './client'
|
||||
|
||||
/** next/headers cookies()가 반환하는 객체의 최소 인터페이스 */
|
||||
export interface CookieStore {
|
||||
|
|
@ -15,10 +16,12 @@ export interface CookieStore {
|
|||
export function createSupabaseServerClient(
|
||||
cookieStore: CookieStore,
|
||||
): ReturnType<typeof createServerClient<Database>> {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
const { url, anonKey } = requireSupabasePublicConfig({
|
||||
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
})
|
||||
|
||||
return createServerClient<Database>(url, key, {
|
||||
return createServerClient<Database>(url, anonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
|
|
@ -37,5 +40,8 @@ export function createSupabaseServerClient(
|
|||
}
|
||||
|
||||
export function isSupabaseConfiguredServer(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
// packages/api-client/src/transcribe.ts
|
||||
// D3RO Cloud STT Transcription Unified Client
|
||||
// D3RO Cloud STT client. User-facing transcription always passes through the
|
||||
// Supabase Edge gateway that owns authentication, quota and usage accounting.
|
||||
|
||||
export interface TranscribeAudioParams {
|
||||
audio: Blob | ArrayBuffer | Uint8Array | string // Blob, Binary, or Base64 string
|
||||
audio: Blob | ArrayBuffer | Uint8Array | string
|
||||
language?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
apiBaseUrl?: string
|
||||
supabaseUrl?: string
|
||||
anonKey?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
|
|
@ -17,75 +15,108 @@ export interface TranscribeAudioResult {
|
|||
language: string
|
||||
durationSeconds: number
|
||||
provider: string
|
||||
modelId: string
|
||||
latencyMs: number
|
||||
cost: number
|
||||
modelId?: string
|
||||
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`
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const normalized = value.replace(/^data:[^,]*,/, '').replace(/\s+/g, '')
|
||||
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
||||
throw new Error('Unsupported audio payload format.')
|
||||
}
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
const output: number[] = []
|
||||
for (let offset = 0; offset < normalized.length; offset += 4) {
|
||||
const chars = normalized.slice(offset, offset + 4)
|
||||
const values = [...chars].map((char) => char === '=' ? 0 : alphabet.indexOf(char))
|
||||
if (values.some((item) => item < 0)) throw new Error('Unsupported audio payload format.')
|
||||
const combined = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3]
|
||||
output.push((combined >>> 16) & 0xff)
|
||||
if (chars[2] !== '=') output.push((combined >>> 8) & 0xff)
|
||||
if (chars[3] !== '=') output.push(combined & 0xff)
|
||||
}
|
||||
return Uint8Array.from(output)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (params.token) {
|
||||
headers['Authorization'] = `Bearer ${params.token}`
|
||||
function gatewayUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL('/functions/v1/stt-proxy', value)
|
||||
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
|
||||
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) throw new Error('invalid')
|
||||
return url.toString()
|
||||
} catch {
|
||||
throw new Error('STT gateway configuration is invalid.')
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(value: unknown, latencyMs: number): TranscribeAudioResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('STT gateway returned an invalid response.')
|
||||
}
|
||||
const payload = value as Record<string, unknown>
|
||||
if (
|
||||
typeof payload.transcript !== 'string'
|
||||
|| !payload.transcript.trim()
|
||||
|| typeof payload.confidence !== 'number'
|
||||
|| !Number.isFinite(payload.confidence)
|
||||
|| payload.confidence < 0
|
||||
|| payload.confidence > 1
|
||||
|| typeof payload.language_code !== 'string'
|
||||
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|
||||
|| typeof payload.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(payload.duration_seconds)
|
||||
|| payload.duration_seconds < 0
|
||||
|| typeof payload.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
|
||||
) {
|
||||
throw new Error('STT gateway returned an invalid response.')
|
||||
}
|
||||
return {
|
||||
text: payload.transcript.trim(),
|
||||
confidence: payload.confidence,
|
||||
language: payload.language_code,
|
||||
durationSeconds: payload.duration_seconds,
|
||||
provider: payload.provider,
|
||||
latencyMs,
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
|
||||
const supabaseUrl = params.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const anonKey = params.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
const token = params.token ?? ''
|
||||
if (!supabaseUrl || !anonKey || !token) {
|
||||
throw new Error('STT gateway requires an authenticated Supabase session.')
|
||||
}
|
||||
|
||||
// 1. If audio is Blob or ArrayBuffer, send via FormData
|
||||
let audio: Blob
|
||||
const makeBlob = Blob as unknown as new (parts: unknown[], options?: { type?: string }) => Blob
|
||||
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 })
|
||||
audio = params.audio
|
||||
} else if (params.audio instanceof ArrayBuffer) {
|
||||
audio = new makeBlob([params.audio], { type: 'audio/wav' })
|
||||
} else if (params.audio instanceof Uint8Array) {
|
||||
audio = new makeBlob([params.audio.slice().buffer], { type: 'audio/wav' })
|
||||
} 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
|
||||
audio = new makeBlob([decodeBase64(params.audio).buffer], { type: 'audio/wav' })
|
||||
} else {
|
||||
throw new Error('Unsupported audio payload format.')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('audio', audio)
|
||||
if (params.language && params.language !== 'auto') formData.append('language_code', params.language)
|
||||
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(gatewayUrl(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: anonKey,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) throw new Error(`STT gateway request failed (${response.status}).`)
|
||||
return parseResult(await response.json().catch(() => null), Date.now() - startedAt)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type TeamMember = {
|
|||
}
|
||||
|
||||
export type MeetingStatus = 'recording' | 'processing' | 'completed' | 'error'
|
||||
export type MeetingLanguage = 'ko' | 'en' | 'ja' | 'zh-cn'
|
||||
|
||||
export type Meeting = {
|
||||
id: string
|
||||
|
|
@ -52,6 +53,11 @@ export type Meeting = {
|
|||
llm_latency_ms: number | null
|
||||
error_message: string | null
|
||||
audio_storage_key: string | null
|
||||
language?: MeetingLanguage | null
|
||||
attendees?: string[]
|
||||
template_id?: string | null
|
||||
creation_idempotency_key?: string | null
|
||||
creation_request_hash?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -77,6 +83,8 @@ export type MeetingDocument = {
|
|||
prompt_used: string | null
|
||||
llm_model: string | null
|
||||
llm_latency_ms: number | null
|
||||
template_id?: string | null
|
||||
generation_idempotency_key?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -120,10 +128,191 @@ export type HistoryEntry = {
|
|||
llm_latency_ms: number | null
|
||||
app_version: string
|
||||
summary_text: string | null
|
||||
is_favorite: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MemoTag = {
|
||||
id: string
|
||||
user_id: string
|
||||
history_id: string
|
||||
tag: string
|
||||
normalized_tag: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TemplateKind = 'dictation' | 'meeting_document'
|
||||
|
||||
export type DictationTemplateField = {
|
||||
id: string
|
||||
name: string
|
||||
label: string
|
||||
promptText: string
|
||||
required: boolean
|
||||
maxDurationSec: number
|
||||
}
|
||||
|
||||
export type UserTemplate = {
|
||||
id: string
|
||||
user_id: string
|
||||
template_kind: TemplateKind
|
||||
builtin_key: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
fields: DictationTemplateField[]
|
||||
output_format: string | null
|
||||
template_type: DocumentTemplateType | null
|
||||
system_prompt: string | null
|
||||
is_builtin: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type UserTemplateSelection = {
|
||||
user_id: string
|
||||
template_kind: TemplateKind
|
||||
template_id: string
|
||||
revision: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MeetingDocumentGenerationRequest = {
|
||||
user_id: string
|
||||
idempotency_key: string
|
||||
meeting_id: string
|
||||
template_id: string
|
||||
request_hash: string
|
||||
document_title: string
|
||||
model: string
|
||||
quota_feature: 'llm_haiku' | 'llm_sonnet' | 'llm_opus'
|
||||
quota_limit: number
|
||||
quota_period: 'daily' | 'weekly'
|
||||
template_revision: number
|
||||
template_type: DocumentTemplateType
|
||||
transcript_hash: string
|
||||
status: 'processing' | 'succeeded' | 'failed'
|
||||
document_id: string | null
|
||||
error_code: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export type AudioFileSource = 'recording' | 'file-picker' | 'share-intent'
|
||||
export type AudioFileUploadStatus = 'pending' | 'uploading' | 'uploaded' | 'failed' | 'deleted'
|
||||
|
||||
export type AudioFile = {
|
||||
id: string
|
||||
user_id: string
|
||||
history_id: string | null
|
||||
meeting_id: string | null
|
||||
source: AudioFileSource
|
||||
original_name: string | null
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
duration_ms: number | null
|
||||
sha256: string
|
||||
upload_status: AudioFileUploadStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ProcessingJobKind = 'transcription' | 'summary' | 'minutes' | 'diarization' | 'export'
|
||||
export type ProcessingJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
|
||||
export type ProcessingJob = {
|
||||
id: string
|
||||
user_id: string
|
||||
audio_file_id: string | null
|
||||
history_id: string | null
|
||||
meeting_id: string | null
|
||||
kind: ProcessingJobKind
|
||||
status: ProcessingJobStatus
|
||||
progress: number
|
||||
attempt_count: number
|
||||
idempotency_key: string
|
||||
error_code: string | null
|
||||
error_message: string | null
|
||||
result: Record<string, unknown> | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
user_id: string
|
||||
theme_mode: 'system' | 'light' | 'dark'
|
||||
locale: string
|
||||
haptic_enabled: boolean
|
||||
auto_polish_enabled: boolean
|
||||
preferred_stt_model: string | null
|
||||
preferred_llm_model: string | null
|
||||
onboarding_version: number
|
||||
tutorial_completed_at: string | null
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type DevicePlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos'
|
||||
|
||||
export type Device = {
|
||||
id: string
|
||||
user_id: string
|
||||
installation_id: string
|
||||
platform: DevicePlatform
|
||||
device_name: string
|
||||
app_version: string
|
||||
os_version: string | null
|
||||
push_token: string | null
|
||||
last_seen_at: string
|
||||
revoked_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type StorePurchaseState =
|
||||
| 'pending'
|
||||
| 'purchased'
|
||||
| 'cancelled'
|
||||
| 'expired'
|
||||
| 'refunded'
|
||||
| 'on_hold'
|
||||
| 'paused'
|
||||
|
||||
export type IapPurchase = {
|
||||
id: string
|
||||
user_id: string
|
||||
platform: 'google_play' | 'app_store'
|
||||
product_id: string
|
||||
store_transaction_id: string | null
|
||||
token_hash: string
|
||||
purchase_state: StorePurchaseState
|
||||
purchase_at: string | null
|
||||
expires_at: string | null
|
||||
auto_renewing: boolean | null
|
||||
acknowledged_at: string | null
|
||||
verified_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AdRewardClaim = {
|
||||
id: string
|
||||
user_id: string
|
||||
network: string
|
||||
placement: string
|
||||
ad_unit_id: string
|
||||
transaction_id: string
|
||||
reward_tokens: number
|
||||
verified_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type DictionaryEntry = {
|
||||
id: string
|
||||
user_id: string
|
||||
|
|
@ -154,6 +343,15 @@ export type Subscription = {
|
|||
stripe_customer_id: string | null
|
||||
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
||||
stripe_subscription_id: string | null
|
||||
payment_provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store'
|
||||
payple_payer_id: string | null
|
||||
payple_pay_oid: string | null
|
||||
renewal_failures: number
|
||||
admin_note: string | null
|
||||
provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store' | 'admin'
|
||||
store_product_id: string | null
|
||||
store_purchase_id: string | null
|
||||
auto_renewing: boolean | null
|
||||
status: string | null
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
|
|
@ -247,6 +445,61 @@ export type Database = {
|
|||
Partial<HistoryEntry> & Pick<HistoryEntry, 'user_id' | 'original_text' | 'duration'>,
|
||||
Partial<HistoryEntry>
|
||||
>
|
||||
memo_tags: TypedTable<
|
||||
MemoTag,
|
||||
Partial<MemoTag> & Pick<MemoTag, 'user_id' | 'history_id' | 'tag'>,
|
||||
Partial<MemoTag>
|
||||
>
|
||||
user_templates: TypedTable<
|
||||
UserTemplate,
|
||||
Partial<UserTemplate> & Pick<UserTemplate, 'user_id' | 'template_kind' | 'name'>,
|
||||
Partial<UserTemplate>
|
||||
>
|
||||
user_template_selections: TypedTable<
|
||||
UserTemplateSelection,
|
||||
Partial<UserTemplateSelection> & Pick<UserTemplateSelection, 'user_id' | 'template_kind' | 'template_id'>,
|
||||
Partial<UserTemplateSelection>
|
||||
>
|
||||
meeting_document_generation_requests: TypedTable<
|
||||
MeetingDocumentGenerationRequest,
|
||||
MeetingDocumentGenerationRequest,
|
||||
Partial<MeetingDocumentGenerationRequest>
|
||||
>
|
||||
audio_files: TypedTable<
|
||||
AudioFile,
|
||||
Partial<AudioFile> &
|
||||
Pick<AudioFile, 'user_id' | 'source' | 'storage_key' | 'mime_type' | 'size_bytes' | 'sha256'>,
|
||||
Partial<AudioFile>
|
||||
>
|
||||
processing_jobs: TypedTable<
|
||||
ProcessingJob,
|
||||
Partial<ProcessingJob> &
|
||||
Pick<ProcessingJob, 'user_id' | 'kind' | 'idempotency_key'>,
|
||||
Partial<ProcessingJob>
|
||||
>
|
||||
user_settings: TypedTable<
|
||||
UserSettings,
|
||||
Partial<UserSettings> & Pick<UserSettings, 'user_id'>,
|
||||
Partial<UserSettings>
|
||||
>
|
||||
devices: TypedTable<
|
||||
Device,
|
||||
Partial<Device> &
|
||||
Pick<Device, 'user_id' | 'installation_id' | 'platform' | 'device_name' | 'app_version'>,
|
||||
Partial<Device>
|
||||
>
|
||||
iap_purchases: TypedTable<
|
||||
IapPurchase,
|
||||
Partial<IapPurchase> &
|
||||
Pick<IapPurchase, 'user_id' | 'platform' | 'product_id' | 'token_hash' | 'purchase_state'>,
|
||||
Partial<IapPurchase>
|
||||
>
|
||||
ad_reward_claims: TypedTable<
|
||||
AdRewardClaim,
|
||||
Partial<AdRewardClaim> &
|
||||
Pick<AdRewardClaim, 'user_id' | 'network' | 'placement' | 'ad_unit_id' | 'transaction_id' | 'reward_tokens'>,
|
||||
Partial<AdRewardClaim>
|
||||
>
|
||||
dictionary: TypedTable<
|
||||
DictionaryEntry,
|
||||
Partial<DictionaryEntry> & Pick<DictionaryEntry, 'user_id' | 'word'>,
|
||||
|
|
@ -354,6 +607,82 @@ export type Database = {
|
|||
}
|
||||
Returns: number
|
||||
}
|
||||
bootstrap_user_templates_v1: {
|
||||
Args: Record<string, never>
|
||||
Returns: UserTemplate[]
|
||||
}
|
||||
create_user_template_v1: {
|
||||
Args: {
|
||||
p_template_kind: TemplateKind
|
||||
p_name: string
|
||||
p_description?: string | null
|
||||
p_fields?: DictationTemplateField[]
|
||||
p_output_format?: string | null
|
||||
p_system_prompt?: string | null
|
||||
}
|
||||
Returns: UserTemplate
|
||||
}
|
||||
update_user_template_v1: {
|
||||
Args: {
|
||||
p_template_id: string
|
||||
p_expected_revision: number
|
||||
p_name: string
|
||||
p_description?: string | null
|
||||
p_fields?: DictationTemplateField[]
|
||||
p_output_format?: string | null
|
||||
p_system_prompt?: string | null
|
||||
}
|
||||
Returns: UserTemplate
|
||||
}
|
||||
delete_user_template_v1: {
|
||||
Args: { p_template_id: string; p_expected_revision: number }
|
||||
Returns: boolean
|
||||
}
|
||||
select_user_template_v1: {
|
||||
Args: { p_template_id: string; p_expected_revision?: number | null }
|
||||
Returns: UserTemplateSelection
|
||||
}
|
||||
mobile_add_memo_tag_v1: {
|
||||
Args: { p_history_id: string; p_tag: string }
|
||||
Returns: MemoTag
|
||||
}
|
||||
mobile_remove_memo_tag_v1: {
|
||||
Args: { p_history_id: string; p_tag: string }
|
||||
Returns: boolean
|
||||
}
|
||||
mobile_rename_memo_tag_v1: {
|
||||
Args: { p_old_tag: string; p_new_tag: string }
|
||||
Returns: number
|
||||
}
|
||||
mobile_list_memo_tags_v1: {
|
||||
Args: Record<string, never>
|
||||
Returns: Array<{ tag: string; normalized_tag: string; history_count: number }>
|
||||
}
|
||||
mobile_search_memos_v1: {
|
||||
Args: { p_query?: string; p_tag?: string | null; p_limit?: number; p_offset?: number }
|
||||
Returns: Array<{ history_row: HistoryEntry; tags: string[] }>
|
||||
}
|
||||
mobile_create_meeting_workspace_v2: {
|
||||
Args: {
|
||||
p_title: string
|
||||
p_attendees: string[]
|
||||
p_language: string
|
||||
p_template_id: string | null
|
||||
p_idempotency_key: string
|
||||
}
|
||||
Returns: Meeting
|
||||
}
|
||||
grant_verified_ad_reward: {
|
||||
Args: {
|
||||
p_user_id: string
|
||||
p_network: string
|
||||
p_placement: string
|
||||
p_ad_unit_id: string
|
||||
p_transaction_id: string
|
||||
p_reward_tokens: number
|
||||
}
|
||||
Returns: Record<string, unknown>
|
||||
}
|
||||
admin_usage_by_feature: {
|
||||
Args: {
|
||||
p_from: string
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/core",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,24 @@
|
|||
// Supabase 연결 정보 SSOT — 데스크톱, 웹, 모바일 모두 여기서 참조.
|
||||
// Anon key는 클라이언트용 공개 키(RLS 보호)이므로 소스에 포함해도 안전.
|
||||
|
||||
interface MobileE2eSupabaseOverride {
|
||||
url?: unknown
|
||||
anonKey?: unknown
|
||||
}
|
||||
|
||||
const runtimeOverride = (
|
||||
globalThis as typeof globalThis & { __D3RO_MOBILE_E2E_SUPABASE__?: MobileE2eSupabaseOverride }
|
||||
).__D3RO_MOBILE_E2E_SUPABASE__
|
||||
const runtimeUrl = runtimeOverride?.url
|
||||
const runtimeAnonKey = runtimeOverride?.anonKey
|
||||
const validRuntimeOverride = runtimeUrl === 'http://10.0.2.2:55321'
|
||||
&& typeof runtimeAnonKey === 'string'
|
||||
&& /^sb_publishable_[A-Za-z0-9_-]{20,}$/.test(runtimeAnonKey)
|
||||
|
||||
export const SUPABASE_LOCAL_MOBILE_E2E = validRuntimeOverride
|
||||
|
||||
export const SUPABASE_URL =
|
||||
(validRuntimeOverride ? runtimeUrl : undefined) ||
|
||||
(typeof process !== 'undefined' &&
|
||||
(process.env?.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env?.SUPABASE_URL ||
|
||||
|
|
@ -10,6 +27,7 @@ export const SUPABASE_URL =
|
|||
'https://llnocwyqvhgwpdjcqqyw.supabase.co'
|
||||
|
||||
export const SUPABASE_ANON_KEY =
|
||||
(validRuntimeOverride ? runtimeAnonKey : undefined) ||
|
||||
(typeof process !== 'undefined' &&
|
||||
(process.env?.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
||||
process.env?.SUPABASE_ANON_KEY ||
|
||||
|
|
|
|||
|
|
@ -1712,7 +1712,8 @@ export interface AdMediationAuctionRequest {
|
|||
}
|
||||
|
||||
export interface AdMediationAuctionResult {
|
||||
winner: AdCreativePayload
|
||||
/** Null means every configured provider failed or returned no fill. */
|
||||
winner: AdCreativePayload | null
|
||||
winningBidEcpm: number
|
||||
participatingBids: Array<{
|
||||
networkId: AdNetworkId | string
|
||||
|
|
@ -1878,23 +1879,27 @@ export interface RefundEligibilityResult {
|
|||
// Phase 18: Multi-PG Billing & Checkout
|
||||
// ============================================================
|
||||
|
||||
export type PaymentGatewayProvider = 'toss' | 'stripe' | 'portone'
|
||||
export type PaymentGatewayProvider = 'stripe'
|
||||
export type CheckoutTier = 'pro' | 'pro_plus'
|
||||
|
||||
export interface CheckoutSessionParams {
|
||||
tier: LicenseTier
|
||||
billingCycle: 'monthly' | 'annual'
|
||||
currency: 'KRW' | 'USD' | 'EUR'
|
||||
tier: CheckoutTier
|
||||
provider: PaymentGatewayProvider
|
||||
taxId?: string
|
||||
customerEmail?: string
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResult {
|
||||
checkoutUrl?: string
|
||||
clientSecret?: string
|
||||
orderId: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: 'pending' | 'completed' | 'failed'
|
||||
checkoutUrl: string
|
||||
provider: 'stripe'
|
||||
status: 'pending'
|
||||
}
|
||||
|
||||
export interface VerifyPaymentResult {
|
||||
success: boolean
|
||||
activeTier: 'free' | CheckoutTier
|
||||
}
|
||||
|
||||
export interface SubscriptionStatusResult {
|
||||
tier: 'free' | CheckoutTier
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,16 @@
|
|||
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-----
|
||||
/**
|
||||
* 공개된 개발 전용 Ed25519 공개키 (SPKI PEM 형식).
|
||||
*
|
||||
* 대응 비밀키가 과거 저장소에 포함됐으므로 운영 라이선스 검증에는 절대 사용할 수
|
||||
* 없다. 운영 호출자는 별도로 보관한 키쌍의 공개키를 반드시 명시해야 한다.
|
||||
*/
|
||||
export const DEVELOPMENT_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 */
|
||||
|
|
@ -55,6 +55,29 @@ export interface LicenseVerificationResult {
|
|||
message: string
|
||||
}
|
||||
|
||||
export interface LicenseVerificationOptions {
|
||||
/** 테스트/개발 fixture 키 허용. production 환경에서는 true여도 무시한다. */
|
||||
allowDevelopmentKeys?: boolean
|
||||
/** 호출 환경을 명시한다. 생략하면 NODE_ENV를 사용한다. */
|
||||
environment?: string
|
||||
}
|
||||
|
||||
const DEVELOPMENT_LICENSES: ReadonlyMap<string, { tier: LicenseTier; licenseId: string; message: string }> = new Map([
|
||||
['D3RO-PRO-TEST-KEY1', { tier: 'pro', licenseId: 'dev-pro', message: 'Dev Pro License Activated' }],
|
||||
['D3RO-PLUS-TEST-KEY1', { tier: 'pro_plus', licenseId: 'dev-pro-plus', message: 'Dev Pro+ License Activated' }],
|
||||
['D3RO-TEAM-TEST-KEY1', { tier: 'team', licenseId: 'dev-team', message: 'Dev Team License Activated' }],
|
||||
])
|
||||
|
||||
function allowsDevelopmentLicenses(options: LicenseVerificationOptions | undefined): boolean {
|
||||
if (options?.allowDevelopmentKeys !== true) return false
|
||||
const environment = options.environment ?? process.env.NODE_ENV
|
||||
return environment === 'development' || environment === 'test'
|
||||
}
|
||||
|
||||
function normalizePublicKeyPem(value: string): string {
|
||||
return value.trim().replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이센스 페이로드를 정규화된 JSON 문자열로 변환 (서명 일관성 보장)
|
||||
*/
|
||||
|
|
@ -118,59 +141,28 @@ export function issueSignedLicenseKey(payload: SignedLicensePayload, privateKeyP
|
|||
export function verifySignedLicenseKey(
|
||||
licenseKey: string,
|
||||
currentMachineId?: string,
|
||||
publicKeyPem: string = DEFAULT_LICENSE_PUBLIC_KEY,
|
||||
publicKeyPem?: string,
|
||||
options?: LicenseVerificationOptions,
|
||||
): LicenseVerificationResult {
|
||||
const trimmed = licenseKey.trim()
|
||||
const developmentLicensesAllowed = allowsDevelopmentLicenses(options)
|
||||
|
||||
// 1. 레거시 개발자 테스트 키 확인 (하위 호환성)
|
||||
if (trimmed.startsWith('D3RO-PRO-') && trimmed.length >= 14) {
|
||||
// 1. 개발 fixture는 정확히 일치하고 test/development에서 명시적으로 허용된 경우만 인정한다.
|
||||
const developmentLicense = developmentLicensesAllowed ? DEVELOPMENT_LICENSES.get(trimmed) : undefined
|
||||
if (developmentLicense) {
|
||||
return {
|
||||
valid: true,
|
||||
tier: 'pro',
|
||||
tier: developmentLicense.tier,
|
||||
reason: 'dev_key',
|
||||
payload: {
|
||||
licenseId: 'dev-pro',
|
||||
tier: 'pro',
|
||||
licenseId: developmentLicense.licenseId,
|
||||
tier: developmentLicense.tier,
|
||||
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',
|
||||
message: developmentLicense.message,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +177,20 @@ export function verifySignedLicenseKey(
|
|||
}
|
||||
}
|
||||
|
||||
const configuredPublicKey = publicKeyPem ? normalizePublicKeyPem(publicKeyPem) : undefined
|
||||
const usesDevelopmentPublicKey =
|
||||
!configuredPublicKey || configuredPublicKey === normalizePublicKeyPem(DEVELOPMENT_LICENSE_PUBLIC_KEY)
|
||||
if (usesDevelopmentPublicKey && !developmentLicensesAllowed) {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
reason: 'invalid_signature',
|
||||
payload: null,
|
||||
message: 'Production license verification key is not configured',
|
||||
}
|
||||
}
|
||||
const verificationPublicKey = configuredPublicKey || DEVELOPMENT_LICENSE_PUBLIC_KEY
|
||||
|
||||
try {
|
||||
const rawBase64 = trimmed.replace('D3RO-LIC-', '')
|
||||
const jsonStr = Buffer.from(rawBase64, 'base64url').toString('utf-8')
|
||||
|
|
@ -204,7 +210,7 @@ export function verifySignedLicenseKey(
|
|||
|
||||
// 3. Ed25519 디지털 서명 검증
|
||||
try {
|
||||
const publicKey = createPublicKey(publicKeyPem)
|
||||
const publicKey = createPublicKey(verificationPublicKey)
|
||||
const canonicalData = canonicalizePayload(payload)
|
||||
const dataBuffer = Buffer.from(canonicalData, 'utf-8')
|
||||
const signatureBuffer = Buffer.from(signature, 'base64')
|
||||
|
|
|
|||
|
|
@ -108,8 +108,8 @@ export function markdownToSimpleHtml(md: string): string {
|
|||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/i18n",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} Funktionen",
|
||||
"mobile.works.searchPlaceholder": "Funktionen suchen",
|
||||
"mobile.works.pinned": "Angeheftet",
|
||||
"mobile.works.pin": "Anheften",
|
||||
"mobile.works.unpin": "Lösen",
|
||||
"mobile.works.emptySearch": "Keine passenden Funktionen.",
|
||||
"mobile.works.section.meetings": "Meetings-Arbeit",
|
||||
"mobile.works.section.team": "Team & Wissen",
|
||||
"mobile.works.section.data": "Daten & Hinweise",
|
||||
"mobile.works.historyDescription": "Transkripte von Aufnahmen und Importen suchen und verwalten.",
|
||||
"mobile.meetings.templateEmpty": "Keine Meeting-Dokumentvorlagen verfügbar. Sie können das Meeting ohne Vorlage starten.",
|
||||
"mobile.meetings.templateNone": "Ohne Vorlage starten",
|
||||
"mobile.meetings.templateNoneDesc": "Vorlage später bei der Dokumenterzeugung wählen"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} funciones",
|
||||
"mobile.works.searchPlaceholder": "Buscar funciones",
|
||||
"mobile.works.pinned": "Fijado",
|
||||
"mobile.works.pin": "Fijar arriba",
|
||||
"mobile.works.unpin": "Quitar fijado",
|
||||
"mobile.works.emptySearch": "No hay funciones que coincidan.",
|
||||
"mobile.works.section.meetings": "Trabajo de reuniones",
|
||||
"mobile.works.section.team": "Equipo y conocimiento",
|
||||
"mobile.works.section.data": "Datos y avisos",
|
||||
"mobile.works.historyDescription": "Busca y gestiona transcripciones de grabaciones e importaciones.",
|
||||
"mobile.meetings.templateEmpty": "No hay plantillas de documento de reunión disponibles. Puedes iniciar la reunión sin una.",
|
||||
"mobile.meetings.templateNone": "Empezar sin plantilla",
|
||||
"mobile.meetings.templateNoneDesc": "Elige una plantilla al generar documentos"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} fonctions",
|
||||
"mobile.works.searchPlaceholder": "Rechercher une fonction",
|
||||
"mobile.works.pinned": "Épinglé",
|
||||
"mobile.works.pin": "Épingler",
|
||||
"mobile.works.unpin": "Détacher",
|
||||
"mobile.works.emptySearch": "Aucune fonction correspondante.",
|
||||
"mobile.works.section.meetings": "Travail de réunion",
|
||||
"mobile.works.section.team": "Équipe et savoir",
|
||||
"mobile.works.section.data": "Données et alertes",
|
||||
"mobile.works.historyDescription": "Recherchez et gérez les transcriptions des enregistrements et importations.",
|
||||
"mobile.meetings.templateEmpty": "Aucun modèle de document de réunion disponible. Vous pouvez démarrer la réunion sans modèle.",
|
||||
"mobile.meetings.templateNone": "Démarrer sans modèle",
|
||||
"mobile.meetings.templateNoneDesc": "Choisissez un modèle lors de la génération de documents"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "機能 {{count}}件",
|
||||
"mobile.works.searchPlaceholder": "機能を検索",
|
||||
"mobile.works.pinned": "ピン留め",
|
||||
"mobile.works.pin": "ピン留め",
|
||||
"mobile.works.unpin": "ピン留め解除",
|
||||
"mobile.works.emptySearch": "一致する機能がありません。",
|
||||
"mobile.works.section.meetings": "会議ワーク",
|
||||
"mobile.works.section.team": "チーム・ナレッジ",
|
||||
"mobile.works.section.data": "データ・通知",
|
||||
"mobile.works.historyDescription": "録音とインポートの文字起こしを検索・管理します。",
|
||||
"mobile.meetings.templateEmpty": "利用可能な会議ドキュメントテンプレートがありません。テンプレートなしで会議を開始できます。",
|
||||
"mobile.meetings.templateNone": "テンプレートなしで開始",
|
||||
"mobile.meetings.templateNoneDesc": "ドキュメント生成時にテンプレートを選択します"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} funções",
|
||||
"mobile.works.searchPlaceholder": "Pesquisar funções",
|
||||
"mobile.works.pinned": "Fixado",
|
||||
"mobile.works.pin": "Fixar no topo",
|
||||
"mobile.works.unpin": "Desafixar",
|
||||
"mobile.works.emptySearch": "Nenhuma função correspondente.",
|
||||
"mobile.works.section.meetings": "Trabalho de reuniões",
|
||||
"mobile.works.section.team": "Equipe e conhecimento",
|
||||
"mobile.works.section.data": "Dados e alertas",
|
||||
"mobile.works.historyDescription": "Pesquise e gerencie transcrições de gravações e importações.",
|
||||
"mobile.meetings.templateEmpty": "Nenhuma template de documento de reunião disponível. Você pode iniciar a reunião sem uma.",
|
||||
"mobile.meetings.templateNone": "Começar sem template",
|
||||
"mobile.meetings.templateNoneDesc": "Escolha uma template ao gerar documentos"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} функций",
|
||||
"mobile.works.searchPlaceholder": "Поиск функций",
|
||||
"mobile.works.pinned": "Закреплено",
|
||||
"mobile.works.pin": "Закрепить",
|
||||
"mobile.works.unpin": "Открепить",
|
||||
"mobile.works.emptySearch": "Подходящих функций нет.",
|
||||
"mobile.works.section.meetings": "Работа с встречами",
|
||||
"mobile.works.section.team": "Команда и знания",
|
||||
"mobile.works.section.data": "Данные и оповещения",
|
||||
"mobile.works.historyDescription": "Поиск и управление расшифровками записей и импорта.",
|
||||
"mobile.meetings.templateEmpty": "Нет доступных шаблонов документов встреч. Можно начать встречу без шаблона.",
|
||||
"mobile.meetings.templateNone": "Начать без шаблона",
|
||||
"mobile.meetings.templateNoneDesc": "Шаблон можно выбрать при создании документов"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} ฟีเจอร์",
|
||||
"mobile.works.searchPlaceholder": "ค้นหาฟีเจอร์",
|
||||
"mobile.works.pinned": "ปักหมุดแล้ว",
|
||||
"mobile.works.pin": "ปักหมุดไว้ด้านบน",
|
||||
"mobile.works.unpin": "ยกเลิกปักหมุด",
|
||||
"mobile.works.emptySearch": "ไม่พบฟีเจอร์ที่ตรงกัน",
|
||||
"mobile.works.section.meetings": "งานประชุม",
|
||||
"mobile.works.section.team": "ทีมและความรู้",
|
||||
"mobile.works.section.data": "ข้อมูลและการแจ้งเตือน",
|
||||
"mobile.works.historyDescription": "ค้นหาและจัดการบทถอดความจากการบันทึกเสียงและการนำเข้า",
|
||||
"mobile.meetings.templateEmpty": "ไม่มีเทมเพลตเอกสารประชุมที่ใช้ได้ เริ่มประชุมโดยไม่ใช้เทมเพลตได้",
|
||||
"mobile.meetings.templateNone": "เริ่มโดยไม่ใช้เทมเพลต",
|
||||
"mobile.meetings.templateNoneDesc": "เลือกเทมเพลตภายหลังเมื่อสร้างเอกสาร"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} tính năng",
|
||||
"mobile.works.searchPlaceholder": "Tìm tính năng",
|
||||
"mobile.works.pinned": "Đã ghim",
|
||||
"mobile.works.pin": "Ghim lên đầu",
|
||||
"mobile.works.unpin": "Bỏ ghim",
|
||||
"mobile.works.emptySearch": "Không có tính năng phù hợp.",
|
||||
"mobile.works.section.meetings": "Công việc họp",
|
||||
"mobile.works.section.team": "Nhóm & kiến thức",
|
||||
"mobile.works.section.data": "Dữ liệu & thông báo",
|
||||
"mobile.works.historyDescription": "Tìm kiếm và quản lý bản chép lời từ bản ghi và tệp nhập.",
|
||||
"mobile.meetings.templateEmpty": "Không có mẫu tài liệu họp nào. Bạn có thể bắt đầu cuộc họp mà không cần mẫu.",
|
||||
"mobile.meetings.templateNone": "Bắt đầu không cần mẫu",
|
||||
"mobile.meetings.templateNoneDesc": "Chọn mẫu sau khi khi tạo tài liệu"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} 個功能",
|
||||
"mobile.works.searchPlaceholder": "搜尋功能",
|
||||
"mobile.works.pinned": "已釘選",
|
||||
"mobile.works.pin": "釘選",
|
||||
"mobile.works.unpin": "取消釘選",
|
||||
"mobile.works.emptySearch": "沒有符合的功能。",
|
||||
"mobile.works.section.meetings": "會議工作",
|
||||
"mobile.works.section.team": "團隊與知識",
|
||||
"mobile.works.section.data": "資料與通知",
|
||||
"mobile.works.historyDescription": "搜尋和管理錄音與匯入的逐字稿。",
|
||||
"mobile.meetings.templateEmpty": "沒有可用的會議文件範本。可以不使用範本開始會議。",
|
||||
"mobile.meetings.templateNone": "不使用範本開始",
|
||||
"mobile.meetings.templateNoneDesc": "稍後產生文件時再選擇範本"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,5 +256,19 @@
|
|||
"license.modelSonnet": "Sonnet",
|
||||
"license.modelOpus": "Opus",
|
||||
"license.quotaWeekly": "weekly",
|
||||
"license.quotaDaily": "daily"
|
||||
"license.quotaDaily": "daily",
|
||||
"mobile.works.title": "WORKS",
|
||||
"mobile.works.count": "{{count}} 个功能",
|
||||
"mobile.works.searchPlaceholder": "搜索功能",
|
||||
"mobile.works.pinned": "已固定",
|
||||
"mobile.works.pin": "置顶",
|
||||
"mobile.works.unpin": "取消置顶",
|
||||
"mobile.works.emptySearch": "没有匹配的功能。",
|
||||
"mobile.works.section.meetings": "会议工作",
|
||||
"mobile.works.section.team": "团队与知识",
|
||||
"mobile.works.section.data": "数据与通知",
|
||||
"mobile.works.historyDescription": "搜索和管理录音与导入的转写记录。",
|
||||
"mobile.meetings.templateEmpty": "没有可用的会议文档模板。可以不使用模板开始会议。",
|
||||
"mobile.meetings.templateNone": "不使用模板开始",
|
||||
"mobile.meetings.templateNoneDesc": "稍后生成文档时再选择模板"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/ui-native",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// RN FilterChip — pill-shaped toggle chip for filter bars
|
||||
|
||||
import { Pressable, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeRadius } from '../theme'
|
||||
import { d3roNativeRadius } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
import { PhosphorText } from './PhosphorText'
|
||||
|
||||
export interface FilterChipProps {
|
||||
|
|
@ -18,14 +19,21 @@ export function FilterChip({
|
|||
onPress,
|
||||
style
|
||||
}: FilterChipProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: active ? d3roNativePalette.accent.main : d3roNativePalette.bg.card,
|
||||
minHeight: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: active ? palette.accent.main : palette.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: active ? d3roNativePalette.accent.main : d3roNativePalette.border.default,
|
||||
borderColor: active ? palette.accent.main : palette.border.default,
|
||||
borderRadius: d3roNativeRadius.pill,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6
|
||||
|
|
@ -36,7 +44,7 @@ export function FilterChip({
|
|||
<PhosphorText
|
||||
variant="small"
|
||||
color={active ? 'primary' : 'muted'}
|
||||
style={active ? { color: d3roNativePalette.bg.app } : undefined}
|
||||
style={active ? { color: palette.text.onAccent ?? palette.bg.app } : undefined}
|
||||
>
|
||||
{label}
|
||||
</PhosphorText>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { View, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { Led } from './Led'
|
||||
import { PhosphorText } from './PhosphorText'
|
||||
import { d3roNativePalette } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface HeaderProps {
|
||||
title: string
|
||||
|
|
@ -22,6 +22,7 @@ export function Header({
|
|||
paddingTop = 0,
|
||||
style
|
||||
}: HeaderProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -33,14 +34,18 @@ export function Header({
|
|||
paddingTop: paddingTop + 8,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: showBorder ? 1 : 0,
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
borderBottomColor: palette.border.subtle
|
||||
},
|
||||
style
|
||||
]}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Led color="amber" size={8} />
|
||||
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}>
|
||||
<PhosphorText
|
||||
accessibilityRole="header"
|
||||
variant="label"
|
||||
color="muted"
|
||||
>
|
||||
{title}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// RN용 Led — 작은 발광 원
|
||||
|
||||
import { View, type ViewStyle } from 'react-native'
|
||||
import { d3roNativePalette } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface LedProps {
|
||||
color?: 'amber' | 'green' | 'red' | 'orange' | 'blue'
|
||||
|
|
@ -10,21 +10,21 @@ export interface LedProps {
|
|||
size?: number
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
amber: d3roNativePalette.accent.main,
|
||||
green: d3roNativePalette.tag.green,
|
||||
red: d3roNativePalette.tag.red,
|
||||
orange: d3roNativePalette.tag.orange,
|
||||
blue: d3roNativePalette.tag.blue
|
||||
}
|
||||
|
||||
export function Led({ color = 'amber', on = true, size = 8 }: LedProps): React.ReactElement {
|
||||
const c = COLOR_MAP[color] ?? d3roNativePalette.accent.main
|
||||
const palette = useNativePalette()
|
||||
const colorMap: Record<NonNullable<LedProps['color']>, string> = {
|
||||
amber: palette.accent.main,
|
||||
green: palette.tag.green,
|
||||
red: palette.tag.red,
|
||||
orange: palette.tag.orange,
|
||||
blue: palette.tag.blue
|
||||
}
|
||||
const c = colorMap[color]
|
||||
const style: ViewStyle = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: on ? c : d3roNativePalette.led.off,
|
||||
backgroundColor: on ? c : palette.led.off,
|
||||
shadowColor: c,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: on ? 0.8 : 0,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// RN용 MetalCard — View + 섀시 스타일
|
||||
|
||||
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeRadius } from '../theme'
|
||||
import { d3roNativeRadius } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface MetalCardProps extends ViewProps {
|
||||
inset?: boolean
|
||||
|
|
@ -10,15 +11,16 @@ export interface MetalCardProps extends ViewProps {
|
|||
}
|
||||
|
||||
export function MetalCard({ children, inset = false, style, ...rest }: MetalCardProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
const baseStyle: ViewStyle = {
|
||||
backgroundColor: inset ? d3roNativePalette.bg.inset : d3roNativePalette.bg.card,
|
||||
backgroundColor: inset ? palette.bg.inset : palette.bg.card,
|
||||
borderRadius: inset ? d3roNativeRadius.inner : d3roNativeRadius.card,
|
||||
padding: inset ? 6 : 16,
|
||||
borderWidth: inset ? 0 : 1,
|
||||
borderColor: d3roNativePalette.border.subtle,
|
||||
borderColor: palette.border.subtle,
|
||||
overflow: 'hidden',
|
||||
// RN 그림자
|
||||
shadowColor: '#000',
|
||||
shadowColor: palette.shadow ?? '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: inset ? 0 : 0.25,
|
||||
shadowRadius: inset ? 0 : 8,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// packages/ui-native/src/components/PhosphorText.tsx
|
||||
// RN용 PhosphorText — Text + 앰버 glow (textShadowColor)
|
||||
// RN용 PhosphorText — v3: 시스템 산세리프 기반 (한글은 Apple SD Gothic Neo /
|
||||
// Noto Sans KR 자동 폴백). 텍스트 글로우는 제거하고 명도로 위계를 만든다.
|
||||
|
||||
import { Text, Platform, type TextProps, type TextStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeTypo, type D3roNativeTypoKey } from '../theme'
|
||||
import { Text, type TextProps, type TextStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativeFonts, d3roNativeTypo, type D3roNativeTypoKey } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export type PhosphorVariant = D3roNativeTypoKey
|
||||
|
||||
|
|
@ -19,29 +21,23 @@ export function PhosphorText({
|
|||
style,
|
||||
...rest
|
||||
}: PhosphorTextProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
const typo = d3roNativeTypo[variant]
|
||||
const isAmberVariant = variant === 'hero' || variant === 'title' || variant === 'value'
|
||||
|
||||
const resolvedColor: string = (() => {
|
||||
if (color === 'amber') return d3roNativePalette.accent.main
|
||||
if (color === 'primary') return d3roNativePalette.text.primary
|
||||
if (color === 'secondary') return d3roNativePalette.text.secondary
|
||||
if (color === 'label') return d3roNativePalette.text.label
|
||||
if (color === 'muted') return d3roNativePalette.text.muted
|
||||
return isAmberVariant ? d3roNativePalette.accent.main : d3roNativePalette.text.primary
|
||||
if (color === 'amber') return palette.accent.main
|
||||
if (color === 'primary') return palette.text.primary
|
||||
if (color === 'secondary') return palette.text.secondary
|
||||
if (color === 'label') return palette.text.label
|
||||
if (color === 'muted') return palette.text.muted
|
||||
return isAmberVariant ? palette.accent.main : palette.text.primary
|
||||
})()
|
||||
|
||||
const baseStyle: TextStyle = {
|
||||
...typo,
|
||||
color: resolvedColor,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
...(isAmberVariant
|
||||
? {
|
||||
textShadowColor: d3roNativePalette.accent.glow,
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 6
|
||||
}
|
||||
: {})
|
||||
fontFamily: d3roNativeFonts.sans
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
type TextStyle,
|
||||
type StyleProp
|
||||
} from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeRadius, d3roNativeTypo } from '../theme'
|
||||
import { d3roNativeRadius, d3roNativeTypo } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface PhysicalButtonProps extends Omit<PressableProps, 'children' | 'style'> {
|
||||
label: string
|
||||
|
|
@ -27,28 +28,30 @@ export function PhysicalButton({
|
|||
onPress,
|
||||
...rest
|
||||
}: PhysicalButtonProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
||||
const bgColor =
|
||||
variant === 'primary'
|
||||
? d3roNativePalette.accent.main
|
||||
? palette.accent.main
|
||||
: variant === 'danger'
|
||||
? d3roNativePalette.tag.red
|
||||
? palette.tag.red
|
||||
: 'transparent'
|
||||
|
||||
const borderColor =
|
||||
variant === 'secondary' ? d3roNativePalette.border.strong : 'transparent'
|
||||
variant === 'secondary' ? palette.border.strong : 'transparent'
|
||||
|
||||
const textColor =
|
||||
variant === 'primary' || variant === 'danger'
|
||||
? d3roNativePalette.text.primary
|
||||
: d3roNativePalette.text.secondary
|
||||
? palette.text.onAccent ?? palette.text.primary
|
||||
: palette.text.secondary
|
||||
|
||||
const containerStyle: ViewStyle = {
|
||||
backgroundColor: bgColor,
|
||||
borderColor,
|
||||
borderWidth: variant === 'secondary' ? 1 : 0,
|
||||
borderRadius: d3roNativeRadius.button,
|
||||
minHeight: 48,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
alignItems: 'center',
|
||||
|
|
@ -59,19 +62,22 @@ export function PhysicalButton({
|
|||
|
||||
const textStyle: TextStyle = {
|
||||
...d3roNativeTypo.heading,
|
||||
color: textColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5
|
||||
color: textColor
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...rest}
|
||||
accessibilityRole={rest.accessibilityRole ?? 'button'}
|
||||
accessibilityState={{
|
||||
...rest.accessibilityState,
|
||||
disabled,
|
||||
}}
|
||||
onPress={onPress}
|
||||
onPressIn={() => setPressed(true)}
|
||||
onPressOut={() => setPressed(false)}
|
||||
disabled={disabled}
|
||||
style={[containerStyle, style]}
|
||||
{...rest}
|
||||
>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@
|
|||
// RN ScreenPanel — inset bg + dot grid background
|
||||
|
||||
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeRadius } from '../theme'
|
||||
import { d3roNativeRadius } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface ScreenPanelProps extends ViewProps {
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function ScreenPanel({ children, style, ...rest }: ScreenPanelProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
const baseStyle: ViewStyle = {
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
backgroundColor: palette.bg.inset,
|
||||
borderRadius: d3roNativeRadius.inner,
|
||||
padding: 20,
|
||||
overflow: 'hidden'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// RN StatusBar — bottom status strip with LED + model label
|
||||
|
||||
import { View, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette, d3roNativeFonts } from '../theme'
|
||||
import { d3roNativeFonts } from '../theme'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
import { Led } from './Led'
|
||||
import { PhosphorText } from './PhosphorText'
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export function AppStatusBar({
|
|||
label = 'PRECISION DATA LINK',
|
||||
style
|
||||
}: AppStatusBarProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
const containerStyle: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -33,15 +35,15 @@ export function AppStatusBar({
|
|||
<PhosphorText
|
||||
variant="label"
|
||||
color="muted"
|
||||
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 8, letterSpacing: 1.5 }}
|
||||
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 10, letterSpacing: 0.6 }}
|
||||
>
|
||||
{model}
|
||||
</PhosphorText>
|
||||
<View style={{ width: 1, height: 8, backgroundColor: d3roNativePalette.border.default }} />
|
||||
<View style={{ width: 1, height: 8, backgroundColor: palette.border.default }} />
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
color="muted"
|
||||
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 8, letterSpacing: 1.5 }}
|
||||
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 10, letterSpacing: 0.6 }}
|
||||
>
|
||||
{label}
|
||||
</PhosphorText>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
// packages/ui-native/src/components/WaveBars.tsx
|
||||
// RN WaveBars — animated audio level bars using core Animated API
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { View, Animated, Easing, type ViewStyle, type StyleProp } from 'react-native'
|
||||
import { d3roNativePalette } from '../theme'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
View,
|
||||
Animated,
|
||||
Easing,
|
||||
type ViewStyle,
|
||||
type StyleProp,
|
||||
} from 'react-native'
|
||||
import { useNativePalette } from '../theme-context'
|
||||
|
||||
export interface WaveBarsProps {
|
||||
active?: boolean
|
||||
barCount?: number
|
||||
reduceMotion?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +23,15 @@ const BAR_MIN = 8
|
|||
const BAR_MAX = 32
|
||||
const DURATIONS = [800, 1000, 900, 1200, 1100, 950, 1050, 1300, 850, 1150, 1000]
|
||||
|
||||
function WaveBar({ index, active }: { index: number; active: boolean }): React.ReactElement {
|
||||
function WaveBar({
|
||||
index,
|
||||
active,
|
||||
color,
|
||||
}: {
|
||||
index: number
|
||||
active: boolean
|
||||
color: string
|
||||
}): React.ReactElement {
|
||||
const height = useRef(new Animated.Value(BAR_MIN)).current
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -41,11 +57,8 @@ function WaveBar({ index, active }: { index: number; active: boolean }): React.R
|
|||
animation.start()
|
||||
return () => animation.stop()
|
||||
} else {
|
||||
Animated.timing(height, {
|
||||
toValue: BAR_MIN,
|
||||
duration: 300,
|
||||
useNativeDriver: false
|
||||
}).start()
|
||||
height.stopAnimation()
|
||||
height.setValue(BAR_MIN)
|
||||
}
|
||||
}, [active, height, index])
|
||||
|
||||
|
|
@ -54,7 +67,7 @@ function WaveBar({ index, active }: { index: number; active: boolean }): React.R
|
|||
style={{
|
||||
width: 6,
|
||||
height,
|
||||
backgroundColor: d3roNativePalette.accent.main,
|
||||
backgroundColor: color,
|
||||
borderRadius: 3
|
||||
}}
|
||||
/>
|
||||
|
|
@ -64,23 +77,54 @@ function WaveBar({ index, active }: { index: number; active: boolean }): React.R
|
|||
export function WaveBars({
|
||||
active = false,
|
||||
barCount = 11,
|
||||
reduceMotion,
|
||||
style
|
||||
}: WaveBarsProps): React.ReactElement {
|
||||
const palette = useNativePalette()
|
||||
// Fail closed: do not animate until the asynchronous OS preference is known.
|
||||
const [systemReduceMotion, setSystemReduceMotion] = useState(
|
||||
reduceMotion === undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion !== undefined) return undefined
|
||||
let mounted = true
|
||||
void AccessibilityInfo.isReduceMotionEnabled()
|
||||
.then((enabled) => {
|
||||
if (mounted) setSystemReduceMotion(enabled)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const subscription = AccessibilityInfo.addEventListener(
|
||||
'reduceMotionChanged',
|
||||
setSystemReduceMotion,
|
||||
)
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.remove()
|
||||
}
|
||||
}, [reduceMotion])
|
||||
|
||||
const motionDisabled = reduceMotion ?? systemReduceMotion
|
||||
const containerStyle: ViewStyle = {
|
||||
height: 128,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
backgroundColor: palette.bg.inset,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
borderBottomColor: palette.border.subtle
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[containerStyle, style]}>
|
||||
{Array.from({ length: barCount }).map((_, i) => (
|
||||
<WaveBar key={i} index={i} active={active} />
|
||||
<WaveBar
|
||||
key={i}
|
||||
index={i}
|
||||
active={active && !motionDisabled}
|
||||
color={palette.accent.main}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ export {
|
|||
d3roNativeTypo,
|
||||
d3roNativeRadius,
|
||||
d3roNativeFonts,
|
||||
type D3roNativePalette,
|
||||
type D3roNativePaletteKey,
|
||||
type D3roNativeTypoKey
|
||||
} from './theme'
|
||||
export {
|
||||
NativeThemeProvider,
|
||||
useNativePalette,
|
||||
type NativeThemeProviderProps
|
||||
} from './theme-context'
|
||||
export { MetalCard, type MetalCardProps } from './components/MetalCard'
|
||||
export {
|
||||
PhosphorText,
|
||||
|
|
|
|||
31
packages/ui-native/src/theme-context.tsx
Normal file
31
packages/ui-native/src/theme-context.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import {
|
||||
d3roNativePalette,
|
||||
type D3roNativePalette,
|
||||
} from './theme'
|
||||
|
||||
const NativeThemeContext = createContext<D3roNativePalette | null>(null)
|
||||
|
||||
export interface NativeThemeProviderProps {
|
||||
palette: D3roNativePalette
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional runtime palette boundary for React Native consumers.
|
||||
* Components outside a provider retain the legacy palette for compatibility.
|
||||
*/
|
||||
export function NativeThemeProvider({
|
||||
palette,
|
||||
children,
|
||||
}: NativeThemeProviderProps): React.ReactElement {
|
||||
return (
|
||||
<NativeThemeContext.Provider value={palette}>
|
||||
{children}
|
||||
</NativeThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNativePalette(): D3roNativePalette {
|
||||
return useContext(NativeThemeContext) ?? d3roNativePalette
|
||||
}
|
||||
|
|
@ -4,7 +4,51 @@
|
|||
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
export const d3roNativePalette = {
|
||||
export interface D3roNativePalette {
|
||||
bg: {
|
||||
app: string
|
||||
card: string
|
||||
cardHover: string
|
||||
elevated: string
|
||||
sidebar: string
|
||||
inset: string
|
||||
chassis: string
|
||||
}
|
||||
text: {
|
||||
primary: string
|
||||
white: string
|
||||
onAccent?: string
|
||||
secondary: string
|
||||
label: string
|
||||
disabled: string
|
||||
inactive: string
|
||||
muted: string
|
||||
}
|
||||
accent: {
|
||||
main: string
|
||||
pressed?: string
|
||||
dim: string
|
||||
glow: string
|
||||
green: string
|
||||
greenGlow: string
|
||||
}
|
||||
border: {
|
||||
subtle: string
|
||||
default: string
|
||||
strong: string
|
||||
}
|
||||
tag: {
|
||||
purple: string
|
||||
orange: string
|
||||
red: string
|
||||
green: string
|
||||
blue: string
|
||||
}
|
||||
led: { off: string }
|
||||
shadow?: string
|
||||
}
|
||||
|
||||
export const d3roNativePalette: D3roNativePalette = {
|
||||
bg: {
|
||||
app: '#19191b',
|
||||
card: '#242427',
|
||||
|
|
@ -24,9 +68,10 @@ export const d3roNativePalette = {
|
|||
muted: '#71717a'
|
||||
},
|
||||
accent: {
|
||||
main: '#ff5c35',
|
||||
dim: 'rgba(255, 92, 53, 0.15)',
|
||||
glow: 'rgba(255, 92, 53, 0.6)',
|
||||
// 브랜드 통일: 앱(Midnight Glass) blue — 레거시 amber 폐기
|
||||
main: '#3b82f6',
|
||||
dim: 'rgba(59, 130, 246, 0.15)',
|
||||
glow: 'rgba(59, 130, 246, 0.5)',
|
||||
green: '#4ade80',
|
||||
greenGlow: 'rgba(74, 222, 128, 0.6)'
|
||||
},
|
||||
|
|
@ -47,15 +92,19 @@ export const d3roNativePalette = {
|
|||
}
|
||||
} as const
|
||||
|
||||
// v3 "타이포그래피 퍼스트" — packages/ui d3roTypo v3와 동일 철학.
|
||||
// 볼드(700) 폐기, 크기·행간·명도로 위계. 한글 행간 1.5~1.6,
|
||||
// 자간 0 근처 (양수 트래킹은 라틴 대문자 라벨에만 소량).
|
||||
// RN letterSpacing 단위는 px.
|
||||
export const d3roNativeTypo = {
|
||||
hero: { fontSize: 42, fontWeight: '300' as const, letterSpacing: -2, lineHeight: 42 },
|
||||
title: { fontSize: 28, fontWeight: '300' as const, letterSpacing: -1, lineHeight: 34 },
|
||||
value: { fontSize: 20, fontWeight: '400' as const, letterSpacing: 0.4, lineHeight: 20 },
|
||||
heading: { fontSize: 16, fontWeight: '600' as const, letterSpacing: 0.32, lineHeight: 22 },
|
||||
body: { fontSize: 14, fontWeight: '400' as const, letterSpacing: 0.14, lineHeight: 21 },
|
||||
small: { fontSize: 12, fontWeight: '600' as const, letterSpacing: 0.36, lineHeight: 17 },
|
||||
meta: { fontSize: 11, fontWeight: '600' as const, letterSpacing: 0.55, lineHeight: 14 },
|
||||
label: { fontSize: 10, fontWeight: '700' as const, letterSpacing: 2, lineHeight: 12 }
|
||||
hero: { fontSize: 42, fontWeight: '400' as const, letterSpacing: -0.5, lineHeight: 50 },
|
||||
title: { fontSize: 28, fontWeight: '400' as const, letterSpacing: -0.3, lineHeight: 36 },
|
||||
value: { fontSize: 20, fontWeight: '500' as const, letterSpacing: 0, lineHeight: 26 },
|
||||
heading: { fontSize: 16, fontWeight: '500' as const, letterSpacing: 0, lineHeight: 24 },
|
||||
body: { fontSize: 14, fontWeight: '400' as const, letterSpacing: 0, lineHeight: 22 },
|
||||
small: { fontSize: 12, fontWeight: '400' as const, letterSpacing: 0, lineHeight: 18 },
|
||||
meta: { fontSize: 11, fontWeight: '500' as const, letterSpacing: 0.3, lineHeight: 16 },
|
||||
label: { fontSize: 11, fontWeight: '500' as const, letterSpacing: 0.4, lineHeight: 15 }
|
||||
} as const
|
||||
|
||||
export const d3roNativeRadius = {
|
||||
|
|
@ -66,9 +115,14 @@ export const d3roNativeRadius = {
|
|||
pill: 999
|
||||
} as const
|
||||
|
||||
// 폰트 전략 (bare RN, 커스텀 폰트 번들링 없음):
|
||||
// - sans: 플랫폼 시스템 산세리프. iOS=SF Pro(한글은 Apple SD Gothic Neo로 자동
|
||||
// 폴백), Android=Roboto(한글은 Noto Sans KR로 자동 폴백). Apple HIG/Material
|
||||
// 권장을 따르는 표준 방식이며 한글 렌더링 품질이 가장 안정적.
|
||||
// - mono: 텔레메트리·코드 등 등폭이 필요한 곳에만 사용 (한글엔 사용 금지).
|
||||
export const d3roNativeFonts = {
|
||||
mono: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
sans: Platform.OS === 'ios' ? 'System' : 'Roboto'
|
||||
sans: Platform.OS === 'ios' ? 'System' : 'Roboto',
|
||||
mono: Platform.OS === 'ios' ? 'Menlo' : 'monospace'
|
||||
} as const
|
||||
|
||||
export type D3roNativePaletteKey = keyof typeof d3roNativePalette
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/ui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function Engraving({ children, sx }: { children: string; sx: Record<string, unkn
|
|||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
opacity: 0.7,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontFamily: d3roFontMono,
|
||||
textTransform: 'uppercase',
|
||||
zIndex: 2,
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export function SegmentControl<T extends string = string>({
|
|||
bgcolor: isSelected ? d3roPalette.accent.dim : d3roPalette.bg.chassis,
|
||||
color: isSelected ? d3roPalette.accent.light : d3roPalette.text.dimLabel,
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{opt.badge}
|
||||
|
|
|
|||
|
|
@ -459,9 +459,13 @@ export const d3roPalette = {
|
|||
},
|
||||
} as const
|
||||
|
||||
// 한글 우선 폰트 스택 — Pretendard(한/영 조화) → 플랫폼 한글 시스템 폰트 폴백.
|
||||
// 웨이트는 Pretendard Variable(45-920) 기준. 폴백 시스템 폰트에 없는 굵기는
|
||||
// 브라우저가 합성하므로 400/500/600 범위로만 사용한다 (W3C klreq 권장 범위).
|
||||
export const d3roFontSans = [
|
||||
'"Pretendard Variable"', 'Pretendard', '-apple-system', 'BlinkMacSystemFont',
|
||||
'"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif',
|
||||
'"Apple SD Gothic Neo"', '"Noto Sans KR"', '"Segoe UI"', 'Roboto',
|
||||
'"Helvetica Neue"', 'Arial', '"Malgun Gothic"', 'sans-serif',
|
||||
].join(',')
|
||||
|
||||
export const d3roFontMono = [
|
||||
|
|
@ -470,20 +474,27 @@ export const d3roFontMono = [
|
|||
].join(',')
|
||||
|
||||
// ── SSOT: 타이포그래피 토큰 ─────────────────────────────
|
||||
// v2: 클린 산세리프(Pretendard) 기반, 굵은 헤드라인 + 큰 수치
|
||||
// v3 "타이포그래피 퍼스트": 볼드(700+)를 제거하고 크기·행간·명도로 위계를 만든다.
|
||||
// 근거 (W3C klreq / 요즘IT DS / KRDS):
|
||||
// - 한글은 낱글자가 사각 블록이라 라틴보다 시각 밀도가 높다 → 600 이상은 과중.
|
||||
// 헤드라인은 500, 몸통 400, 라벨 500으로 충분하다.
|
||||
// - 한글 행간은 1.5~1.7 (블록 글자의 숨 쉬는 공간). 헤드라인도 1.2 이상.
|
||||
// - 한글 자간 0 근처. 음수 트래킹은 대형 라틴 헤드라인에만 -0.01~-0.02em.
|
||||
// - 양수 트래킹(+0.02~0.04em)은 라틴 대문자 라벨에만. 한글엔 적용하지 않는다.
|
||||
// - 한글 최소 11px (획이 뭉개지는 하한). 8-9px 토큰은 폐기하고 상향.
|
||||
export const d3roTypo = {
|
||||
hero: { size: '32px', weight: 700, spacing: '-0.5px', line: 1.2 },
|
||||
title: { size: '24px', weight: 700, spacing: '-0.3px', line: 1.25 },
|
||||
value: { size: '20px', weight: 600, spacing: '0', line: 1.1 },
|
||||
heading: { size: '16px', weight: 600, spacing: '0', line: 1.4 },
|
||||
body: { size: '14px', weight: 400, spacing: '0', line: 1.55 },
|
||||
compact: { size: '13px', weight: 400, spacing: '0', line: 1.5 },
|
||||
small: { size: '12px', weight: 500, spacing: '0.01em', line: 1.4 },
|
||||
meta: { size: '11px', weight: 600, spacing: '0.04em', line: 1.3 },
|
||||
label: { size: '11px', weight: 600, spacing: '0.08em', line: 1.2 },
|
||||
engrave: { size: '9px', weight: 700, spacing: '1px', line: 1 },
|
||||
micro: { size: '9px', weight: 700, spacing: '1px', line: 1 },
|
||||
nano: { size: '8px', weight: 700, spacing: '0.5px', line: 1 },
|
||||
hero: { size: '32px', weight: 500, spacing: '-0.02em', line: 1.3 },
|
||||
title: { size: '24px', weight: 500, spacing: '-0.01em', line: 1.35 },
|
||||
value: { size: '20px', weight: 500, spacing: '0', line: 1.2 },
|
||||
heading: { size: '16px', weight: 500, spacing: '0', line: 1.5 },
|
||||
body: { size: '14px', weight: 400, spacing: '0', line: 1.6 },
|
||||
compact: { size: '13px', weight: 400, spacing: '0', line: 1.55 },
|
||||
small: { size: '12px', weight: 400, spacing: '0', line: 1.45 },
|
||||
meta: { size: '11px', weight: 500, spacing: '0.02em', line: 1.35 },
|
||||
label: { size: '11px', weight: 500, spacing: '0.04em', line: 1.3 },
|
||||
engrave: { size: '10px', weight: 500, spacing: '0.06em', line: 1.2 },
|
||||
micro: { size: '10px', weight: 500, spacing: '0.04em', line: 1.2 },
|
||||
nano: { size: '9px', weight: 500, spacing: '0.03em', line: 1.15 },
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
|
@ -601,6 +612,12 @@ function buildCssVars(key: ThemeKey): Record<string, string> {
|
|||
'--d3-glow-soft': r.glow.soft,
|
||||
'--d3-glow-card': r.glow.card,
|
||||
'--d3-glow-cardHover': r.glow.cardHover,
|
||||
// 시맨틱 태그색 (테마 불변) — color-mix 틴트 파생용
|
||||
'--d3-tag-purple': d3roPalette.tag.purple,
|
||||
'--d3-tag-orange': d3roPalette.tag.orange,
|
||||
'--d3-tag-red': d3roPalette.tag.red,
|
||||
'--d3-tag-green': d3roPalette.tag.green,
|
||||
'--d3-tag-blue': d3roPalette.tag.blue,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -631,15 +648,16 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
},
|
||||
typography: {
|
||||
fontFamily: d3roFontSans,
|
||||
h4: { fontWeight: 700, fontSize: '22px', lineHeight: 1.3 },
|
||||
h5: { fontWeight: 700, fontSize: '18px', lineHeight: 1.4 },
|
||||
h6: { fontWeight: 600, fontSize: '14px', lineHeight: 1.5 },
|
||||
subtitle1: { fontWeight: 500, fontSize: '18px', lineHeight: 1.4 },
|
||||
body1: { fontSize: '14px', lineHeight: 1.5 },
|
||||
body2: { fontSize: '12px', lineHeight: 1.4 },
|
||||
button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '14px' },
|
||||
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.06em', color: r.text.label },
|
||||
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
|
||||
// v3: 헤드라인 500 — 볼드가 아닌 크기와 명도로 위계 (한글 과중 방지)
|
||||
h4: { fontWeight: 500, fontSize: '22px', lineHeight: 1.35 },
|
||||
h5: { fontWeight: 500, fontSize: '18px', lineHeight: 1.45 },
|
||||
h6: { fontWeight: 500, fontSize: '14px', lineHeight: 1.5 },
|
||||
subtitle1: { fontWeight: 500, fontSize: '18px', lineHeight: 1.45 },
|
||||
body1: { fontSize: '14px', lineHeight: 1.6 },
|
||||
body2: { fontSize: '12px', lineHeight: 1.5 },
|
||||
button: { textTransform: 'none' as const, fontWeight: 500, fontSize: '14px' },
|
||||
caption: { fontSize: '11px', fontWeight: 500, letterSpacing: '0.02em', color: r.text.label },
|
||||
overline: { fontSize: '11px', fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase' as const, lineHeight: 1.3 },
|
||||
},
|
||||
shape: { borderRadius: 16 },
|
||||
components: {
|
||||
|
|
@ -656,7 +674,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none', fontWeight: 600, borderRadius: 10, padding: '10px 20px',
|
||||
textTransform: 'none', fontWeight: 500, borderRadius: 10, padding: '10px 20px',
|
||||
transition: 'filter 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease',
|
||||
},
|
||||
containedPrimary: {
|
||||
|
|
@ -711,7 +729,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999, fontWeight: 600, fontSize: '11px', letterSpacing: '0.04em', height: 24 },
|
||||
root: { borderRadius: 999, fontWeight: 500, fontSize: '11px', letterSpacing: '0.02em', height: 24 },
|
||||
colorPrimary: { backgroundColor: accentDim, color: accentLight },
|
||||
colorSecondary: { backgroundColor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple },
|
||||
colorSuccess: { backgroundColor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green },
|
||||
|
|
@ -724,7 +742,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12, marginLeft: 8, marginRight: 8,
|
||||
'&.Mui-selected': { backgroundColor: accentDim, color: accentLight, fontWeight: 600, '&:hover': { backgroundColor: accentDim } },
|
||||
'&.Mui-selected': { backgroundColor: accentDim, color: accentLight, fontWeight: 500, '&:hover': { backgroundColor: accentDim } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -801,7 +819,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
|
|||
},
|
||||
},
|
||||
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain, height: 2, borderRadius: 2 } } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentLight, fontWeight: 600 } } } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentLight, fontWeight: 500 } } } },
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: { backgroundImage: 'none' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue