o2o-site-AEO/solution/frontend/src/features/dev/palette.ts
Mina Choi c85c577349 이름: solution/front → solution/frontend
`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
2026-08-31 15:27:16 +09:00

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,
};
}