From d661c176d6f6f34f29f6e3cb5a1bb10b111e38dd Mon Sep 17 00:00:00 2001 From: LESANF Date: Tue, 22 Sep 2026 09:48:40 +0900 Subject: [PATCH] feat(styles): primitive color layer and JS color guardrails colors.css now holds the brand primitives under @theme static and registers the semantic tokens; semantic.css light/dark values reference primitives only. A new app changes the primitives and nothing else. @theme static keeps variables that only JS reads in the bundle, so the tab bar drops its fallback hex. Tab label colors become tokens read with useCSSVariable so they follow the theme. ESLint now fails on hex literals and Uniwind.getCSSVariable under src/; remaining example screens are listed in LEGACY_HEX_FILES. The prompt hook routes color questions to docs/colors-in-js.md, which records the decision, the light-only variant, and the rejected alternatives. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/route.mjs | 4 + CHANGELOG.md | 8 ++ docs/colors-in-js.md | 83 +++++++++++++++++++ docs/ui.md | 7 +- eslint.config.js | 32 +++++++ src/app/(tabs)/_layout.tsx | 6 +- .../navigation/custom-tabs-layout.tsx | 16 +--- src/components/navigation/tab-button.tsx | 6 +- src/constants/tab-bar.ts | 6 +- src/styles/tokens/colors.css | 53 ++++++++---- src/styles/tokens/semantic.css | 52 ++++++------ 11 files changed, 208 insertions(+), 65 deletions(-) create mode 100644 docs/colors-in-js.md diff --git a/.claude/hooks/route.mjs b/.claude/hooks/route.mjs index 3e42df7..b0d5841 100755 --- a/.claude/hooks/route.mjs +++ b/.claude/hooks/route.mjs @@ -112,6 +112,10 @@ const ROUTES = [ ? 'SDK 업그레이드 → `upgrading-expo` 스킬 + **expo MCP** 로 해당 버전 문서 확인.' : 'SDK 업그레이드 → `npx expo install --check` 로 시작하고 해당 버전 문서를 **expo MCP** 로 확인한다.', }, + { + re: /색상?|컬러|colou?r|hex|(디자인|색상?|컬러)\s*토큰|design\s*token|테마|theme|다크\s*모드|css\.create|useCSSVariable|getCSSVariable/i, + say: '색 → 값은 `src/styles/tokens` CSS 에만 둔다. 정적인 곳은 className, JS 값은 `useCSSVariable` 배열 읽기. hex 직접 기입·`getCSSVariable`·생성 스크립트는 거부된 대안이다(ESLint error) — `docs/colors-in-js.md`.', + }, ]; const EXPO_RE = /\bexpo\b|expo-router|expo-\w+|EAS\b/i; diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ade08..64cd078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ ## [Unreleased] +### Changed + +- **색 토큰을 원시색 층으로 분리** — `colors.css` 는 `@theme static` 의 원시색(`--color-gray-200` …)과 + 의미 토큰 등록, `semantic.css` 의 light/dark 값은 원시색 참조만. 새 앱은 원시색만 바꾼다. + 탭 라벨 색이 `--color-tab-active`·`--color-tab-inactive` 토큰이 됐다(값은 그대로) +- **JS 에서 hex 리터럴과 `Uniwind.getCSSVariable` 을 ESLint error 로 막는다.** JS 색은 + `useCSSVariable` 배열 읽기. 남은 예시 화면은 `LEGACY_HEX_FILES` (docs/colors-in-js.md) + ## [0.0.9] — 2026-09-17 ### Added diff --git a/docs/colors-in-js.md b/docs/colors-in-js.md new file mode 100644 index 0000000..1ed38fe --- /dev/null +++ b/docs/colors-in-js.md @@ -0,0 +1,83 @@ +# 색을 JS에서 쓸 때 — CSS 토큰 하나에서 읽는다 + +> `docs/ui.md` "Single source of truth = CSS `@theme`" 와 같은 결정이다. 이 문서는 그 결정을 +> JS 쪽까지 밀어붙인 것이다. carhartt-kr-app 에서 검증한 방식(2026-09-21)을 이 템플릿의 다크 모드에 +> 맞게 옮겼다. + +## 결정 + +색 값은 `src/styles/tokens/*.css` 에만 있다. JS 는 값을 복사하지 않고 **변수 이름으로 읽는다**. + +| 하려는 것 | 쓸 것 | +| --------------------------------------------------- | ------------------------------------------------------------------------------- | +| 한 번 그리고 마는 배경·글자·테두리 | `className` 토큰 (`bg-primary`, `text-muted-foreground`) | +| SVG 채우기, 로띠 `colorFilters`, 라이브러리 색 프롭 | `useCSSVariable(['--color-a', '--color-b'])` — 필요한 변수를 **배열로 한 번에** | +| `css.create`·워클릿처럼 모듈 수준에서 색이 필요 | 테마별 스타일을 둘 다 만들고 `useUniwind().theme` 으로 고른다 | +| 새 색 | `colors.css` 원시색 → `semantic.css` 참조 → 쓰는 쪽 | + +```ts +// 탭 바 — src/components/navigation/tab-button.tsx +const [active, inactive] = useCSSVariable(TAB_BAR_COLOR_VARS); +const color = String(isFocused ? active : inactive); +``` + +`useCSSVariable` 은 컴포넌트마다 테마 구독 하나를 갖는다. JS 에서 색을 읽는 곳은 탭 바·로띠·SVG +정도라 앱 전체에 몇 곳뿐이고, 그 비용은 잴 수 없다. 대신 다크 전환을 따라간다. + +## 토큰 파일 구조 + +``` +colors.css @theme static — 원시색(--color-gray-200 …) + 의미 토큰 등록·light 기본값 +semantic.css @layer theme — light / dark 두 벌. 값은 원시색 참조만 +``` + +- **원시색은 `colors.css` 에만 적는다.** 새 앱은 이 값을 브랜드 팔레트로 바꾸고 `semantic.css` 의 + 참조만 조정한다. 의미 토큰에 hex 를 바로 적지 않는다. +- **`@theme static`** 이어야 className 에서 안 쓰는 변수도 번들에 남는다. 일반 `@theme` 는 빌드에서 + 빼 버려서 JS 에서만 읽는 색이 `undefined` 가 된다. 그래서 JS 쪽에 폴백 hex 를 둘 이유도 없다. + +## 라이트 고정 앱으로 만들 때 + +`userInterfaceStyle: 'light'` 로 고정하고 `semantic.css` 의 `dark` 블록과 `lib/theme/selected-theme.ts` +를 지우는 앱이라면, 값이 한 벌뿐이라 **시작할 때 한 번 읽어 문자열로 들고 있어도 된다**. + +```ts +// src/constants/colors.ts — 라이트 고정 앱에서만 +import '@/global.css'; // 먼저 평가돼야 변수가 채워진다 + +import { Uniwind } from 'uniwind'; + +const read = (name: string) => Uniwind.getCSSVariable(name) as string; +export const colors = { + foreground: read('--color-foreground'), + border: read('--color-border'), +} as const; +``` + +이러면 구독도 리렌더도 없고 워클릿·`css.create` 에서도 그냥 문자열로 쓴다. 그 앱에서는 ESLint 의 +`GET_CSS_VARIABLE` 규칙을 `colors.ts` 에서 풀고, 반대로 `useCSSVariable` import 를 막는다. +**다크 모드를 유지하는 앱에서는 이 방식을 쓰지 않는다** — 시작 테마의 값이 고정돼 탭 바만 흰색으로 +남는 식의 불일치가 난다. carhartt-kr-app 의 `docs/ui.md` 가 이 변형의 원본이다. + +## 가드레일 + +문서만으로는 에이전트가 "그냥 hex 적으면 되지" 로 땜질하는 것을 못 막는다(확인됨). + +- **ESLint** (`eslint.config.js` 끝): `src/**` 에서 hex 리터럴과 `getCSSVariable` 을 error 로 막는다. + `LEGACY_HEX_FILES` 는 템플릿 시절 hex 가 남은 예시 화면이다. 목록은 줄기만 한다. +- **프롬프트 훅** (`.claude/hooks/route.mjs`): 색·테마 관련 프롬프트에 이 문서를 가리킨다. +- ESLint 규칙이 살아 있는지 확인하는 테스트는 **넣지 않는다**. 픽스처를 `src/` 에 쓰는 순간 + expo-router 타입 재생성과 Metro 리로드가 반복된다(#46). flat config 에 `no-restricted-syntax` + 블록을 추가할 때는 색 가드레일 블록이 파일 끝에 남아 있는지 눈으로 본다. + +## 거부된 대안 — 다시 제안하지 않는다 + +| 대안 | 왜 안 되나 | +| ----------------------------------- | ------------------------------------------------------------------------------------------- | +| hex 를 JS 에 같이 적기 | CSS 와 어긋나도 아무도 모른다. 대조 테스트를 붙여도 잡을 뿐 없애지 못한다 | +| CSS→TS 생성 스크립트 | "돌리는 걸 잊는" 단계가 생긴다. 읽어 오면 그 단계가 없다 | +| `@config` + colors.ts (TS 가 원본) | `@config` 색은 유틸리티에 인라인될 뿐 CSS 변수로 안 나와 semantic `var()` 가 깨진다(검증됨) | +| 읽기 한 번 `colors.ts` 를 기본으로 | 다크 전환을 못 따라간다. 라이트 고정 앱에서만(위 절) | +| `withUniwind` + `accent-*` (SVG) | Path 마다 래퍼·구독이 붙고, 로띠·Skia·워클릿은 해결 못 한다 | +| JS 쪽 폴백 hex | `@theme static` 이면 변수가 항상 있다. 폴백은 값이 두 벌 생기는 것이다 | +| Style Dictionary 등 토큰 파이프라인 | 여러 플랫폼에 토큰을 뿌릴 때의 도구. 앱 하나에 색 20개면 과하다 | diff --git a/docs/ui.md b/docs/ui.md index baf1bda..0a82574 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -64,9 +64,9 @@ Obytes-style duplicate colors.js + @theme (two hand-synced sources). `className="bg-bg-primary text-text-primary"`. - Text component: tailwind-variants `tv()` maps variant→utility class, color→semantic token. className still works (2-way). -- JS color value (Reanimated color interpolation, Skia, charts — the rare - exception): `useCSSVariable('--color-...')`. Docs say avoid this hook - for normal styling — className first, always. +- JS color value (SVG, Lottie, Reanimated interpolation — the rare + exception): `useCSSVariable([...])`, never a hex literal (ESLint error). + Token file layout and the light-only variant: `docs/colors-in-js.md`. ### Verified @@ -227,6 +227,7 @@ times"_ 라고 경고한다. 그래서 배럴에서 `SafeAreaView` export 를 - 토큰을 JS 객체로 → CSS `@theme` 이 단일 출처. Tailwind v4 의 `@config` 는 semantic 계층에 동작하지 않는다(위 "Single source of truth"). - `colors.ts → css` 생성 스크립트 → 커스텀 안티패턴, 사용자 거부. +- JS 에 hex 직접 기입 · 폴백 hex → `docs/colors-in-js.md` "거부된 대안". - 우리 버전의 프리미티브 재작성 → `components/ui` 배럴이 RN 시각 프리미티브를 re-export 한다. - jp `pressable`(pull-to-refresh 결합) · jp `image` CDN 리사이즈 → 자사 종속. diff --git a/eslint.config.js b/eslint.config.js index 685d07b..353b2d6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,28 @@ const BARREL_PATTERNS = [ }, ]; +/** + * 색 가드레일 — 값은 src/styles/tokens 의 CSS 에만 둔다(docs/colors-in-js.md). + * flat config 는 같은 rule 키를 쓰는 블록 중 마지막 것만 적용한다. 아래 블록은 파일 끝에 둔다. + */ +const HEX_COLOR = { + selector: 'Literal[value=/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/]', + message: + 'hex 색을 직접 적지 않습니다 — className 토큰(bg-primary 등)이나 useCSSVariable 을 쓰세요. 없는 색이면 src/styles/tokens 에 먼저 추가합니다.', +}; +const GET_CSS_VARIABLE = { + selector: "MemberExpression[property.name='getCSSVariable']", + message: + 'Uniwind.getCSSVariable 은 시작 시점 값이라 다크 전환을 못 따라갑니다 — useCSSVariable 을 쓰세요. 라이트 고정 앱이면 docs/colors-in-js.md.', +}; +/** 템플릿 시절 hex 가 남은 파일. 여기에 추가하지 않는다 — 화면을 바꿀 때 뺀다. */ +const LEGACY_HEX_FILES = [ + 'src/components/ui/net-log-fab.tsx', + 'src/features/home/home-screen.tsx', + 'src/features/menu-2/menu-2-screen.tsx', + 'src/features/splash/splash-screen.tsx', +]; + module.exports = defineConfig([ expoConfig, { @@ -140,4 +162,14 @@ module.exports = defineConfig([ ], }, }, + + // ── 색 가드레일 ───────────────────────────────────────────────── + { + files: ['src/**/*.{ts,tsx}'], + rules: { 'no-restricted-syntax': ['error', HEX_COLOR, GET_CSS_VARIABLE] }, + }, + { + files: LEGACY_HEX_FILES, + rules: { 'no-restricted-syntax': ['error', GET_CSS_VARIABLE] }, + }, ]); diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 3f9e877..d81e023 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,11 +1,13 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs'; +import { useCSSVariable } from 'uniwind'; import { CustomTabsLayout } from '@/components/navigation/custom-tabs-layout'; -import { TAB_BAR_COLORS } from '@/constants/tab-bar'; +import { TAB_BAR_COLOR_VARS } from '@/constants/tab-bar'; import { tabs } from '@/constants/tabs'; // iOS 26: 비선택 라벨·아이콘 색은 OS 강제라 지정 불가(expo/expo#44029). selected 만 가능. function LiquidGlassTabsLayout() { + const [active] = useCSSVariable(TAB_BAR_COLOR_VARS); return ( {tabs.map(tab => { @@ -14,7 +16,7 @@ function LiquidGlassTabsLayout() { return ( - + {route.label} diff --git a/src/components/navigation/custom-tabs-layout.tsx b/src/components/navigation/custom-tabs-layout.tsx index a5ad7f7..5ad0def 100644 --- a/src/components/navigation/custom-tabs-layout.tsx +++ b/src/components/navigation/custom-tabs-layout.tsx @@ -8,21 +8,13 @@ import { tabs } from '@/constants/tabs'; import { TabButton } from './tab-button'; const TAB_BAR_HEIGHT = 72; -const TAB_BAR_BORDER_FALLBACK = '#e4e4e7'; -const TAB_BAR_BACKGROUND_FALLBACK = '#ffffff'; - -function resolveColorToken(value: number | string | undefined, fallback: string): string { - return typeof value === 'string' ? value : fallback; -} export function CustomTabsLayout() { const insets = useSafeAreaInsets(); - const [backgroundColorToken, borderColorToken] = useCSSVariable([ - '--color-card', - '--color-border', - ]); - const backgroundColor = resolveColorToken(backgroundColorToken, TAB_BAR_BACKGROUND_FALLBACK); - const borderTopColor = resolveColorToken(borderColorToken, TAB_BAR_BORDER_FALLBACK); + // @theme static 이라 변수는 항상 있다 — 폴백 hex 를 두지 않는다. 타입만 string 으로 좁힌다. + const [backgroundColor, borderTopColor] = useCSSVariable(['--color-card', '--color-border']).map( + String + ); return ( diff --git a/src/components/navigation/tab-button.tsx b/src/components/navigation/tab-button.tsx index 43732c0..e50f0af 100644 --- a/src/components/navigation/tab-button.tsx +++ b/src/components/navigation/tab-button.tsx @@ -1,9 +1,10 @@ import { forwardRef, type ComponentType } from 'react'; import { Pressable, StyleSheet, type PressableProps, type View } from 'react-native'; import type { TabTriggerSlotProps } from 'expo-router/ui'; +import { useCSSVariable } from 'uniwind'; import { type TabIconProps } from '@/components/icons/tabs'; -import { TAB_BAR_COLORS } from '@/constants/tab-bar'; +import { TAB_BAR_COLOR_VARS } from '@/constants/tab-bar'; import { Text } from '../ui/text'; @@ -21,7 +22,8 @@ export const TabButton = forwardRef(function TabButton( { href: _href, icon: Icon, isFocused = false, label, style: _style, ...props }, ref ) { - const color = isFocused ? TAB_BAR_COLORS.selected : TAB_BAR_COLORS.default; + const [active, inactive] = useCSSVariable(TAB_BAR_COLOR_VARS); + const color = String(isFocused ? active : inactive); const pressableProps: PressableProps = props; return ( diff --git a/src/constants/tab-bar.ts b/src/constants/tab-bar.ts index 5e2f838..0bc7094 100644 --- a/src/constants/tab-bar.ts +++ b/src/constants/tab-bar.ts @@ -1,4 +1,2 @@ -export const TAB_BAR_COLORS = { - selected: '#333333', - default: '#B1B1B1', -} as const; +/** 탭 라벨·아이콘 색의 CSS 변수. 값은 src/styles/tokens 에만 있다 — useCSSVariable 로 읽는다. */ +export const TAB_BAR_COLOR_VARS = ['--color-tab-active', '--color-tab-inactive']; diff --git a/src/styles/tokens/colors.css b/src/styles/tokens/colors.css index 42cead9..df46e76 100644 --- a/src/styles/tokens/colors.css +++ b/src/styles/tokens/colors.css @@ -1,22 +1,39 @@ -/* OFFICIAL 공통 컬러 토큰 (경량) — 유틸리티 등록 + light 기본값. - * 다크 오버라이드는 semantic.css. 회사 브랜드색 주입은 #18에서. */ -@theme { - /* ── Base ── */ - --color-background: #ffffff; - --color-foreground: #09090b; - --color-card: #ffffff; - --color-muted: #f4f4f5; - --color-muted-foreground: #71717a; +/* 원시색 — 브랜드가 정한 값은 여기에만 적는다. 의미 토큰(semantic.css)은 이 변수를 참조한다. + * `static`: className 에서 안 쓰는 변수도 번들에 남긴다 — JS 에서 useCSSVariable 로 읽는 색이 사라지지 않게. + * 새 앱: 아래 값을 브랜드 팔레트로 바꾸고 semantic.css 의 참조만 조정한다. */ +@theme static { + --color-white: #ffffff; + --color-black: #09090b; - /* ── Action ── */ - --color-primary: #18181b; - --color-primary-foreground: #fafafa; + --color-gray-50: #fafafa; + --color-gray-100: #f4f4f5; + --color-gray-200: #e4e4e7; + --color-gray-300: #b1b1b1; + --color-gray-400: #a1a1aa; + --color-gray-500: #71717a; + --color-gray-700: #333333; + --color-gray-800: #27272a; + --color-gray-900: #18181b; - /* ── Feedback (색만) ── */ - --color-success: #16a34a; - --color-warning: #d97706; - --color-destructive: #dc2626; + --color-green-500: #16a34a; + --color-green-400: #4ade80; + --color-amber-600: #d97706; + --color-amber-400: #fbbf24; + --color-red-600: #dc2626; + --color-red-400: #f87171; - /* ── Form ── */ - --color-border: #e4e4e7; + /* 의미 토큰 등록 + light 기본값. 테마별 값은 semantic.css. */ + --color-background: var(--color-white); + --color-foreground: var(--color-black); + --color-card: var(--color-white); + --color-muted: var(--color-gray-100); + --color-muted-foreground: var(--color-gray-500); + --color-primary: var(--color-gray-900); + --color-primary-foreground: var(--color-gray-50); + --color-success: var(--color-green-500); + --color-warning: var(--color-amber-600); + --color-destructive: var(--color-red-600); + --color-border: var(--color-gray-200); + --color-tab-active: var(--color-gray-700); + --color-tab-inactive: var(--color-gray-300); } diff --git a/src/styles/tokens/semantic.css b/src/styles/tokens/semantic.css index 9727841..bf78013 100644 --- a/src/styles/tokens/semantic.css +++ b/src/styles/tokens/semantic.css @@ -1,33 +1,37 @@ -/* 테마별 값 — light / dark. 토큰 등록·기본값은 colors.css. - * 화면 코드엔 dark: 안 쓴다 (여기서 토큰 값만 교체). */ +/* 테마별 값 — light / dark. 원시색·토큰 등록은 colors.css. + * 화면 코드엔 dark: 안 쓴다 (여기서 토큰 값만 교체). 값은 원시색 참조로만 적는다. */ @layer theme { :root { @variant light { - --color-background: #ffffff; - --color-foreground: #09090b; - --color-card: #ffffff; - --color-muted: #f4f4f5; - --color-muted-foreground: #71717a; - --color-primary: #18181b; - --color-primary-foreground: #fafafa; - --color-success: #16a34a; - --color-warning: #d97706; - --color-destructive: #dc2626; - --color-border: #e4e4e7; + --color-background: var(--color-white); + --color-foreground: var(--color-black); + --color-card: var(--color-white); + --color-muted: var(--color-gray-100); + --color-muted-foreground: var(--color-gray-500); + --color-primary: var(--color-gray-900); + --color-primary-foreground: var(--color-gray-50); + --color-success: var(--color-green-500); + --color-warning: var(--color-amber-600); + --color-destructive: var(--color-red-600); + --color-border: var(--color-gray-200); + --color-tab-active: var(--color-gray-700); + --color-tab-inactive: var(--color-gray-300); } @variant dark { - --color-background: #09090b; - --color-foreground: #fafafa; - --color-card: #18181b; - --color-muted: #27272a; - --color-muted-foreground: #a1a1aa; - --color-primary: #fafafa; - --color-primary-foreground: #18181b; - --color-success: #4ade80; - --color-warning: #fbbf24; - --color-destructive: #f87171; - --color-border: #27272a; + --color-background: var(--color-black); + --color-foreground: var(--color-gray-50); + --color-card: var(--color-gray-900); + --color-muted: var(--color-gray-800); + --color-muted-foreground: var(--color-gray-400); + --color-primary: var(--color-gray-50); + --color-primary-foreground: var(--color-gray-900); + --color-success: var(--color-green-400); + --color-warning: var(--color-amber-400); + --color-destructive: var(--color-red-400); + --color-border: var(--color-gray-800); + --color-tab-active: var(--color-gray-50); + --color-tab-inactive: var(--color-gray-500); } } }