Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/hooks/route.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions docs/colors-in-js.md
Original file line number Diff line number Diff line change
@@ -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개면 과하다 |
7 changes: 4 additions & 3 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 리사이즈 → 자사 종속.

Expand Down
32 changes: 32 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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] },
},
]);
6 changes: 4 additions & 2 deletions src/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NativeTabs>
{tabs.map(tab => {
Expand All @@ -14,7 +16,7 @@ function LiquidGlassTabsLayout() {
return (
<NativeTabs.Trigger key={route.name} name={route.name}>
<NativeTabs.Trigger.Icon renderingMode="original" src={tab.nativeIcon} />
<NativeTabs.Trigger.Label selectedStyle={{ color: TAB_BAR_COLORS.selected }}>
<NativeTabs.Trigger.Label selectedStyle={{ color: String(active) }}>
{route.label}
</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
Expand Down
16 changes: 4 additions & 12 deletions src/components/navigation/custom-tabs-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tabs style={styles.tabs}>
Expand Down
6 changes: 4 additions & 2 deletions src/components/navigation/tab-button.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -21,7 +22,8 @@ export const TabButton = forwardRef<View, TabButtonProps>(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 (
Expand Down
6 changes: 2 additions & 4 deletions src/constants/tab-bar.ts
Original file line number Diff line number Diff line change
@@ -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'];
53 changes: 35 additions & 18 deletions src/styles/tokens/colors.css
Original file line number Diff line number Diff line change
@@ -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);
}
52 changes: 28 additions & 24 deletions src/styles/tokens/semantic.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading