193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
// 4단계 swatch → 16개 shadcn 토큰 자동 매핑.
|
|
// 명도(luminance) 정렬 + WCAG 대비 보정.
|
|
|
|
import { PALETTES } from "./palettes"
|
|
|
|
// ───────────────────────── 색 유틸 ─────────────────────────
|
|
|
|
type Rgb = readonly [number, number, number]
|
|
|
|
function hexToRgb(hex: string): Rgb {
|
|
const h = hex.replace("#", "")
|
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
|
}
|
|
|
|
function rgbToHex(r: number, g: number, b: number): string {
|
|
const c = (n: number) =>
|
|
Math.max(0, Math.min(255, Math.round(n)))
|
|
.toString(16)
|
|
.padStart(2, "0")
|
|
return "#" + c(r) + c(g) + c(b)
|
|
}
|
|
|
|
// 0~1 범위 luminance (간단형 — WCAG가 아니라 평균 명도)
|
|
function luminance(hex: string): number {
|
|
const [r, g, b] = hexToRgb(hex)
|
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
}
|
|
|
|
// 두 색을 t(0~1) 비율로 섞음
|
|
function mix(a: string, b: string, t: number): string {
|
|
const [r1, g1, b1] = hexToRgb(a)
|
|
const [r2, g2, b2] = hexToRgb(b)
|
|
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
|
|
}
|
|
|
|
// 명도가 minLum 미만이면 흰색 쪽으로 끌어올림
|
|
function ensureLight(hex: string, minLum: number): string {
|
|
const lum = luminance(hex)
|
|
if (lum >= minLum) return hex
|
|
const t = (minLum - lum) / (1 - lum)
|
|
return mix(hex, "#FFFFFF", t)
|
|
}
|
|
|
|
// 명도가 maxLum 초과면 검정 쪽으로 끌어내림
|
|
function ensureDark(hex: string, maxLum: number): string {
|
|
const lum = luminance(hex)
|
|
if (lum <= maxLum) return hex
|
|
const t = (lum - maxLum) / lum
|
|
return mix(hex, "#000000", t)
|
|
}
|
|
|
|
// ───────────────────────── derive ─────────────────────────
|
|
|
|
export type DerivedTokens = {
|
|
background: string
|
|
foreground: string
|
|
card: string
|
|
cardForeground: string
|
|
popover: string
|
|
popoverForeground: string
|
|
primary: string
|
|
primaryForeground: string
|
|
secondary: string
|
|
secondaryForeground: string
|
|
muted: string
|
|
mutedForeground: string
|
|
accent: string
|
|
accentForeground: string
|
|
destructive: string
|
|
border: string
|
|
input: string
|
|
ring: string
|
|
sidebar: string
|
|
sidebarForeground: string
|
|
sidebarPrimary: string
|
|
sidebarPrimaryForeground: string
|
|
sidebarAccent: string
|
|
sidebarAccentForeground: string
|
|
sidebarBorder: string
|
|
sidebarRing: string
|
|
}
|
|
|
|
// 의미색 — 모든 테마 공통
|
|
const DESTRUCTIVE = "oklch(0.577 0.245 27.325)"
|
|
|
|
// 사용자가 선택한 목업 색을 그대로 유지. 명도 정렬로 primary가 차콜로 바뀌지 않게 함.
|
|
const CLEAN_BLUE_TOKENS: DerivedTokens = {
|
|
background: "#FFFFFF",
|
|
foreground: "#242C39",
|
|
card: "#F7F9FC",
|
|
cardForeground: "#242C39",
|
|
popover: "#FFFFFF",
|
|
popoverForeground: "#242C39",
|
|
primary: "#034EA2",
|
|
primaryForeground: "#FFFFFF",
|
|
secondary: "#F0F3F8",
|
|
secondaryForeground: "#242C39",
|
|
muted: "#F7F9FC",
|
|
mutedForeground: "#606D7F",
|
|
accent: "#EDF3FC",
|
|
accentForeground: "#034EA2",
|
|
destructive: DESTRUCTIVE,
|
|
border: "#E1E6EE",
|
|
input: "#BBC7D8",
|
|
ring: "#034EA2",
|
|
sidebar: "#F7F9FC",
|
|
sidebarForeground: "#242C39",
|
|
sidebarPrimary: "#034EA2",
|
|
sidebarPrimaryForeground: "#FFFFFF",
|
|
sidebarAccent: "#EDF3FC",
|
|
sidebarAccentForeground: "#034EA2",
|
|
sidebarBorder: "#E1E6EE",
|
|
sidebarRing: "#034EA2",
|
|
}
|
|
|
|
export function derivePaletteTokens(
|
|
swatch: readonly [string, string, string, string]
|
|
): DerivedTokens {
|
|
// 명도 내림차순 정렬: L0(가장 밝음) → L3(가장 어두움)
|
|
const [L0, L1, L2, L3] = [...swatch].sort((a, b) => luminance(b) - luminance(a))
|
|
|
|
// 배경: 가장 밝은 색을 0.92 이상으로 보정
|
|
const background = ensureLight(L0, 0.92)
|
|
// 카드/팝오버: 배경 + 흰색 30% 보간 (떠 있는 느낌)
|
|
const card = mix(background, "#FFFFFF", 0.3)
|
|
// 본문 텍스트: 가장 어두운 색을 0.20 이하로 보정 (대비 확보)
|
|
const foreground = ensureDark(L3, 0.2)
|
|
|
|
// primary: 가장 어두운 색(L3)부터 채택. 0.45 이하로 다시 보정해서 흰 텍스트 가독성 확보.
|
|
// 채도 우선이 아니라 명도 우선 — 사용자 지시(2026-05-08).
|
|
const primary = ensureDark(L3, 0.45)
|
|
const primaryForeground = "#FAFAFA"
|
|
|
|
// secondary, muted, accent: L1·L2 보간으로 톤 위계 만듦
|
|
const secondary = L1
|
|
const muted = mix(L0, L1, 0.5)
|
|
const mutedForeground = L2
|
|
const accent = mix(L1, L2, 0.3)
|
|
const border = L1
|
|
const sidebar = mix(L0, L1, 0.25)
|
|
|
|
return {
|
|
background,
|
|
foreground,
|
|
card,
|
|
cardForeground: foreground,
|
|
popover: card,
|
|
popoverForeground: foreground,
|
|
primary,
|
|
primaryForeground,
|
|
secondary,
|
|
secondaryForeground: foreground,
|
|
muted,
|
|
mutedForeground,
|
|
accent,
|
|
accentForeground: foreground,
|
|
destructive: DESTRUCTIVE,
|
|
border,
|
|
input: border,
|
|
ring: primary,
|
|
sidebar,
|
|
sidebarForeground: foreground,
|
|
sidebarPrimary: primary,
|
|
sidebarPrimaryForeground: primaryForeground,
|
|
sidebarAccent: accent,
|
|
sidebarAccentForeground: foreground,
|
|
sidebarBorder: border,
|
|
sidebarRing: primary,
|
|
}
|
|
}
|
|
|
|
// ───────────────────────── CSS 생성 ─────────────────────────
|
|
|
|
function paletteToCss(id: string, t: DerivedTokens): string {
|
|
const body = `--background:${t.background};--foreground:${t.foreground};--card:${t.card};--card-foreground:${t.cardForeground};--popover:${t.popover};--popover-foreground:${t.popoverForeground};--primary:${t.primary};--primary-foreground:${t.primaryForeground};--secondary:${t.secondary};--secondary-foreground:${t.secondaryForeground};--muted:${t.muted};--muted-foreground:${t.mutedForeground};--accent:${t.accent};--accent-foreground:${t.accentForeground};--destructive:${t.destructive};--border:${t.border};--input:${t.input};--ring:${t.ring};--sidebar:${t.sidebar};--sidebar-foreground:${t.sidebarForeground};--sidebar-primary:${t.sidebarPrimary};--sidebar-primary-foreground:${t.sidebarPrimaryForeground};--sidebar-accent:${t.sidebarAccent};--sidebar-accent-foreground:${t.sidebarAccentForeground};--sidebar-border:${t.sidebarBorder};--sidebar-ring:${t.sidebarRing};`
|
|
// 1) :not(.dark) — 라이트모드에서 팔레트 토큰 적용. globals.css 의 .dark grayscale 블록은 그대로 살림.
|
|
// 2) .dark .theme-light — 다크모드여도 이 class 붙은 subtree(채팅 버블 영역)만 라이트 토큰 유지.
|
|
// <html>에 data-theme + .dark 둘 다 붙어 있어서 자식 .theme-light 를 이렇게 되살림.
|
|
return `[data-theme="${id}"]:not(.dark){${body}}[data-theme="${id}"].dark .theme-light{${body}}`
|
|
}
|
|
|
|
// 전체 테마 CSS를 한 번에 생성. 모듈 로드 시 1회 실행.
|
|
export const ALL_THEMES_CSS: string = PALETTES.map((p) =>
|
|
paletteToCss(p.id, p.id === "clean-blue" ? CLEAN_BLUE_TOKENS : derivePaletteTokens(p.swatch))
|
|
).join("")
|
|
|
|
// 미리보기 카드용 — id로 derived tokens 조회
|
|
export function getDerivedTokens(id: string): DerivedTokens | null {
|
|
if (id === "clean-blue") return { ...CLEAN_BLUE_TOKENS }
|
|
const p = PALETTES.find((x) => x.id === id)
|
|
return p ? derivePaletteTokens(p.swatch) : null
|
|
}
|