diff --git a/apps/desktop/tests/shared/entitlement.test.ts b/apps/desktop/tests/shared/entitlement.test.ts new file mode 100644 index 0000000..4b53c2e --- /dev/null +++ b/apps/desktop/tests/shared/entitlement.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + adminRoleAtLeast, + entitlementFeatures, + maxEntitlementTier, + normalizeAdminRole, + normalizeEntitlementTier, + resolveEntitlement, +} from '@d3ro/core/entitlement' +import { Feature } from '@d3ro/core/types' + +describe('entitlement tier normalization', () => { + it('maps cloud and desktop tiers onto the canonical set', () => { + expect(normalizeEntitlementTier('free')).toBe('free') + expect(normalizeEntitlementTier('pro')).toBe('pro') + expect(normalizeEntitlementTier('pro_plus')).toBe('pro_plus') + expect(normalizeEntitlementTier('team')).toBe('pro_plus') + expect(normalizeEntitlementTier('enterprise')).toBe('pro_plus') + }) + + it('falls back to free for unknown values', () => { + expect(normalizeEntitlementTier(undefined)).toBe('free') + expect(normalizeEntitlementTier(42)).toBe('free') + expect(normalizeEntitlementTier('vip')).toBe('free') + }) + + it('picks the higher tier', () => { + expect(maxEntitlementTier('free', 'pro')).toBe('pro') + expect(maxEntitlementTier('pro_plus', 'pro')).toBe('pro_plus') + expect(maxEntitlementTier('free', 'free')).toBe('free') + }) +}) + +describe('admin role normalization', () => { + it('normalizes .NET and Supabase spellings', () => { + expect(normalizeAdminRole('SuperAdmin')).toBe('superadmin') + expect(normalizeAdminRole('super_admin')).toBe('superadmin') + expect(normalizeAdminRole('admin')).toBe('admin') + expect(normalizeAdminRole('Manager')).toBe('manager') + expect(normalizeAdminRole('LegacyDisabled')).toBe('user') + expect(normalizeAdminRole(undefined)).toBe('user') + }) + + it('compares roles by rank', () => { + expect(adminRoleAtLeast('admin', 'manager')).toBe(true) + expect(adminRoleAtLeast('manager', 'admin')).toBe(false) + expect(adminRoleAtLeast('superadmin', 'superadmin')).toBe(true) + }) +}) + +describe('resolveEntitlement', () => { + it('takes the highest paid tier across sources', () => { + const snapshot = resolveEntitlement({ subscriptionTier: 'free', licenseTier: 'pro_plus' }) + expect(snapshot.tier).toBe('pro_plus') + expect(snapshot.licenseTier).toBe('pro_plus') + expect(snapshot.adFree).toBe(true) + expect(snapshot.features).toContain(Feature.TEAM_WORKSPACE) + }) + + it('does not downgrade a paid subscription with a stale free license', () => { + const snapshot = resolveEntitlement({ subscriptionTier: 'pro', licenseTier: 'free' }) + expect(snapshot.tier).toBe('pro') + expect(snapshot.source).toBe('supabase') + }) + + it('collapses an expired period to free', () => { + const snapshot = resolveEntitlement({ + subscriptionTier: 'pro_plus', + expiresAt: Date.now() - 1000, + }) + expect(snapshot.tier).toBe('free') + expect(snapshot.adFree).toBe(false) + expect(snapshot.features).not.toContain(Feature.TEAM_WORKSPACE) + }) + + it('prefers the Supabase role over the .NET role for admin capabilities', () => { + const snapshot = resolveEntitlement({ dotnetRole: 'SuperAdmin', supabaseRole: 'manager' }) + expect(snapshot.adminRole).toBe('manager') + expect(snapshot.adminCapabilities).toEqual({ + canManage: true, + canAdmin: false, + canSuperAdmin: false, + }) + }) + + it('reports no source when nothing is provided', () => { + const snapshot = resolveEntitlement() + expect(snapshot.tier).toBe('free') + expect(snapshot.adminRole).toBe('user') + expect(snapshot.source).toBe('none') + }) + + it('grants the team feature only to paid tiers', () => { + expect(entitlementFeatures('free')).not.toContain(Feature.TEAM_WORKSPACE) + expect(entitlementFeatures('pro')).toContain(Feature.TEAM_WORKSPACE) + expect(entitlementFeatures('free')).toContain(Feature.DICTATION) + }) +}) diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 2861e45..46d7ab4 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -562,6 +562,28 @@ export type Database = { }, Partial<{ accepted_at: string | null; accepted_by: string | null }> > + team_activities: TypedTable< + { + id: string + team_id: string + actor_id: string | null + kind: 'note' | 'member_joined' | 'member_left' | 'invite_created' | 'meeting_shared' | 'document_shared' + body: string | null + metadata: Record + created_at: string + }, + { + team_id: string + actor_id?: string | null + kind: 'note' | 'member_joined' | 'member_left' | 'invite_created' | 'meeting_shared' | 'document_shared' + body?: string | null + metadata?: Record + }, + Partial<{ + body: string | null + metadata: Record + }> + > } Views: Record Functions: { @@ -591,6 +613,22 @@ export type Database = { similarity: number }> } + create_team_activity: { + Args: { + p_team_id: string + p_kind: string + p_body: string | null + p_metadata?: Record + } + Returns: { + id: string + team_id: string + actor_id: string | null + kind: string + body: string | null + created_at: string + } + } consume_quota: { Args: { p_user_id: string diff --git a/packages/core/package.json b/packages/core/package.json index 1cd17b8..678c7bd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,10 @@ "types": "./src/constants.ts", "default": "./src/constants.ts" }, + "./entitlement": { + "types": "./src/entitlement.ts", + "default": "./src/entitlement.ts" + }, "./utils/meeting-markdown": { "types": "./src/utils/meeting-markdown.ts", "default": "./src/utils/meeting-markdown.ts" diff --git a/packages/core/src/entitlement.ts b/packages/core/src/entitlement.ts new file mode 100644 index 0000000..e0b53a6 --- /dev/null +++ b/packages/core/src/entitlement.ts @@ -0,0 +1,166 @@ +// packages/core/src/entitlement.ts +// Single entitlement contract shared by desktop, web, and mobile. +// +// The product currently derives access from three sources: Supabase +// subscriptions (web/mobile/cloud), the desktop Ed25519 offline license, and +// the .NET back-office role. Each source uses a different shape. This module +// defines one canonical snapshot and a resolver that maps all three into it so +// consumers stop re-implementing tier comparisons. + +import type { LicenseTier, Feature } from './types' +import { Feature as FeatureEnum } from './types' + +/** Canonical paid tier. `team`/`enterprise` licenses collapse to `pro_plus`. */ +export type EntitlementTier = 'free' | 'pro' | 'pro_plus' + +/** Administrative role shared by the .NET back office and Supabase profiles. */ +export type AdminRole = 'user' | 'manager' | 'admin' | 'superadmin' + +export type EntitlementSource = 'supabase' | 'desktop_license' | 'dotnet' | 'none' + +export interface EntitlementSnapshot { + tier: EntitlementTier + /** Original 5-value license tier when the source is a desktop license. */ + licenseTier: LicenseTier | null + adminRole: AdminRole + adminCapabilities: { + canManage: boolean + canAdmin: boolean + canSuperAdmin: boolean + } + adFree: boolean + features: readonly Feature[] + source: EntitlementSource + expiresAt: number | null + refreshedAt: number +} + +export interface ResolveEntitlementInput { + subscriptionTier?: unknown + licenseTier?: unknown + dotnetRole?: unknown + supabaseRole?: unknown + expiresAt?: number | null + refreshedAt?: number +} + +const TIER_RANK: Record = { + free: 0, + pro: 1, + pro_plus: 2, +} + +const ADMIN_RANK: Record = { + user: 0, + manager: 1, + admin: 2, + superadmin: 3, +} + +const LOCAL_FEATURES: readonly Feature[] = [ + FeatureEnum.DICTATION, + FeatureEnum.LLM_PROCESS, + FeatureEnum.HISTORY_UNLIMITED, + FeatureEnum.HISTORY_EXPORT, + FeatureEnum.CUSTOM_INSTRUCTION_CREATE, + FeatureEnum.LIVE_CAPTION, + FeatureEnum.SCREEN_CONTEXT, + FeatureEnum.VOICE_MEMO, + FeatureEnum.VOICE_COMMAND, + FeatureEnum.LLM_CHAIN, + FeatureEnum.FILE_TRANSCRIPTION, + FeatureEnum.VOICE_CONVERSATION, + FeatureEnum.DICTATION_TEMPLATE, + FeatureEnum.MEETING_SUMMARY, + FeatureEnum.LOCAL_RAG, + FeatureEnum.OS_AUTOMATION, + FeatureEnum.PREMIUM_LLM, + FeatureEnum.CLOUD_SYNC, +] + +const PAID_FEATURES: readonly Feature[] = [...LOCAL_FEATURES, FeatureEnum.TEAM_WORKSPACE] + +export function entitlementFeatures(tier: EntitlementTier): readonly Feature[] { + return tier === 'free' ? LOCAL_FEATURES : PAID_FEATURES +} + +export function normalizeEntitlementTier(value: unknown): EntitlementTier { + if (typeof value !== 'string') return 'free' + const normalized = value.trim().toLowerCase() + if (normalized === 'pro') return 'pro' + if (normalized === 'pro_plus' || normalized === 'proplus') return 'pro_plus' + // Desktop licenses still carry team/enterprise; collapse them to pro_plus. + if (normalized === 'team' || normalized === 'enterprise') return 'pro_plus' + return 'free' +} + +export function normalizeAdminRole(value: unknown): AdminRole { + if (typeof value !== 'string') return 'user' + const normalized = value.replace(/[_-]/g, '').trim().toLowerCase() + if (normalized === 'superadmin') return 'superadmin' + if (normalized === 'admin') return 'admin' + if (normalized === 'manager') return 'manager' + return 'user' +} + +export function adminRoleAtLeast(role: AdminRole, minimum: AdminRole): boolean { + return ADMIN_RANK[role] >= ADMIN_RANK[minimum] +} + +export function maxEntitlementTier(a: EntitlementTier, b: EntitlementTier): EntitlementTier { + return TIER_RANK[a] >= TIER_RANK[b] ? a : b +} + +function hasValidPeriod(expiresAt: number | null | undefined, now: number): boolean { + if (expiresAt === null || expiresAt === undefined) return true + return expiresAt > now +} + +/** + * Resolve a single entitlement snapshot from any combination of sources. + * + * Precedence: + * - Tier: the highest paid tier across sources wins (a paid subscription is not + * downgraded by a stale free license, and vice versa). + * - Admin role: the Supabase profile role wins over the .NET role. + * - Paid access requires a valid (non-expired) period. + */ +export function resolveEntitlement(input: ResolveEntitlementInput = {}): EntitlementSnapshot { + const now = input.refreshedAt ?? Date.now() + + const subscriptionTier = normalizeEntitlementTier(input.subscriptionTier) + const licenseTier = typeof input.licenseTier === 'string' ? input.licenseTier : null + const normalizedLicenseTier = normalizeEntitlementTier(input.licenseTier) + + const sources: EntitlementSource[] = [] + if (input.subscriptionTier !== undefined && input.subscriptionTier !== null) sources.push('supabase') + if (licenseTier !== null) sources.push('desktop_license') + if (input.dotnetRole !== undefined && input.dotnetRole !== null) sources.push('dotnet') + + const periodValid = hasValidPeriod(input.expiresAt ?? null, now) + // An expired paid period collapses to free regardless of the source label. + const tier: EntitlementTier = periodValid + ? maxEntitlementTier(subscriptionTier, normalizedLicenseTier) + : 'free' + + const adminRole = + input.supabaseRole !== undefined && input.supabaseRole !== null + ? normalizeAdminRole(input.supabaseRole) + : normalizeAdminRole(input.dotnetRole) + + return { + tier, + licenseTier: (licenseTier as LicenseTier | null) ?? null, + adminRole, + adminCapabilities: { + canManage: adminRoleAtLeast(adminRole, 'manager'), + canAdmin: adminRoleAtLeast(adminRole, 'admin'), + canSuperAdmin: adminRole === 'superadmin', + }, + adFree: tier !== 'free' && periodValid, + features: entitlementFeatures(tier), + source: sources.length > 0 ? sources[0] : 'none', + expiresAt: input.expiresAt ?? null, + refreshedAt: now, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 43b4e26..fe45a56 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from './types' export * from './errors' export * from './ipc-channels' export * from './constants' +export * from './entitlement' export * from './utils/crypto-license' export * from './utils/pii-redactor' export * from './utils/secure-memory' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1626b2e..9847adf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -323,6 +323,12 @@ export interface LLMModel { export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom' | 'chain' +/** + * LLM 후처리 없이 원본 전사를 그대로 삽입하는 선택 값. + * `defaultLLMAction`은 사용자가 후처리를 끌 수 있어야 하므로 `'none'`을 포함한다. + */ +export type LLMActionSelection = LLMAction | 'none' + export interface LLMProcessParams { text: string action: LLMAction @@ -444,7 +450,7 @@ export interface AppConfig { * 'realtime' — OpenAI Realtime API (gpt-realtime-2.1, WebRTC 직결, 로그인+구독 필요) */ conversationBackend: 'local' | 'realtime' - defaultLLMAction: LLMAction + defaultLLMAction: LLMActionSelection dictationShortcut: HotkeyBinding handsFreeShortcut: HotkeyBinding commandShortcut: HotkeyBinding @@ -488,6 +494,12 @@ export interface AppConfig { activeChainId: string | null /** Phase 10.1: Caption audio source (CaptionService, MeetingModeService) */ captionAudioSource: import('@d3ro/core/types').CaptionAudioSource + /** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */ + updateChannel: 'latest' | 'beta' | 'alpha' + /** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */ + updateDeviceId: string + /** 사용자가 건너뛴 버전 (강제 업데이트에는 적용되지 않음) */ + skippedUpdateVersion: string | null } export interface ConfigGetParams { @@ -1587,10 +1599,12 @@ export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx' export interface MeetingExportDocParams { documentId: string format: MeetingExportFormat + targetPath?: string } export interface MeetingExportTranscriptParams { sessionId: string + targetPath?: string } export interface MeetingDocGeneratingProgress { @@ -1682,6 +1696,8 @@ export interface AdNetworkConfig { adUnitId?: string appKey?: string apiSecret?: string + /** Decision/reporting endpoint for REST-based adapters (house/direct sponsors). */ + endpointUrl?: string adapterType: 'rest_json' | 'in_app_bidding' | 'header_bidding_ssp' | 'rewarded_video_sdk' | 'direct_house' } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index b1724e0..9d64a8c 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -62,6 +62,12 @@ "dictionary.noResults": "No results found", "dictionary.noWords": "No words — add custom words to improve STT accuracy", "dictionary.used": "Used {{count}} times", + "dictionary.export": "Export", + "dictionary.import": "Import", + "dictionary.exported": "Dictionary exported.", + "dictionary.exportFailed": "Dictionary export failed.", + "dictionary.importedCount": "Imported {{imported}}, skipped {{skipped}}, errors {{errors}}.", + "dictionary.importFailed": "Dictionary import failed.", "dictionary.editTitle": "Edit Word", "dictionary.addTitle": "Add Word", "dictionary.word": "Word", @@ -1390,6 +1396,12 @@ "mobile.teams.deleteTitle": "Permanently delete team", "mobile.teams.deleteBody": "This permanently deletes the team, invitations, and memberships. Shared meetings will be detached. This cannot be undone.", "mobile.teams.realtimeWaiting": "Waiting for realtime sync. Pull down to verify the latest server state.", + "mobile.teams.activity": "Activity", + "mobile.teams.activityPlaceholder": "Leave a note for the team...", + "mobile.teams.post": "Post", + "mobile.teams.posting": "Posting...", + "mobile.teams.noActivity": "No activity yet.", + "mobile.teams.system": "System", "mobile.teams.acceptTitle": "Team invitation", "mobile.teams.inviteLoading": "Checking invitation link", "mobile.teams.loginToAccept": "Sign in before accepting this invitation.", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 58caf25..416a093 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -70,6 +70,12 @@ "dictionary.noResults": "검색 결과 없음", "dictionary.noWords": "단어 없음 — STT 정확도 향상을 위해 커스텀 단어를 추가하세요", "dictionary.used": "{{count}}회 사용", + "dictionary.export": "내보내기", + "dictionary.import": "가져오기", + "dictionary.exported": "사전을 내보냈습니다.", + "dictionary.exportFailed": "사전 내보내기에 실패했습니다.", + "dictionary.importedCount": "가져오기 {{imported}}건, 건너뜀 {{skipped}}건, 오류 {{errors}}건.", + "dictionary.importFailed": "사전 가져오기에 실패했습니다.", "dictionary.editTitle": "단어 편집", "dictionary.addTitle": "단어 추가", "dictionary.word": "단어", @@ -1397,6 +1403,12 @@ "mobile.teams.deleteTitle": "팀 영구 삭제", "mobile.teams.deleteBody": "팀과 초대, 멤버 연결을 영구 삭제합니다. 공유 회의의 팀 연결도 해제됩니다. 이 작업은 되돌릴 수 없습니다.", "mobile.teams.realtimeWaiting": "실시간 동기화 연결을 기다리는 중입니다. 아래로 당겨 최신 상태를 확인할 수 있습니다.", + "mobile.teams.activity": "활동", + "mobile.teams.activityPlaceholder": "팀에 메모를 남겨보세요...", + "mobile.teams.post": "등록", + "mobile.teams.posting": "등록 중...", + "mobile.teams.noActivity": "아직 활동이 없습니다.", + "mobile.teams.system": "시스템", "mobile.teams.acceptTitle": "팀 초대", "mobile.teams.inviteLoading": "초대 링크를 확인하는 중", "mobile.teams.loginToAccept": "이 초대를 수락하려면 먼저 로그인하세요.", diff --git a/packages/ui-native/src/components/MetalCard.tsx b/packages/ui-native/src/components/MetalCard.tsx index dd7c774..7c2adae 100644 --- a/packages/ui-native/src/components/MetalCard.tsx +++ b/packages/ui-native/src/components/MetalCard.tsx @@ -20,7 +20,7 @@ export function MetalCard({ children, inset = false, style, ...rest }: MetalCard borderColor: palette.border.subtle, overflow: 'hidden', // RN 그림자 - shadowColor: palette.shadow ?? '#000', + shadowColor: palette.shadow, shadowOffset: { width: 0, height: 4 }, shadowOpacity: inset ? 0 : 0.25, shadowRadius: inset ? 0 : 8, diff --git a/packages/ui-native/src/index.ts b/packages/ui-native/src/index.ts index 1097b8f..02b9962 100644 --- a/packages/ui-native/src/index.ts +++ b/packages/ui-native/src/index.ts @@ -1,5 +1,5 @@ // packages/ui-native — RN DS barrel -// apps/mobile file: dependency +// Consumed by the React Native app via file: workspace dependencies. export { d3roNativePalette, diff --git a/packages/ui-native/src/theme.ts b/packages/ui-native/src/theme.ts index 7f33a98..9126fb3 100644 --- a/packages/ui-native/src/theme.ts +++ b/packages/ui-native/src/theme.ts @@ -44,6 +44,17 @@ export interface D3roNativePalette { green: string blue: string } + /** v3: 시맨틱 상태 역할 (액센트와 분리) */ + status: { + success: string + successBg: string + warning: string + warningBg: string + danger: string + dangerBg: string + info: string + infoBg: string + } led: { off: string } shadow?: string } @@ -87,9 +98,37 @@ export const d3roNativePalette: D3roNativePalette = { green: '#22c55e', blue: '#3b82f6' }, + status: { + success: '#4ade80', + successBg: 'rgba(74, 222, 128, 0.14)', + warning: '#fbbf24', + warningBg: 'rgba(251, 191, 36, 0.14)', + danger: '#f87171', + dangerBg: 'rgba(248, 113, 113, 0.14)', + info: '#60a5fa', + infoBg: 'rgba(96, 165, 250, 0.14)' + }, led: { off: '#111111' - } + }, + shadow: '#000000' +} as const + +// ── v3: 간격·컨트롤·웨이트·모션 (RN은 px 숫자) ── +export const d3roNativeSpace = { + s1: 4, s2: 8, s3: 12, s4: 16, s5: 24, s6: 40, s7: 64, s8: 96 +} as const + +export const d3roNativeControl = { + h: 44, hSm: 36, padX: 16, padXSm: 10 +} as const + +export const d3roNativeWeight = { + body: '400' as const, medium: '500' as const, strong: '600' as const +} as const + +export const d3roNativeMotion = { + instant: 100, quick: 200, normal: 350, slow: 600 } as const // v3 "타이포그래피 퍼스트" — packages/ui d3roTypo v3와 동일 철학. diff --git a/packages/ui/src/components/ds/PhysicalButton.tsx b/packages/ui/src/components/ds/PhysicalButton.tsx index bdfce0b..7c2cc55 100644 --- a/packages/ui/src/components/ds/PhysicalButton.tsx +++ b/packages/ui/src/components/ds/PhysicalButton.tsx @@ -102,8 +102,8 @@ export function PhysicalButton({ width: 24, height: 24, borderRadius: '50%', - bgcolor: isAccent ? 'rgba(255, 255, 255, 0.18)' : d3roPalette.bg.inset, - border: `1px solid ${isAccent ? 'rgba(255, 255, 255, 0.2)' : d3roPalette.border.subtle}`, + bgcolor: isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.bg.inset, + border: `1px solid ${isAccent ? 'var(--d3-overlay-strong)' : d3roPalette.border.subtle}`, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 91fd3c8..088d0c8 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -142,7 +142,7 @@ const RAW: Record = { specular: 'rgba(180,205,255,0.22)', }, gradient: { - accent: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)', + accent: 'linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%)', logo: 'linear-gradient(90deg, #60a5fa 0%, #818cf8 100%)', bar: 'linear-gradient(90deg, #60a5fa 0%, #3b82f6 100%)', barAlt: 'linear-gradient(90deg, #818cf8 0%, #a78bfa 100%)', @@ -183,7 +183,7 @@ const RAW: Record = { specular: 'rgba(255,255,255,0.50)', }, gradient: { - accent: 'linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%)', + accent: 'linear-gradient(135deg, #1e40af 0%, #1d4ed8 55%, #2563eb 100%)', logo: 'linear-gradient(90deg, #2563eb 0%, #6366f1 100%)', bar: 'linear-gradient(90deg, #06b6d4 0%, #2563eb 100%)', barAlt: 'linear-gradient(90deg, #7c3aed 0%, #c026d3 100%)', @@ -265,7 +265,7 @@ const RAW: Record = { specular: 'rgba(238,232,213,0.22)', }, gradient: { - accent: 'linear-gradient(135deg, #8a6800 0%, #b58900 55%, #d4a017 100%)', + accent: 'linear-gradient(135deg, #8a6800 0%, #9c7600 55%, #b58900 100%)', logo: 'linear-gradient(90deg, #d4a017 0%, #cb4b16 100%)', bar: 'linear-gradient(90deg, #2aa198 0%, #268bd2 100%)', barAlt: 'linear-gradient(90deg, #6c71c4 0%, #d33682 100%)', @@ -384,6 +384,8 @@ export const d3roPalette = { dark: 'var(--d3-accent-dark)', dim: 'var(--d3-accent-dim)', glow: 'var(--d3-accent-glow)', + /** v3: 액센트 배경 위 글자색 */ + ink: 'var(--d3-accent-ink)', }, /** v2: 글래스 표면 토큰 */ glass: { @@ -414,6 +416,26 @@ export const d3roPalette = { card: 'var(--d3-glow-card)', cardHover: 'var(--d3-glow-cardHover)', }, + /** v3: 시맨틱 상태 역할 (테마 반응) */ + status: { + success: 'var(--d3-status-success)', + successBg: 'var(--d3-status-successBg)', + warning: 'var(--d3-status-warning)', + warningBg: 'var(--d3-status-warningBg)', + danger: 'var(--d3-status-danger)', + dangerBg: 'var(--d3-status-dangerBg)', + info: 'var(--d3-status-info)', + infoBg: 'var(--d3-status-infoBg)', + }, + /** v3: 반투명 표면·오버레이·스크림 역할 */ + surface: { + insetSoft: 'var(--d3-bg-inset-soft)', + cardSoft: 'var(--d3-bg-card-soft)', + raisedSoft: 'var(--d3-bg-raised-soft)', + scrim: 'var(--d3-scrim)', + overlaySoft: 'var(--d3-overlay-soft)', + overlayStrong: 'var(--d3-overlay-strong)', + }, tag: { purple: '#8b5cf6', purpleBg: 'rgba(139, 92, 246, 0.12)', @@ -430,6 +452,9 @@ export const d3roPalette = { orangeGlow: 'rgba(245, 158, 11, 0.35)', purpleGlow: 'rgba(139, 92, 246, 0.35)', blueGlow: 'rgba(59, 130, 246, 0.35)', + cyan: '#06b6d4', + cyanBg: 'rgba(6, 182, 212, 0.12)', + cyanGlow: 'rgba(6, 182, 212, 0.35)', }, text: { primary: 'var(--d3-text-primary)', @@ -550,9 +575,121 @@ export const d3roRadius = { pill: '999px', } as const +// ── SSOT: 간격 리듬 (4px 그리드) ───────────────────────── +// dark-instrument: 밀도를 높이되 정렬을 완벽하게. 중간값을 즉석 생성하지 않는다. +export const d3roSpace = { + s1: '4px', + s2: '8px', + s3: '12px', + s4: '16px', + s5: '24px', + s6: '40px', + s7: '64px', + s8: '96px', +} as const + +// ── SSOT: 컨트롤 치수 (버튼처럼 생긴 것은 전부 같은 치수) ── +// 같은 역할의 컴포넌트는 이 값을 공유한다. 다른 밀도·위험도는 별도 역할로 설명. +export const d3roControl = { + h: '40px', + hSm: '32px', + padX: '16px', + padXSm: '10px', +} as const + +// ── SSOT: 웨이트는 세 개 ───────────────────────────────── +// 가변폰트 중간값(560/620…)을 쓰기 시작하면 폴백 폰트에서 힌팅이 흐려진다. +// 위계는 웨이트가 아니라 크기·명도가 만든다. +export const d3roWeight = { + body: 400, + medium: 500, + strong: 600, +} as const + +// ── SSOT: 모션 문법 (컴포넌트마다 다른 duration 금지) ── +// transform·opacity를 기본으로. 실시간 수치 변화는 위치 이동이 아니라 색·굵기로. +export const d3roMotion = { + instant: '100ms', + quick: '200ms', + normal: '350ms', + slow: '600ms', + easeOut: 'cubic-bezier(0.22, 1, 0.36, 1)', + easeIn: 'cubic-bezier(0.64, 0, 0.78, 0)', + easeSoft: 'cubic-bezier(0.4, 0, 0.2, 1)', +} as const + +/** 숫자 정렬 계약 — 수치가 흔들리면 계기판이 아니다. */ +export const d3roNumeralSx = { + fontVariantNumeric: 'tabular-nums' as const, +} as const + +// ── SSOT: 시맨틱 상태 역할 (액센트와 분리 — 기능 우선) ── +// 상태색은 액센트에서 파생하지 않는다. 다크는 밝은 상태색, 라이트는 어두운 상태색. +interface StatusRole { + success: string + successBg: string + warning: string + warningBg: string + danger: string + dangerBg: string + info: string + infoBg: string +} +const STATUS: Record = { + dark: { + success: '#34d399', successBg: 'rgba(52,211,153,0.12)', + warning: '#fbbf24', warningBg: 'rgba(251,191,36,0.12)', + danger: '#f87171', dangerBg: 'rgba(248,113,113,0.12)', + info: '#60a5fa', infoBg: 'rgba(96,165,250,0.12)', + }, + light: { + success: '#047857', successBg: 'rgba(4,120,87,0.10)', + warning: '#b45309', warningBg: 'rgba(180,83,9,0.10)', + danger: '#b91c1c', dangerBg: 'rgba(185,28,28,0.10)', + info: '#1d4ed8', infoBg: 'rgba(29,78,216,0.10)', + }, + nord: { + success: '#a3be8c', successBg: 'rgba(163,190,140,0.14)', + warning: '#ebcb8b', warningBg: 'rgba(235,203,139,0.14)', + danger: '#bf616a', dangerBg: 'rgba(191,97,106,0.14)', + info: '#88c0d0', infoBg: 'rgba(136,192,208,0.14)', + }, + solarized: { + success: '#859900', successBg: 'rgba(133,153,0,0.16)', + warning: '#b58900', warningBg: 'rgba(181,137,0,0.16)', + danger: '#dc322f', dangerBg: 'rgba(220,50,47,0.16)', + info: '#268bd2', infoBg: 'rgba(38,139,210,0.16)', + }, + catppuccin: { + success: '#a6e3a1', successBg: 'rgba(166,227,161,0.14)', + warning: '#f9e2af', warningBg: 'rgba(249,226,175,0.14)', + danger: '#f38ba8', dangerBg: 'rgba(243,139,168,0.14)', + info: '#89b4fa', infoBg: 'rgba(137,180,250,0.14)', + }, + dracula: { + success: '#50fa7b', successBg: 'rgba(80,250,123,0.14)', + warning: '#f1fa8c', warningBg: 'rgba(241,250,140,0.14)', + danger: '#ff5555', dangerBg: 'rgba(255,85,85,0.14)', + info: '#8be9fd', infoBg: 'rgba(139,233,253,0.14)', + }, +} + +// ── SSOT: 액센트 위 글자색 ────────────────────────────── +// 액센트 배경(버튼/배지) 위 텍스트. 테마의 액센트 명도에 따라 흰/검을 정한다. +// 밝은 액센트(nord/catppuccin/dracula)에는 어두운 잉크, 어두운 액센트에는 흰 잉크. +const ACCENT_INK: Record = { + dark: '#ffffff', + light: '#ffffff', + nord: '#2e3440', + solarized: '#ffffff', + catppuccin: '#11111b', + dracula: '#1e2029', +} + // ── CSS Custom Properties 생성 ────────────────────────── function buildCssVars(key: ThemeKey): Record { const r = RAW[key] + const st = STATUS[key] return { '--d3-bg-app': r.bg.app, '--d3-bg-card': r.bg.card, @@ -612,12 +749,53 @@ function buildCssVars(key: ThemeKey): Record { '--d3-glow-soft': r.glow.soft, '--d3-glow-card': r.glow.card, '--d3-glow-cardHover': r.glow.cardHover, + // v3: 시맨틱 상태 역할 (액센트와 분리) + '--d3-status-success': st.success, + '--d3-status-successBg': st.successBg, + '--d3-status-warning': st.warning, + '--d3-status-warningBg': st.warningBg, + '--d3-status-danger': st.danger, + '--d3-status-dangerBg': st.dangerBg, + '--d3-status-info': st.info, + '--d3-status-infoBg': st.infoBg, + // v3: 간격·컨트롤·웨이트·모션 + '--d3-space-1': d3roSpace.s1, + '--d3-space-2': d3roSpace.s2, + '--d3-space-3': d3roSpace.s3, + '--d3-space-4': d3roSpace.s4, + '--d3-space-5': d3roSpace.s5, + '--d3-space-6': d3roSpace.s6, + '--d3-space-7': d3roSpace.s7, + '--d3-space-8': d3roSpace.s8, + '--d3-control-h': d3roControl.h, + '--d3-control-h-sm': d3roControl.hSm, + '--d3-control-pad-x': d3roControl.padX, + '--d3-weight-body': String(d3roWeight.body), + '--d3-weight-medium': String(d3roWeight.medium), + '--d3-weight-strong': String(d3roWeight.strong), + '--d3-dur-instant': d3roMotion.instant, + '--d3-dur-quick': d3roMotion.quick, + '--d3-dur-normal': d3roMotion.normal, + '--d3-dur-slow': d3roMotion.slow, + '--d3-ease-out': d3roMotion.easeOut, + '--d3-ease-in': d3roMotion.easeIn, + '--d3-ease-soft': d3roMotion.easeSoft, // 시맨틱 태그색 (테마 불변) — 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, + '--d3-tag-cyan': d3roPalette.tag.cyan, + '--d3-tag-cyanBg': d3roPalette.tag.cyanBg, + '--d3-accent-ink': ACCENT_INK[key], + // v3: 반투명 표면·오버레이·스크림 (테마 파생) + '--d3-bg-inset-soft': 'color-mix(in srgb, var(--d3-bg-inset) 70%, transparent)', + '--d3-bg-card-soft': 'color-mix(in srgb, var(--d3-bg-card) 70%, transparent)', + '--d3-bg-raised-soft': 'color-mix(in srgb, var(--d3-bg-elevated) 80%, transparent)', + '--d3-scrim': 'rgba(0,0,0,0.6)', + '--d3-overlay-soft': 'rgba(255,255,255,0.06)', + '--d3-overlay-strong': 'rgba(255,255,255,0.12)', } } @@ -633,11 +811,12 @@ function createD3ROTheme(key: ThemeKey): Theme { const accentDim = r.accent.dim const accentLight = r.accent.light const accentDark = r.accent.dark + const accentInk = ACCENT_INK[key] return createTheme({ palette: { mode: muiMode, - primary: { main: accentMain, light: accentLight, dark: accentDark, contrastText: '#fff' }, + primary: { main: accentMain, light: accentLight, dark: accentDark, contrastText: accentInk }, secondary: { main: d3roPalette.tag.purple, light: '#c4b5fd', dark: '#8b5cf6' }, error: { main: d3roPalette.tag.red }, warning: { main: d3roPalette.tag.orange }, @@ -678,7 +857,7 @@ function createD3ROTheme(key: ThemeKey): Theme { transition: 'filter 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease', }, containedPrimary: { - color: '#fff', + color: accentInk, backgroundImage: 'var(--d3-gradient-accent)', boxShadow: 'var(--d3-glow-accent)', '&:hover': { filter: 'brightness(1.1)', boxShadow: 'var(--d3-glow-accent)' },