Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d661c17
feat(styles): primitive color layer and JS color guardrails
LESANF Sep 22, 2026
95071ef
Merge pull request #57 from LESANF/feat/color-primitives
LESANF Sep 22, 2026
e4e6a07
feat(theme): read JS colors through useColors and split primitives
LESANF Sep 22, 2026
0411edb
feat(hooks): dismiss the keyboard on background and add useDebouncedV…
LESANF Sep 22, 2026
8365699
feat(ui): UnderlineText and StrikethroughText drawn as views
LESANF Sep 22, 2026
b584a4a
feat(constants): shared motion durations, curves and springs
LESANF Sep 22, 2026
bb877e3
chore(lint): forbid parent-relative imports in favor of the @/ alias
LESANF Sep 22, 2026
dc72ac9
feat(ui): Button keeps ghost/link labels while loading and themes the…
LESANF Sep 22, 2026
baf9f04
feat(ui): Input wrapper owns the height, adds hint and left/right slots
LESANF Sep 22, 2026
ecdae12
feat(icons): shared Icon base with an optional press target
LESANF Sep 22, 2026
285b6a2
feat(lib): dayjs with the app locale and locale-neutral format helpers
LESANF Sep 22, 2026
0e38d8d
docs: UI principles, migration notes and changelog for the carhartt port
LESANF Sep 22, 2026
820e93c
chore: 색 가드레일 테스트를 추가하고 ui 배럴의 죽은 export 와 import 출처를 정리한다
LESANF Sep 22, 2026
933414f
chore: tighten comments and doc notes from the port
LESANF Sep 22, 2026
b45ed78
Merge pull request #58 from LESANF/feat/port-carhartt
LESANF Sep 22, 2026
3f9eb5a
chore(release): 0.1.0
LESANF Sep 22, 2026
382711f
Merge pull request #59 from LESANF/chore/release-0.1.0
LESANF Sep 22, 2026
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 색은 `useColors()`. 다크는 `semantic.css` 의 dark 블록이 담당하고 화면 코드에 `dark:` 를 쓰지 않는다. hex 직접 기입·생성 스크립트·읽기 한 번 상수는 거부된 대안이다(ESLint error) — `docs/colors-in-js.md`.',
},
];

const EXPO_RE = /\bexpo\b|expo-router|expo-\w+|EAS\b/i;
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ OS Linking · 푸시 탭 · 인앱 · 어트리뷰션 SDK — 진입이 어디
# ④ 반드시 지킬 것

- **거부된 대안을 다시 제안하지 마라.** 각 섹션 문서에 그 절이 있다. 이미 판단이 끝난 것이다.
- **Figma 값은 인스턴스 분포로 검증한다.** 한 노드를 믿지 않는다 — 잔재와 잘못 그린 variant 가 섞여 있다.
- **텍스트·입력의 높이는 감싸는 뷰가 갖는다.** 입력에는 글꼴·크기만. 플랫폼별 픽셀 보정 전에 구조로 푼다.
색은 CSS 토큰에만, JS 는 `useColors()` — `docs/ui.md` "UI 원칙", `docs/colors-in-js.md`.
- **참조 앱(KR/JP)이 스펙이다.** 로컬에 소스가 있다 — 추측하지 말고 열어서 대조한다.
경로는 사용자에게 받는다(머신마다 다르다). 단 참조 앱에도 결함이 있으니(push.md 의 D1~D19) 그대로 베끼지 않는다.
- **메커니즘은 그대로, 정책은 비운다.** 참조 앱에서 이식할 때 안전 탈출 경로·게이트 인프라는
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@

## [Unreleased]

## [0.1.0] — 2026-09-22

### Added

- `UnderlineText`·`StrikethroughText` — 선을 뷰로 그린다. `textDecorationLine` 은 두께·색·위치를 못 잡는다
- `useDismissKeyboardOnBackground`(루트 레이아웃에서 호출) — iOS 가 백그라운드 전환 때 포커스를 남겨 복귀
후 키보드가 안 뜨는 문제. `useDebouncedValue`
- `components/icons/icon.tsx` — 모든 아이콘의 바탕. `onPress` 를 주면 `Pressable` 로 감싼다(`hitSlop` 16).
탭 아이콘이 이 위로 옮겨 갔다
- `lib/dayjs`(로케일을 `DEFAULT_LANGUAGE` 로 한 번 건다) · `utils/format`(로케일 중립 표기만) · `dayjs` 의존성
- `constants/motion` — 시간·곡선·스프링 공통 상수
- ESLint: 상위 폴더 상대경로(`../`) import 를 warn 으로 막고 `@/` alias 로 통일
- `docs/ui.md` "UI 원칙" — Figma 인스턴스 분포 검증, 높이는 감싸는 뷰가, 구조 우선, 행간 안전값

