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:
parent
6ba25f53b7
commit
f6a29db95a
13 changed files with 576 additions and 11 deletions
98
apps/desktop/tests/shared/entitlement.test.ts
Normal file
98
apps/desktop/tests/shared/entitlement.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
166
packages/core/src/entitlement.ts
Normal file
166
packages/core/src/entitlement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "이 초대를 수락하려면 먼저 로그인하세요.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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와 동일 철학.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue