`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로 베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
167 lines
6.6 KiB
TypeScript
167 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|