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