### Changed

- **`Input` 구조** — 높이·테두리·배경이 입력에서 감싸는 뷰로 옮겨 갔다(`className` 대상이 바뀐다).
입력은 `text-size-md` 만, 행간 없음. `hint`·`left`·`right` 추가, `editable` 대신 `disabled`. 모양은 그대로
- **`Button` 동작** — `textClassName`, 라벨 한 줄, `ghost`·`link` 는 로딩 중 라벨 유지, 로띠 점 색을
variant 별 토큰으로. `size` 와 모양은 그대로

- **색 토큰을 원시색 층으로 분리** — `colors.css` 는 `@theme static` 원시색만, `semantic.css` 가 의미 토큰을
등록하고 light 값을 주며 dark 는 값만 바꾼다. 새 앱은 원시색만 바꾼다. 탭 라벨 색이
`--color-tab-active`·`--color-tab-inactive` 토큰이 됐다(값은 그대로)
- **JS 색은 `useColors()`** (`lib/theme/use-colors.ts`) — 토큰 전부를 구독 하나로 읽고 테마를 따라간다.
`constants/tab-bar.ts` 는 지웠다. ESLint 가 `src` 의 hex 리터럴, `use-colors.ts` 밖의 `useCSSVariable`,
`getCSSVariable` 을 error 로 막는다(docs/colors-in-js.md)

## [0.0.9] — 2026-09-17

### Added
Expand Down
15 changes: 15 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

| 무엇이 있나 | 그 버전 |
| ------------------------------------------------------------------------------ | --------------- |
| `src/lib/theme/use-colors.ts` 가 있다 | v0.1.0 |
| `targets/notification-service/` 가 있고 `package.json` 에 `engines.pnpm` | v0.0.9 |
| `enableSceneSupport` 와 `expo-build-properties` `~57.0.20`, `targets/` 없음 | v0.0.7 · v0.0.8 |
| `patches/expo@57.0.22.patch` 와 `plugins/with-ios-scene.ts` | v0.0.4 · v0.0.5 |
Expand All @@ -17,6 +18,20 @@

---

## v0.0.9 에서 올리기 — Input · 탭 색 · 색 규칙

- **`Input` 의 `className` 대상이 바뀌었다.** 입력 자체가 아니라 감싸는 뷰(높이·테두리·배경)에 붙는다.
`editable` 은 타입에서 빠졌다 — `disabled` 로 바꾼다. `hint`·`left`·`right` 가 새로 생겼다.
- **`constants/tab-bar.ts` 가 없어졌다.** 탭 라벨 색은 `useColors().tabActive`·`tabInactive` 로 읽는다.
토큰은 `semantic.css` 의 `--color-tab-active`·`--color-tab-inactive`.
- **`colors.css` 는 원시색만, `semantic.css` 가 의미 토큰이다.** 브랜드 색을 이미 `colors.css` 의 의미
토큰에 직접 넣었다면 원시색으로 옮기고 참조로 바꾼다. 둘 다 `@theme static`.
- **JS 에서 색을 읽던 곳은 `useColors()` 로.** `useCSSVariable` 직접 호출과 hex 리터럴은 lint error 가 된다.
당장 못 고치는 파일은 `eslint.config.js` 의 `LEGACY_HEX_FILES` 에 넣고 줄여 간다.
- 그 외는 추가만이다: `UnderlineText`·`StrikethroughText`, `useDismissKeyboardOnBackground`(루트에서 호출),
`useDebouncedValue`, `components/icons/icon.tsx`, `lib/dayjs`·`utils/format`, `constants/motion`,
`../` import 금지 lint(warn).

## v0.0.7 · v0.0.8 에서 올리기 — pnpm 핀과 NSE

둘은 독립이다. pnpm 은 전부에 권하고, NSE 는 iOS 리치 푸시 이미지가 필요할 때만.
Expand Down
138 changes: 138 additions & 0 deletions docs/colors-in-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 색

> `docs/ui.md` "Single source of truth = CSS `@theme`"와 같은 결정이다. 이 문서는 그 결정을 JS·테마까지 확장한 규칙이다.
> carhartt-kr-app에서 검증했다(2026-09-22).

## 규칙

