발행일

팔레트에 없는 색은 조용히 안 칠해진다 — 색 클래스 142곳 정리와 대비 감사

팔레트에 없는 색은 조용히 안 칠해진다

안내 문구 하나가 회색으로 떠 있었습니다. 마크업을 보니 text-red-500이 붙어 있어요. 오타도 아니고, 빌드도 통과하고, 콘솔도 조용합니다. 그냥 안 칠해진 겁니다.

이 프로젝트의 토큰 파일은 첫 줄에서 기본 팔레트를 지웁니다.

@theme {
  --color-*: initial;   /* Tailwind 기본 팔레트를 전부 없앤다 */
  --color-primary: #1D4ED8;
  --color-sub1: #E8EEFF;
  ...
}

디자인 시스템 밖의 색을 못 쓰게 하려고 일부러 넣은 줄입니다. 의도대로 동작하는데, 실패하는 방식이 조용합니다. text-red-500은 존재하지 않는 유틸리티라서 CSS가 생성되지 않고, 브라우저는 그 클래스를 그냥 무시하고, 글자는 상속받은 색으로 뜹니다. 에러가 나면 고쳤을 텐데 에러가 안 나요.

훑어봤더니 142곳이었습니다.

죽어 있던 클래스 142곳

잘못 쓴 것바꾼 것
gray-50 · gray-95062gray-100 (팔레트에 100~900만 있음)
blue-*38primary
red-*22rose
yellow-*12amber
green-*8emerald

gray-50이 제일 많은 게 흥미롭습니다. 이건 "없는 색"이 아니라 있는 색의 없는 단계예요. gray-100부터 gray-900까지는 살아 있으니까 gray-500을 쓰던 손이 gray-50도 될 거라고 생각한 겁니다. 옅은 배경을 칠하려던 62곳이 전부 배경 없이 떠 있었어요.

redrose, yellowamber 같은 교체는 색상환에서 가장 가까운 살아 있는 팔레트로 옮긴 것입니다. slate·sky·emerald·amber·rose는 이 프로젝트가 남겨 둔 계열이라 컴파일됩니다.

고치는 것보다 다시 못 들어오게 하는 것

142곳을 고치는 데는 오후 한나절이면 됩니다. 문제는 다음 주에 또 생긴다는 거예요. 새로 합류한 사람도, 예전 코드를 복사한 사람도 text-blue-500을 씁니다. 그게 세상에서 제일 흔한 Tailwind 클래스니까요.

방법장점포기하는 것판단
① 규칙 문서에 적기비용 0이미 적혀 있었고 142곳이 생겼음기각
② 코드 리뷰에서 잡기맥락까지 봄사람이 매번 봐야 하고, 눈에 안 띄는 실패라 리뷰어도 놓침기각
③ eslint 커스텀 플러그인규칙을 코드로 표현템플릿(.html.j2)의 클래스 문자열까지 파싱해야 해서 규칙 하나 값이 큼기각
eslint.shgrep 가드를 붙이고 검출 시 exit 1이미 도는 스크립트에 40줄. 템플릿·JS 같은 방식으로 훑음정규식이라 문자열 조합으로 만든 클래스명은 못 잡음채택
UTILITY='(bg|text|border|ring|fill|stroke|from|via|to|divide|outline|shadow|accent|decoration|placeholder|caret)'
DEAD_COLORS='(blue|red|green|indigo|purple|pink|orange|yellow|teal|cyan|lime|violet|fuchsia|stone|zinc|neutral)'
# 우리 토큰은 단계가 없다. `bg-primary-50` 처럼 숫자를 붙이면 그것도 컴파일되지 않는다.
SCALELESS='(primary|sub[1-4]|warning|section-header|api|hero|tile-deep|footer-link|index-section|translucence)'
# gray 는 100~900 만 존재한다. gray-500 을 오탐하지 않도록 뒤에 숫자가 오면 제외한다.
PALETTE_PATTERN="${UTILITY}-${DEAD_COLORS}-[0-9]{2,3}|${UTILITY}-${SCALELESS}-[0-9]{2,3}|${UTILITY}-gray-(50|950)([^0-9]|$)"

두 종류를 봅니다. 없는 팔레트(text-blue-500)와 있는 토큰에 없는 단계를 붙인 것(bg-primary-500). 후자가 특히 안 보이는데, primary는 실제로 존재하는 토큰이라 이름이 그럴듯하거든요. 숫자를 붙이는 순간 죽습니다.

출력에도 한 가지를 넣었습니다.

  # 전부 쏟으면 방금 넣은 게 묻힌다. 작업 중인 파일로 먼저 좁힌다.
  changed=$(git status --porcelain 2>/dev/null | awk '{print $NF}')

가드가 걸렸을 때 142줄을 쏟아내면 아무도 안 읽습니다. git status로 지금 건드리는 중인 파일만 먼저 보여 주고, 거기 없으면 그때 전체를 냅니다.

팔레트 안에 있는 색도 미달이었다

없는 색을 정리하고 나서 있는 색을 재봤습니다. 회색 스케일이 표준 Tailwind보다 통째로 한 칸 밝았어요.

  • gray-500 — 흰 배경 대비 2.6:1 (본문 텍스트 기준 4.5:1)
  • gray-4001.5:1

gray-500은 "적당히 흐린 글자" 자리에 습관적으로 쓰는 이름입니다. 표준 Tailwind에서는 대체로 통과하는 값이라 그렇게 굳었는데, 이 팔레트에서는 그 이름이 다른 색을 가리키고 있었어요. 이름이 같아도 값이 다르면 지금까지의 감이 안 통합니다.

