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

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