1. **색 값은 `src/styles/tokens`의 CSS에만 있다.** `colors.css`가 원시색, `semantic.css`가 의미 토큰이다. JS·TS 파일에 hex를 적지 않는다.
2. **정적인 스타일은 `className`으로 쓴다.** `bg-background`·`text-muted-foreground`처럼 의미 토큰 클래스만 쓴다. 원시색 클래스(`bg-gray-200`)는 컴포넌트에 쓰지 않는다.
3. **`className`을 못 받는 곳은 `useColors()`로 읽는다.** SVG `fill`, 로띠 `colorFilters`, Reanimated 스타일, 라이브러리 색 프롭이 여기에 든다. 훅 하나가 토큰 전부를 구독 하나로 읽고 테마를 따라간다.
4. **테마는 항상 지원된다.** `semantic.css`의 `@variant dark` 블록이 의미 토큰 값을 바꾼다. 화면 코드에 `dark:`를 쓰지 않는다. 다크 디자인이 없는 앱도 dark 블록을 비워 두지 않는다. 임시 팔레트를 채우고 노출은 `app.config.ts`의 `userInterfaceStyle`로 정한다.
5. **토큰 블록은 `@theme static`이다.** 일반 `@theme`는 `className`에서 안 쓴 변수를 빌드에서 빼므로 JS에서만 읽는 색이 사라진다.

```tsx
<View className="bg-background" />

const colors = useColors();
<Path fill={colors.foreground} />
<Animated.View style={[styles.track, { backgroundColor: checked ? colors.foreground : colors.disabled }]} />
```

움직이는 부분은 `StyleSheet.create` 대신 Reanimated의 `css.create`로 전환 속성만 갖고, 색은 `useColors()` 값을 인라인으로 준다.

## 파일

```
src/styles/tokens/colors.css @theme static — 원시색
src/styles/tokens/semantic.css @theme static — 의미 토큰 등록 + light 값
@layer theme { :root { @variant dark { … } } } — dark 값
src/lib/theme/use-colors.ts useColors() — TOKENS 표 하나, useCSSVariable 배열 읽기
src/lib/theme/use-colors.test.ts 이름 검증
```

```ts
// src/lib/theme/use-colors.ts
import { useMemo } from 'react';
import { useCSSVariable } from 'uniwind';

// className 을 못 받는 곳(SVG·로띠·Reanimated 스타일)에서 쓰는 색. CSS 토큰을 읽고 테마를 따라간다.
const TOKENS = {
background: '--color-background',
foreground: '--color-foreground',
border: '--color-border',
} as const;

export type ColorName = keyof typeof TOKENS;
export type Colors = Readonly<Record<ColorName, string>>;

const NAMES = Object.keys(TOKENS) as ColorName[];
const VARIABLES = NAMES.map(name => TOKENS[name]);

export function useColors(): Colors {
const values = useCSSVariable(VARIABLES);
return useMemo(
() => Object.fromEntries(NAMES.map((name, i) => [name, String(values[i])])) as Colors,
[values]
);
}
```

## 색을 추가할 때

1. 원시색이면 `colors.css`에 `--color-<이름>: #hex`.
2. 의미 토큰이면 `semantic.css`의 `@theme static`(light)과 `@variant dark` **둘 다**에 원시색 참조로 적는다.
3. JS에서도 쓰면 `use-colors.ts`의 `TOKENS`에 한 줄 추가한다.

## 가드레일

| 무엇을 | 어디서 | 막는 것 |
| ------------------------------------- | -------------------------- | -------------------------------------------------------------------------------- |
| ESLint `no-restricted-syntax` (error) | `eslint.config.js` 끝 블록 | `src`의 hex 리터럴, `use-colors.ts` 밖의 `useCSSVariable`, `getCSSVariable` 전부 |
| `lib/theme/use-colors.test.ts` | `pnpm test` | 훅이 읽는 변수 이름 오타, `@theme static` 이탈, light·dark 토큰 집합 불일치 |
| 프롬프트 훅 | `.claude/hooks/route.mjs` | 색·테마 작업을 시작할 때 이 문서를 가리킨다 |

```js
// eslint.config.js
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 등)이나 useColors() 를 쓰세요. 없는 색이면 src/styles/tokens 에 먼저 추가합니다.',
};
const CSS_VARIABLE_READS = [
{
selector: "ImportSpecifier[imported.name='useCSSVariable']",
message: 'CSS 변수는 useColors()(@/lib/theme/use-colors) 로 읽습니다 — 거기에 색을 추가해 쓰세요.',
},
{
selector: "MemberExpression[property.name='getCSSVariable']",
message: 'getCSSVariable 은 테마 전환을 따라가지 않습니다 — useColors() 를 쓰세요.',
},
];
/** 템플릿 시절 hex 가 남은 파일. 여기에 추가하지 않는다 — 화면을 바꿀 때 뺀다. */
const LEGACY_HEX_FILES = [];

// defineConfig 배열 끝. 같은 rule 키를 쓰는 좁은 블록이 넓은 블록을 덮으므로 순서가 중요하다.
{
files: ['src/**/*.{ts,tsx}'],
rules: { 'no-restricted-syntax': ['error', HEX_COLOR, ...CSS_VARIABLE_READS] },
},
{
files: ['src/lib/theme/use-colors.ts'],
rules: { 'no-restricted-syntax': ['error', HEX_COLOR, CSS_VARIABLE_READS[1]] },
},
{
files: LEGACY_HEX_FILES,
rules: { 'no-restricted-syntax': ['error', ...CSS_VARIABLE_READS] },
},
```