제일 넓게 퍼진 건 폼이었습니다.

  input:not([type="radio"]):not([type="checkbox"]) {
    /* gray-500 은 2.6:1 이라 입력 경계(비텍스트 3:1)와 자리표시자(텍스트 4.5:1) 둘 다 미달이었다 */
    @apply h-[2.5rem] rounded-lg border border-gray-600 pl-[1.25rem] placeholder-gray-600;

input·textarea·select 세 규칙에서 gray-500gray-600. 세 줄로 사이트 전체의 입력 컨트롤이 해소됐습니다. 원소 선택자로 색을 주는 구조라 화면마다 찾아다닐 필요가 없었어요.

삭제 버튼도 한 줄이었습니다.

    /* 클래스 이름은 warning 이지만 실제로는 삭제·취소 등 파괴적 동작 49곳이 쓴다.
       warning 색(2.76:1)으로는 옆의 primary 버튼과 무게가 안 맞고 테두리도 흐렸다. */
    &.warning {
-     @apply border-warning text-warning;
+     @apply border-error text-error;
    }

49곳이 .warning을 달고 있는데 대부분 OID·용어·데이터·구획 삭제입니다. 제일 되돌리기 어려운 버튼이 제일 흐렸어요.

JS가 같은 클래스를 다시 붙인다

템플릿만 고치고 넘어갈 뻔한 자리가 하나 있었습니다. 목차 사이드바인데, 링크 초기 클래스를 text-gray-600으로 고쳤더니 처음엔 멀쩡하다가 스크롤하면 도로 흐려졌어요.

// 템플릿 3곳(약관·개인정보처리방침·서비스 소개)의 .toc-link 초기 클래스와 같은 값이어야 한다 —
// 여기서 다시 붙이므로 한쪽만 고치면 스크롤 직후 되돌아간다.
const inactiveClass = ['text-gray-600']

tocSidebar.js가 스크롤에 따라 활성 항목을 바꾸면서 비활성 항목에 자기가 들고 있는 클래스를 다시 붙입니다. 같은 값이 템플릿 3곳과 JS 1곳, 네 군데에 있었던 거예요.

정적 마크업만 grep해서는 이런 게 안 나옵니다. 색을 바꿀 때는 그 클래스를 런타임에 다시 붙이는 코드가 있는지를 같이 봐야 하고, 확인은 화면을 실제로 조작해 봐야 나옵니다.

고치지 않기로 한 35곳

폼 검증 문구는 미달인 채로 뒀습니다.

--color-warning: #FF6C6C는 흰 배경에서 2.76:1이고 본문 최소 4.5:1에 못 미칩니다. 그런데 이 프로젝트는 시험 시스템과 화면 정합을 맞추는 게 요구사항이라, 그쪽이 이 색으로 검증 문구를 그리면 여기도 같아야 해요. 필수 표시(*)와 삭제 버튼만 error로 올리고 문구 35곳은 남겼습니다.

"그럼 붉은 배경 박스로 감싸서 대비를 벌면 되지 않나" — 안 됩니다. 재봤어요.

#FF6C6C on #FFFFFF2.76:1
#FF6C6C on #FEF2F22.52:1   (더 나빠짐)

배경을 연한 붉은색으로 깔면 글자와 배경이 같은 색상 쪽으로 가까워져서 대비가 떨어집니다. 흔히 쓰는 회피책인데 이 색 조합에서는 역효과예요.

그래서 토큰 정의 옆에 남는 대가를 적어 뒀습니다.

/* ⚠️ warning 은 흰 배경 대비 2.76:1 이라 본문 최소 4.5:1 에 못 미친다. 정합을 우선한 결정이고
     근거는 docs/scts-design-parity-analysis.md 15절에 있다 — 새 코드에서 따라 하지 말 것. */
--color-warning: #ff6c6c;
--color-error: oklch(0.577 0.245 27.325); /* #E7000B, 4.77:1 — 글자 */

검증

  • 가드를 붙인 상태에서 text-blue-500을 일부러 한 줄 넣고 scripts/eslint.sh 실행 → exit 1과 해당 파일 경로 출력 확인. 레드를 먼저 본 뒤 되돌렸습니다.
  • 142곳 교체 후 가드 재실행 → 검출 0.
  • Playwright로 실제 렌더된 색을 읽어 대비 재측정 — 약관·로그인·문의·메타클래스 트리에서 미달 0.
  • 목차 사이드바를 스크롤해 활성/비활성이 오갈 때 색이 되돌아가지 않는 것 확인. 템플릿만 고쳤을 때 되돌아가는 것도 먼저 재현했습니다.
  • 브라우저에서 실제 값 확인 — 별표 #E7000B, 검증 문구 #FF6C6C, 삭제 버튼 #E7000B.
  • 삭제 버튼이 한 화면에 29개 뜨는 관리 목록에서 위계 확인. 전부 외곽선 버튼이라 색만 진해져도 primary 버튼을 안 이깁니다.

남은 것 · 한계

  • 폼 검증 문구 35곳은 기준 미달로 남았습니다. 시험 시스템 정합을 우선한 결정이고, 그 판단이 뒤집히면 35곳을 다시 손대야 합니다. 지금은 토큰 주석과 문서에만 적혀 있고 화면은 아무 말도 안 해요.
  • 가드는 정규식이라 조합된 클래스명을 못 잡습니다. `text-${color}-500` 같은 템플릿 리터럴이나 파이썬에서 만들어 내려보내는 클래스는 통과합니다. 실제로 물리 아키텍처 안내 문구가 파이썬 코드에 text-red-500을 달고 있어 이 가드에 안 걸렸던 사례가 있습니다.
  • 가드가 도는 곳은 4개 디렉터리뿐입니다. templates·static/js와 물리 아키텍처 두 곳. 파이썬·문서·CSS는 안 봅니다.
  • error-surface·error-border 토큰은 아직 쓰는 곳이 없습니다. v4는 유틸리티로 실제 사용된 토큰만 내보내서 빌드 산출물에 안 나오는데, 이게 고장으로 보일 수 있어 주석에 적어 뒀어요. 재료만 만들어 두고 안 쓰는 상태라, 쓸 자리가 안 생기면 그냥 죽은 정의입니다.
  • 회색 스케일이 한 칸 밝은 것 자체는 안 고쳤습니다. gray-500을 쓰는 곳마다 gray-600으로 올리는 식이라, 다음에 누가 gray-500을 쓰면 또 미달입니다. 스케일 값을 표준에 맞추면 근본이 해결되지만 시험 시스템 정합과 부딪혀서 안 건드렸어요. 같은 실수를 할 자리를 알면서 안 막아둔 자리가 여기입니다.
  • 대비를 재는 게 아직 사람의 일입니다. 회귀로 잠그려면 Playwright에서 계산된 색을 읽어 대비를 단언하는 스펙이 있어야 하는데, 이번엔 측정만 하고 스펙으로 안 남겼습니다.

관련 글: Tailwind v3에서 v4로 170개 파일을 옮겼다 · disabled를 쓰면 "왜 안 되는지"를 말할 수 없다