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