Initial Commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 팔레트 목록 — 클린 블루의 고정 토큰은 derive.ts에서 정의.
|
||||
// 각 swatch는 스크린샷 위→아래 순서. 실제 shadcn 토큰은 derive.ts에서 자동 생성.
|
||||
// 새 팔레트 추가/수정 시 이 배열만 건드리면 됨.
|
||||
|
||||
export type RawPalette = {
|
||||
id: string
|
||||
name: string
|
||||
swatch: [string, string, string, string]
|
||||
}
|
||||
|
||||
export const PALETTES: RawPalette[] = [
|
||||
{
|
||||
id: "clean-blue",
|
||||
name: "클린 블루",
|
||||
swatch: ["#FFFFFF", "#F7F9FC", "#034EA2", "#242C39"],
|
||||
},
|
||||
// Sheet 1
|
||||
{ id: "dark-teal", name: "Dark Teal", swatch: ["#1A222B", "#2D3540", "#009E9F", "#ECECEC"] },
|
||||
{ id: "mint", name: "Mint", swatch: ["#DEF5F4", "#B8DDD8", "#87C5BF", "#5DAEA9"] },
|
||||
{ id: "steel-navy", name: "Steel Navy", swatch: ["#F5F5F7", "#DCE0E8", "#4A6FA5", "#14253E"] },
|
||||
{ id: "coral-cream", name: "Coral Cream", swatch: ["#FCE9D9", "#FBD2C9", "#FBB1AB", "#FA8B85"] },
|
||||
{ id: "dusty-rose", name: "Dusty Rose", swatch: ["#FFCBC9", "#FBE3E1", "#DDDED7", "#837C99"] },
|
||||
{ id: "warm-tan", name: "Warm Tan", swatch: ["#8B7B68", "#A38F7C", "#C9B8A4", "#EFE0CB"] },
|
||||
{
|
||||
id: "lavender-navy",
|
||||
name: "Lavender Navy",
|
||||
swatch: ["#EDE7F1", "#DBD3F0", "#9E9CC4", "#3F3D6A"],
|
||||
},
|
||||
{ id: "ocean-blue", name: "Ocean Blue", swatch: ["#1B2734", "#114870", "#1F86B6", "#BFDCEB"] },
|
||||
{ id: "steel-slate", name: "Steel Slate", swatch: ["#2D3845", "#4E5B6A", "#8C97A3", "#D7DBE2"] },
|
||||
{ id: "vivid-teal", name: "Vivid Teal", swatch: ["#20D0C7", "#1F2329", "#FF2A55", "#E4E2E0"] },
|
||||
{ id: "sunset", name: "Sunset", swatch: ["#FCE545", "#F4854A", "#BB346E", "#6F215C"] },
|
||||
// Sheet 2
|
||||
{ id: "tropical", name: "Tropical", swatch: ["#F37873", "#F8DA75", "#D8F39E", "#6BCFAF"] },
|
||||
{
|
||||
id: "soft-lavender",
|
||||
name: "Soft Lavender",
|
||||
swatch: ["#B0A8FA", "#A6BFFA", "#D8DCFA", "#DBE2EE"],
|
||||
},
|
||||
{ id: "mocha-cream", name: "Mocha Cream", swatch: ["#B5BEC8", "#ECE2CB", "#C7AC93", "#8B7565"] },
|
||||
{ id: "sky-cream", name: "Sky Cream", swatch: ["#6595B5", "#B7CEDC", "#CFD2D6", "#F1ECDF"] },
|
||||
{ id: "bubblegum", name: "Bubblegum", swatch: ["#B6D9EB", "#B2A3D7", "#F8B5CB", "#FAF1AF"] },
|
||||
{ id: "coral-mint", name: "Coral Mint", swatch: ["#F8B0AC", "#FBE3D9", "#C7E8DD", "#4FB6A8"] },
|
||||
{ id: "nude-tan", name: "Nude Tan", swatch: ["#FBE3CF", "#EDC3AC", "#DAA48E", "#B9876C"] },
|
||||
{ id: "mocha-beige", name: "Mocha Beige", swatch: ["#8E7A65", "#B59E84", "#D7C4AC", "#ECE4D5"] },
|
||||
{ id: "mocha-nude", name: "Mocha Nude", swatch: ["#74564A", "#BD8F7A", "#E0BBA5", "#F2D9C9"] },
|
||||
{ id: "sage", name: "Sage", swatch: ["#ECE6D8", "#B8C5A8", "#D7DDC8", "#6F8364"] },
|
||||
{ id: "vivid-navy", name: "Vivid Navy", swatch: ["#2F4660", "#2EBABE", "#F4F4F6", "#F8285E"] },
|
||||
{ id: "mauve", name: "Mauve", swatch: ["#F8EFE5", "#BC9C9F", "#835C66", "#432F3A"] },
|
||||
// Sheet 3 (2026-05-08 추가)
|
||||
{
|
||||
id: "charcoal-copper",
|
||||
name: "Charcoal Copper",
|
||||
swatch: ["#2C353D", "#3D4C49", "#90704F", "#D2C5AA"],
|
||||
},
|
||||
{
|
||||
id: "pink-mint-tea",
|
||||
name: "Pink Mint Tea",
|
||||
swatch: ["#F8D5D5", "#E0D5D3", "#D8E1DA", "#9CDFD7"],
|
||||
},
|
||||
{ id: "soft-blush", name: "Soft Blush", swatch: ["#FAE0E0", "#EAC8C7", "#DCE5E8", "#B7CCD1"] },
|
||||
{
|
||||
id: "mint-lavender",
|
||||
name: "Mint Lavender",
|
||||
swatch: ["#D7F5F0", "#C5D2EC", "#B5A6E0", "#B58CD3"],
|
||||
},
|
||||
{ id: "steel-cream", name: "Steel Cream", swatch: ["#F1EBDB", "#DDD0BC", "#A4B7C3", "#6F8EA8"] },
|
||||
{ id: "forest-sage", name: "Forest Sage", swatch: ["#E0E9C8", "#9FC195", "#5C9362", "#3A4D35"] },
|
||||
{ id: "mauve-blush", name: "Mauve Blush", swatch: ["#866C6E", "#C4A6A8", "#DEC8C8", "#F2DCDB"] },
|
||||
{ id: "cream-rose", name: "Cream Rose", swatch: ["#F5EEE0", "#F0DDDB", "#E9C7C5", "#C68F88"] },
|
||||
{ id: "sky-whisper", name: "Sky Whisper", swatch: ["#F2F5F4", "#D6E5EF", "#B6D6EB", "#5C9CCF"] },
|
||||
{ id: "slate-sand", name: "Slate Sand", swatch: ["#7C98AC", "#A6B7C2", "#DAD7CF", "#E6DAC1"] },
|
||||
{ id: "coral-plum", name: "Coral Plum", swatch: ["#E47077", "#A75873", "#574460", "#1F4E62"] },
|
||||
{ id: "pale-teal", name: "Pale Teal", swatch: ["#B7CFCB", "#CFE0DC", "#E2EBE6", "#E5E5DC"] },
|
||||
// Sheet 4 (2026-05-08 추가) — Pantone Color of the Year 2021 조합
|
||||
{
|
||||
id: "pantone-2021",
|
||||
name: "Pantone 2021",
|
||||
swatch: ["#939597", "#F2DD52", "#56585A", "#F2A007"],
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_THEME = "clean-blue"
|
||||
|
||||
export const PALETTE_BY_ID: Record<string, RawPalette> = Object.fromEntries(
|
||||
PALETTES.map((p) => [p.id, p])
|
||||
)
|
||||
|
||||
export function isValidThemeId(id: string | undefined | null): id is string {
|
||||
return !!id && id in PALETTE_BY_ID
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { DEFAULT_THEME, isValidThemeId } from "./palettes"
|
||||
|
||||
const STORAGE_KEY = "theme"
|
||||
|
||||
function readDomTheme(): string | null {
|
||||
if (typeof document === "undefined") return null
|
||||
return document.documentElement.dataset.theme ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 팔레트 + 변경 함수.
|
||||
* 변경 시 즉시 DOM(data-theme) + localStorage 동기화.
|
||||
* Vite SPA 환경 — 쿠키/SSR 없음. FOUC 방지는 index.html 인라인 스크립트가 담당.
|
||||
*/
|
||||
export function useTheme() {
|
||||
// 초기값: 인라인 스크립트가 박은 data-theme → localStorage → DEFAULT 순.
|
||||
const [theme, setThemeState] = useState<string>(() => {
|
||||
const dom = readDomTheme()
|
||||
if (isValidThemeId(dom)) return dom
|
||||
if (typeof window !== "undefined") {
|
||||
const ls = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (isValidThemeId(ls)) return ls
|
||||
}
|
||||
return DEFAULT_THEME
|
||||
})
|
||||
|
||||
// 다른 탭에서 팔레트 바꾸면 동기화
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY && isValidThemeId(e.newValue)) {
|
||||
setThemeState(e.newValue)
|
||||
document.documentElement.dataset.theme = e.newValue
|
||||
}
|
||||
}
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => window.removeEventListener("storage", onStorage)
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((id: string) => {
|
||||
if (!isValidThemeId(id)) return
|
||||
setThemeState(id)
|
||||
// 즉시 DOM 반영 — 화면 전체 색이 바뀜
|
||||
document.documentElement.dataset.theme = id
|
||||
// localStorage — 다른 탭과 동기화 + 캐시 + 다음 로드 FOUC 방지
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// 사용 불가 환경이면 무시
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { theme, setTheme }
|
||||
}
|
||||
Reference in New Issue
Block a user