feat(shared): gate paid features from one entitlement source

Desktop, web, mobile, and the API each decided locally what a tier could do,
so a plan change could unlock a feature on one surface and not another.
Entitlement checks now live in `@d3ro/core` and are exercised by tests.

The shared theme and design-system packages also gain the tokens the new
surfaces consume, and the api-client exposes the dictionary and team types
the clients now send.
This commit is contained in:
Yun Chan 2026-09-16 23:24:18 +09:00
parent 6ba25f53b7
commit f6a29db95a
13 changed files with 576 additions and 11 deletions

View file

@ -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<string, unknown>
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<string, unknown>
},
Partial<{
body: string | null
metadata: Record<string, unknown>
}>
>
}
Views: Record<string, never>
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<string, unknown>
}
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

View file

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

View file

@ -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<EntitlementTier, number> = {
free: 0,
pro: 1,
pro_plus: 2,
}
const ADMIN_RANK: Record<AdminRole, number> = {
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,
}
}

View file

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

View file

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

View file

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

View file

@ -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": "이 초대를 수락하려면 먼저 로그인하세요.",

View file

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

View file

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

View file

@ -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와 동일 철학.

View file

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

View file

@ -142,7 +142,7 @@ const RAW: Record<ThemeKey, RawTheme> = {
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<ThemeKey, RawTheme> = {
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<ThemeKey, RawTheme> = {
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<ThemeKey, StatusRole> = {
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<ThemeKey, string> = {
dark: '#ffffff',
light: '#ffffff',
nord: '#2e3440',
solarized: '#ffffff',
catppuccin: '#11111b',
dracula: '#1e2029',
}
// ── CSS Custom Properties 생성 ──────────────────────────
function buildCssVars(key: ThemeKey): Record<string, string> {
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<string, string> {
'--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)' },