feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -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)
})
})

View file

@ -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')
})
})

View file

@ -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",

View file

@ -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())
}

View file

@ -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(),
)
}

View file

@ -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(),
)
}

View file

@ -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)
}

View file

@ -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

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/core",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",

View file

@ -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 ||

View file

@ -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
}

View file

@ -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')

View file

@ -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, '')

View file

@ -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",

View file

@ -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

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View file

@ -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"
}

View file

@ -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": "Шаблон можно выбрать при создании документов"
}

View file

@ -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": "เลือกเทมเพลตภายหลังเมื่อสร้างเอกสาร"
}

View file

@ -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"
}

View file

@ -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": "稍後產生文件時再選擇範本"
}

View file

@ -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": "稍后生成文档时再选择模板"
}

View file

@ -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",

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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,

View file

@ -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 (

View file

@ -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>

View file

@ -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'

View file

@ -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>

View file

@ -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>
)

View file

@ -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,

View 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
}

View file

@ -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

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/ui",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT",

View file

@ -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,

View file

@ -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}

View file

@ -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' },