```js
// .claude/hooks/route.mjs ROUTES
{
re: /색상?|컬러|colou?r|hex|(디자인|색상?|컬러)\s*토큰|design\s*token|테마|theme|다크\s*모드|StyleSheet|css\.create|스타일링|useCSSVariable|getCSSVariable/i,
say: '색·테마 → 색 값은 `src/styles/tokens` CSS 에만 둔다. 정적인 곳은 className, JS 색은 `useColors()`. 다크는 `semantic.css` 의 dark 블록이 담당하고 화면 코드에 `dark:` 를 쓰지 않는다. hex 직접 기입·생성 스크립트·읽기 한 번 상수는 거부된 대안이다(ESLint error) — `docs/colors-in-js.md`.',
},
```

ESLint 규칙이 살아 있는지 확인하는 테스트(`eslint.config.test.ts`)는 픽스처를 파일로 쓰지 않고 `eslint --stdin --stdin-filename <가상 경로>`로 돌린다. 파일을 `src/`에 만들면 expo-router 타입 재생성과 Metro 리로드가 반복된다(#46).

## 거부된 대안

| 대안 | 왜 안 되나 |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| hex를 JS에 같이 적기 | CSS와 어긋나도 아무도 모른다. 대조 테스트는 잡을 뿐 없애지 못한다 |
| CSS→TS 생성 스크립트 | 돌리는 걸 잊는 단계가 생긴다 |
| `@config` + colors.ts (TS가 원본) | `@config` 색은 유틸리티에 인라인될 뿐 CSS 변수로 안 나와 semantic `var()`가 깨진다(검증됨) |
| 시작할 때 한 번 읽는 상수 (`getCSSVariable`) | 테마 전환을 따라가지 못한다. 구독을 아끼려는 것이었지만 `className` 컴포넌트마다 이미 같은 리스너(Uniwind `useStyle`)가 붙어 있어 아낄 것이 없다 |
| 컴포넌트마다 `useCSSVariable` 직접 호출 | 필요한 변수를 각자 고르면 구독이 흩어지고 이름 오타를 잡을 곳이 없다. `useColors()` 하나로 모은다 |
| JS 쪽 폴백 hex | `@theme static`이면 변수가 항상 있다. 폴백은 값이 두 벌 생기는 것이다 |
| `withUniwind` + `accent-*` (SVG) | Path마다 래퍼·구독이 붙고 로띠·Skia·워클릿은 해결 못 한다 |
| 화면 코드의 `dark:` | 토큰이 이미 테마를 바꾼다. 클래스마다 다크를 적으면 팔레트를 바꿀 때 화면 전부를 고친다 |
| Style Dictionary 등 토큰 파이프라인 | 여러 플랫폼에 토큰을 뿌릴 때의 도구. 앱 하나에 색 20개면 과하다 |

## 검증

- `pnpm run check-all`.
- 번들: `npx expo export --platform android --no-bytecode --output-dir <임시경로>` 뒤 번들에 `"__uniwind-theme-dark":{"--color-background":…}`(dark 표)와 기본 표의 `"--color-background":…`(light)가 둘 다 있는지 본다.
- jest에서는 Metro 변환이 안 돌아 `useCSSVariable` 값이 비어 있다. 색 값 자체를 검증하는 테스트는 쓸 수 없고 쓸 필요도 없다.
5 changes: 2 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,8 @@ feature/xxx ──PR──▶ 0.0.2 ──PR──▶ master ──tag──▶
머지는 항상 **merge commit**(`gh pr merge --merge`)이다. squash 는 커밋 단위 이력과
`Co-Authored-By` 트레일러를 하나로 뭉갠다.

이 흐름을 프로필 achievements 집계 때문에 바꾸지 않는다. 피쳐 PR 은 버전 브랜치로 머지돼도
센다 — Pair Extraordinaire x2 가 `create-lesa-app#10`(`ci/check` → `0.0.3`)으로 열렸다.
반영은 며칠 늦는다. 2026-09-21 에 "기본 브랜치 PR 만 센다" 고 오판해 흐름을 바꿨다가 되돌렸다.
버전 브랜치로 가는 피쳐 PR 도 프로필 achievements 에 센다(`create-lesa-app#10` 으로 x2, 반영은 며칠 늦다).
집계를 이유로 이 흐름을 바꾸지 않는다.

브랜치 이름에는 `v` 를 붙이지 않는다. 태그가 `v0.0.2` 라서 브랜치도 같은 이름이면
`git checkout 0.0.2` 가 "refname is ambiguous" 로 갈린다.
Expand Down
Loading
Loading