import {mix} from '@/lib/color'; /** * 팔레트 → 디자인 토큰 유도. * * colorhunt 처럼 **색 4개**만 주어졌을 때, 그걸 화면 전체를 바꿀 수 있는 토큰 한 벌로 편다. * * ★ 색을 역할에 배정하는 규칙이 이 파일의 전부다. 아무 색이나 primary 로 쓰면 * 흰 글씨가 안 보이는 버튼이 나온다 — primary 는 "흰 글씨를 얹을 수 있는 어두운 색" 이라는 * 계약을 지켜야 한다(배리에이션들이 그 전제로 쓰고 있다). */ /** `#rrggbb` 로 정규화. 못 읽으면 null. */ function normalizeHex(raw: string): string | null { const m = raw.trim().replace(/^#/, ''); if (/^[0-9a-fA-F]{3}$/.test(m)) { return `#${m[0]}${m[0]}${m[1]}${m[1]}${m[2]}${m[2]}`.toLowerCase(); } return /^[0-9a-fA-F]{6}$/.test(m) ? `#${m.toLowerCase()}` : null; } function rgb(hex: string): [number, number, number] { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** 상대 휘도(WCAG). 0=검정 1=흰색. 대비 판단의 기준. */ export function luminance(hex: string): number { const [r, g, b] = rgb(hex).map((v) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** HSL 채도. 회색인지 색이 있는지 가른다. */ function saturation(hex: string): number { const [r, g, b] = rgb(hex).map((v) => v / 255); const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max === min) return 0; const l = (max + min) / 2; return l > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min); } /** 본문 크기 글자의 최소 대비(WCAG AA). */ const MIN_CONTRAST = 4.5; /** WCAG 명도 대비비. 4.5 이상이면 본문 크기 글자가 읽힌다. */ export function contrast(a: string, b: string): number { const [hi, lo] = luminance(a) > luminance(b) ? [luminance(a), luminance(b)] : [luminance(b), luminance(a)]; return (hi + 0.05) / (lo + 0.05); } /** 흰 글씨를 얹을 수 있는가. primary·inverse 가 지켜야 하는 조건이다. */ export function carriesWhiteText(hex: string): boolean { return contrast(hex, '#ffffff') >= MIN_CONTRAST; } /** * 대비가 모자라면 될 때까지 어둡게(또는 밝게) 민다. * * ★ 팔레트 사이트의 색은 '예쁜 조합'이지 '읽히는 조합'이 아니다. * 고른 색을 그대로 쓰면 버튼 위 흰 글씨가 안 보이는 팔레트가 절반이다 — * 여기서 최소 대비를 보장한다. 색상(hue)은 유지되고 밝기만 움직인다. */ function ensureContrast(color: string, against: string, min = MIN_CONTRAST): string { const toward = luminance(against) > 0.5 ? '#000000' : '#ffffff'; let out = color; for (let i = 0; i < 24 && contrast(out, against) < min; i++) { out = mix(out, toward, 0.08); } return out; } /** * 입력에서 hex 를 뽑는다. * * 받아들이는 것: * - colorhunt URL `https://colorhunt.co/palette/222831393e46948979dfd0b8` * - `#222831 #393E46 #948979 #DFD0B8` (쉼표·줄바꿈·공백 아무거나) * - `222831,393e46,...` */ export function parsePalette(input: string): string[] { const text = input.trim(); // colorhunt 는 '#' 없이 6자리씩 이어 붙인다(보통 4색=24자). const packed = text.match(/colorhunt\.co\/palette\/([0-9a-fA-F]{12,32})/); if (packed) { const body = packed[1]; const out: string[] = []; for (let i = 0; i + 6 <= body.length; i += 6) { const hex = normalizeHex(body.slice(i, i + 6)); if (hex) out.push(hex); } return out; } const tokens = text.split(/[\s,;|]+/).filter(Boolean); return tokens.map(normalizeHex).filter((h): h is string => h !== null); } export interface DerivedPalette { primary: string; secondary: string; bg: string; card: string; text: string; accent: string; surface: string; surfaceAlt: string; inverse: string; border: string; } /** * 색 목록 → 토큰 한 벌. * * 배정 규칙: * 밝은 순으로 세워서 밝은 쪽은 바탕(surface/card), 어두운 쪽은 글자(text/inverse). * primary 는 **어두운 것 중 가장 색이 있는 것** — 흰 글씨 계약을 지키기 위해서다. * accent 는 밝기와 무관하게 가장 색이 진한 것(배경으로는 안 쓰이므로 자유롭다). */ export function derivePalette(input: string[]): DerivedPalette | null { const hexes = [...new Set(input)]; if (hexes.length < 2) return null; // 밝은 것부터 어두운 것 순. const byLight = [...hexes].sort((a, b) => luminance(b) - luminance(a)); const lightest = byLight[0]; const darkest = byLight[byLight.length - 1]; const second = byLight[1] ?? lightest; // 보조 텍스트·테두리에 쓸 중간 색. 밝은 바탕 위에서 읽혀야 하므로 // 너무 밝으면(0.6 초과) 쓰지 않고 가장 어두운 색으로 떨어뜨린다. const midCandidate = byLight[byLight.length - 2] ?? darkest; const mid = luminance(midCandidate) > 0.6 ? darkest : midCandidate; const accent = [...hexes].sort((a, b) => saturation(b) - saturation(a))[0]; // primary 는 '흰 글씨를 얹는 버튼 색'이다 — 밝기 어림짐작이 아니라 실제 대비로 고른다. const bySat = [...hexes].sort((a, b) => saturation(b) - saturation(a)); const primaryBase = bySat.find(carriesWhiteText) ?? bySat[0] ?? darkest; // 후보가 전부 밝으면(파스텔 팔레트) 대비가 찰 때까지 어둡게 민다. const primary = ensureContrast(primaryBase, '#ffffff'); // ★ 팔레트 4색만으로는 '면 단계'가 부족하다 — 섹션 기본 / 살짝 어두운 면 / 카드 세 단계가 // 서로 구분돼야 섹션이 경계선 없이도 나뉜다. 가장 밝은 색을 기준으로 섞어서 만든다. return { // 섹션 기본 바탕(가장 흔한 톤). 팔레트의 가장 밝은 색을 그대로 쓴다. bg: lightest, // paper 톤 — 기본보다 아주 살짝 어둡게. surface: mix(lightest, darkest, 0.06), // tint 톤 — 두 번째로 밝은 색(팔레트가 2색뿐이면 조금 더 어둡게). surfaceAlt: second === lightest ? mix(lightest, darkest, 0.12) : second, // 카드는 바탕보다 밝아야 떠 보인다. card: mix(lightest, '#ffffff', 0.65), // 본문·보조 텍스트는 바탕 위에서 반드시 읽혀야 한다. text: ensureContrast(darkest, lightest), inverse: darkest, secondary: ensureContrast(mid, lightest), primary, accent, // ★ 테두리를 card 와 같게 두면 카드 경계가 사라진다 — 한 단계 어두운 중간색을 쓴다. border: mid, }; }