diff --git a/.agents/skills/ai-review-workflow/SKILL.md b/.agents/skills/ai-review-workflow/SKILL.md
index 2dca4eab..40d7d4bc 100644
--- a/.agents/skills/ai-review-workflow/SKILL.md
+++ b/.agents/skills/ai-review-workflow/SKILL.md
@@ -51,7 +51,8 @@ Before editing, read:
1. `CONVENTIONS.md`
2. `docs/ai-rules.md`
-3. `docs/git-commit-convention.md` before committing
+3. `docs/architecture.md` — 워크스페이스 경계·인증·API·WebView·알림 동작 지도
+4. `docs/git-commit-convention.md` before committing
Keep scope tight. Do not touch frontend code while working on review workflow
formatting unless the user explicitly asks.
@@ -247,14 +248,38 @@ Procedure:
':(glob,exclude)**/generated/**/*.d.ts'
```
-4. Review high-signal issues only:
+4. **컨텍스트 수집 — diff만 보고 리뷰하지 않는다.** finding을 확정하기 전에 head 기준
+ 실제 코드를 읽는다:
+ - `docs/architecture.md`를 먼저 읽고 관련 워크스페이스의 동작 계약을 파악한다.
+ - PR body의 의도와 `👀 Review Point`를 확인한다.
+ - 변경 파일은 hunk뿐 아니라 파일 전체를 읽는다: `git show
:`.
+ - 변경된 export·hook·API·공용 type의 호출처를 추적한다:
+ `git grep -n '' -- web app admin maeil-mail shared`.
+ - 같은 워크스페이스·도메인의 이웃 구현 1~2개와 비교한다. 다른 워크스페이스의
+ 우연히 같은 이름인 구현을 표준으로 오인하지 않는다.
+ - 이전 AI 리뷰가 있으면 답글과 resolve 상태를 읽고 반박된 finding을 반복하지 않는다.
+
+5. Review high-signal issues only:
- Comment only on likely production issues, security problems, data
corruption risks, broken user flows, or severe maintainability risks.
- Do not comment on generated files, style nits, formatting, subjective
naming, unchanged code, or speculative refactors.
- Only report issues introduced by the PR diff.
-
-5. Decide comment placement:
+ - **Repository-specific high-signal areas** (근거는 `docs/architecture.md`와 실제 코드):
+ - `shared` fetcher·generated API·theme·WebView union 변경 후 소비처 누락.
+ - WebView message 송수신 한쪽만 변경, 브라우저 optional guard 제거, 로그인 분기 파손.
+ - cookie 인증을 bearer/local storage로 잘못 전제하거나 최초 profile probe를 상시
+ route guard로 오인한 변경.
+ - mutation invalidation 누락, 워크스페이스별 retry/cache 정책 혼동, 공용 `ApiError`
+ 대신 axios 오류 shape 전제.
+ - FCM의 memberId/deviceUuid/token 연결, notificationType→route mapping, listener cleanup
+ 중 하나가 빠진 변경.
+ - admin 외부 adapter를 공용 API로 오인하거나 Supabase lazy 초기화를 깨는 변경.
+ - **제네릭 필터**: 아무 React/React Native 모노레포에도 성립하는 코멘트면 버리거나,
+ 실제 호출 흐름·워크스페이스 계약·file:line 근거를 붙여 서비스 특화 finding으로
+ 다시 쓴다. 일반론 체크리스트는 게시하지 않는다.
+
+6. Decide comment placement:
- `critical` and `major`: inline comments when the path and changed line are
valid.
- `minor`: summary only.
@@ -262,15 +287,15 @@ Procedure:
summary instead of forcing an inline comment.
- **항상 PR 코멘트(리뷰)를 게시한다.** Findings가 0개여도 publisher가
`> ✅ 확실하게 수정이 필요한 항목을 찾지 못했습니다.` summary를 자동으로
- 채우므로, 스킬이 발동되면 무조건 step 7의 publisher 실행까지 진행한다.
+ 채우므로, 스킬이 발동되면 무조건 step 8의 publisher 실행까지 진행한다.
"찾은 게 없으니 안 올리고 끝"은 금지.
-6. Write the review result to the scratch `$REVIEW_JSON` (step 0, 레포 밖) using
+7. Write the review result to the scratch `$REVIEW_JSON` (step 0, 레포 밖) using
`.github/codex-review-output-schema.json`. **레포 루트에 쓰지 않는다.** The
shared publisher owns the Preferred Review Body Format, Preferred Inline
Comment Format, ``, and ``.
-7. **반드시 publisher를 실행해 PR 코멘트를 게시한다.** 권장 경로는 `REVIEWER=claude`로
+8. **반드시 publisher를 실행해 PR 코멘트를 게시한다.** 권장 경로는 `REVIEWER=claude`로
로컬 publisher를 직접 실행하는 것이다 (`REVIEW_JSON`은 step 0의 scratch 경로):
```bash
@@ -300,12 +325,12 @@ Procedure:
`OPENAI_API_KEY`, and must only decode the local structured review result
and run `.github/scripts/publish-codex-review.sh`.
-8. If inline publishing fails because of invalid positions or paths, retry once
+9. If inline publishing fails because of invalid positions or paths, retry once
with `comments: []` so the summary is still posted. The shared publisher
already performs this fallback; tell the user which inline comments could not
be attached if the logs reveal that detail.
-9. **마무리: 워킹트리를 clean 상태로 남긴다.** 게시 후 `git status --short`가
+10. **마무리: 워킹트리를 clean 상태로 남긴다.** 게시 후 `git status --short`가
비어 있어야 한다. 리뷰용 산출물이 워킹트리에 남았다면 삭제한다. 이 스킬은
커밋·브랜치·PR을 만들지 않는다 — 클라우드 라우틴에서 auto-PR로 쓸려 들어가는
것을 막기 위함이다.
@@ -314,6 +339,17 @@ Never publish mock findings as a real PR review. Never publish `APPROVE` or
`REQUEST_CHANGES`. Avoid posting a duplicate review for the same head commit
unless the user explicitly asks.
+## Architecture Learning Loop
+
+1. finding이 사람 답글로 반박·정정되거나 다른 리뷰어가 놓친 실제 이슈를 찾으면,
+ 재현 근거가 있는 항목만 `.review-learnings/REVIEW.md`에 다음 리뷰 규칙으로 적립한다.
+2. 실제 회귀가 확인된 영역만 repository-specific high-signal 목록에 추가한다. 일반론으로
+ 목록을 희석하지 않는다.
+3. 리뷰 중 `docs/architecture.md`와 코드가 어긋나면 코드를 기준으로 리뷰하고 사용자에게
+ architecture 문서 갱신을 별도 제안한다.
+4. `` finding id와 `` 마커는 과거 finding
+ 추적 키이므로 유지한다.
+
## Generated File Exclusions
Keep both prompt diff exclusions and review policy aligned:
diff --git a/.prettierrc b/.prettierrc
index 14853109..f5f65801 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -8,6 +8,7 @@
"jsxSingleQuote": false,
"trailingComma": "all",
"singleAttributePerLine": false,
+ "embeddedLanguageFormatting": "off",
"proseWrap": "preserve",
"printWidth": 80,
"tabWidth": 2,
diff --git a/AGENTS.md b/AGENTS.md
index d6659122..04614f1f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -86,11 +86,11 @@ AI agents MUST treat linting as a BLOCKING requirement.
Before considering any task complete, the AI MUST:
-1. Run ESLint auto-fix:
+1. Run sequential ESLint → Stylelint auto-fix:
- `pnpm --filter {workspace} lint:fix`
-2. Run Stylelint auto-fix:
- - `pnpm --filter {workspace} stylelint:fix`
-3. Re-run lint checks:
+ - In workspaces with Stylelint, this script MUST run ESLint first and
+ Stylelint second. The two fixers MUST NOT run in parallel.
+2. Re-run lint checks:
- ESLint / Stylelint MUST pass with ZERO errors
Rules:
@@ -177,6 +177,11 @@ These rules apply to commits and pull requests in **any** package.
When Codex reviews a pull request, it MUST act as a high-signal reviewer.
+Before reviewing, read `docs/architecture.md`. It is the repository behavior map
+for workspace boundaries, authentication, API, routing, WebView, and notification
+contracts. If the document and code disagree, the code is authoritative and the
+reviewer MUST propose updating the document.
+
Focus on:
- Production bugs, runtime crashes, and broken user flows
diff --git a/admin/package.json b/admin/package.json
index 77a19c89..0ebfec94 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -8,9 +8,8 @@
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx,.js,.jsx",
- "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix",
- "stylelint:fix": "stylelint './src/**/*.tsx' --fix",
- "format": "prettier --write .",
+ "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix",
+ "format": "prettier --write . && stylelint './src/**/*.tsx' --fix",
"type-check": "tsc --noEmit",
"generate-openapi-types": "dotenv -e .env -- bash -c 'openapi-typescript $VITE_OPEN_API_DOCS -o ./src/types/openapi.d.ts && prettier --write ./src/types/openapi.d.ts'"
},
diff --git a/admin/src/components/reviewers/ReviewerModals.tsx b/admin/src/components/reviewers/ReviewerModals.tsx
index 3b59899c..774bd236 100644
--- a/admin/src/components/reviewers/ReviewerModals.tsx
+++ b/admin/src/components/reviewers/ReviewerModals.tsx
@@ -145,31 +145,32 @@ export const AddReviewerModal = ({ onClose }: { onClose: () => void }) => {
const Overlay = styled.div`
position: fixed;
- inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(17, 24, 39, 0.4);
+ background: rgb(17 24 39 / 40%);
+
+ inset: 0;
`;
const ModalCard = styled.div`
width: 400px;
padding: ${({ theme }) => theme.spacing.lg};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+ box-shadow: ${({ theme }) => theme.shadows.lg};
background: ${({ theme }) => theme.colors.white};
- box-shadow: ${({ theme }) => theme.shadows.lg};
`;
const ModalTitle = styled.h2`
margin-bottom: ${({ theme }) => theme.spacing.md};
color: ${({ theme }) => theme.colors.gray900};
- font-size: ${({ theme }) => theme.fontSize.lg};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.lg};
`;
const Field = styled.div`
@@ -182,8 +183,8 @@ const Field = styled.div`
const FieldLabel = styled.label`
color: ${({ theme }) => theme.colors.gray700};
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.medium};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const FieldInput = styled.input`
@@ -227,17 +228,18 @@ const PrimaryButton = styled.button`
background: ${({ theme }) => theme.colors.primary};
color: ${({ theme }) => theme.colors.white};
- cursor: pointer;
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+
+ cursor: pointer;
&:hover {
background: ${({ theme }) => theme.colors.primaryHover};
}
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
+ opacity: 0.5;
}
`;
@@ -248,9 +250,10 @@ const SecondaryButton = styled.button`
background: ${({ theme }) => theme.colors.white};
color: ${({ theme }) => theme.colors.gray600};
- cursor: pointer;
font-size: ${({ theme }) => theme.fontSize.sm};
+ cursor: pointer;
+
&:hover {
background: ${({ theme }) => theme.colors.gray50};
}
diff --git a/admin/src/components/reviewers/ReviewersTableBody.tsx b/admin/src/components/reviewers/ReviewersTableBody.tsx
index be7156af..7cd2d356 100644
--- a/admin/src/components/reviewers/ReviewersTableBody.tsx
+++ b/admin/src/components/reviewers/ReviewersTableBody.tsx
@@ -230,8 +230,8 @@ const Td = styled.td`
text-align: left;
&.num {
- font-variant-numeric: tabular-nums;
text-align: right;
+ font-variant-numeric: tabular-nums;
}
&.strong {
@@ -256,12 +256,11 @@ const GaugeCell = styled.div`
`;
const GaugeTrack = styled.div`
+ overflow: hidden;
width: 80px;
height: 6px;
border-radius: ${({ theme }) => theme.borderRadius.full};
- overflow: hidden;
-
background: ${({ theme }) => theme.colors.gray100};
`;
@@ -275,6 +274,7 @@ const GaugeFill = styled.div`
const GaugeText = styled.span`
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.xs};
+
font-variant-numeric: tabular-nums;
`;
@@ -289,8 +289,8 @@ const StateBadge = styled.span<{ $vacation: boolean }>`
background: ${({ $vacation }) => ($vacation ? '#FEF3C7' : '#D1FAE5')};
color: ${({ $vacation }) => ($vacation ? '#92400E' : '#065F46')};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
`;
const SubCount = styled.span`
@@ -329,17 +329,18 @@ const MiniButton = styled.button`
background: ${({ theme }) => theme.colors.white};
color: ${({ theme }) => theme.colors.gray600};
- cursor: pointer;
font-size: ${({ theme }) => theme.fontSize.xs};
white-space: nowrap;
+ cursor: pointer;
+
&:hover {
background: ${({ theme }) => theme.colors.gray50};
}
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
+ opacity: 0.5;
}
`;
@@ -354,15 +355,16 @@ const DeleteButton = styled.button<{ $confirming: boolean }>`
$confirming ? '#FEE2E2' : theme.colors.white};
color: ${({ theme, $confirming }) =>
$confirming ? theme.colors.error : theme.colors.gray600};
- cursor: pointer;
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme, $confirming }) =>
$confirming ? theme.fontWeight.semibold : theme.fontWeight.normal};
+ font-size: ${({ theme }) => theme.fontSize.xs};
white-space: nowrap;
+ cursor: pointer;
+
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
+ opacity: 0.5;
}
`;
@@ -373,16 +375,17 @@ const ToggleButton = styled.button`
background: ${({ theme }) => theme.colors.white};
color: ${({ theme }) => theme.colors.gray600};
- cursor: pointer;
font-size: ${({ theme }) => theme.fontSize.xs};
+ cursor: pointer;
+
&:hover {
background: ${({ theme }) => theme.colors.gray50};
}
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
+ opacity: 0.5;
}
`;
diff --git a/admin/src/components/reviewers/SummaryStatCard.tsx b/admin/src/components/reviewers/SummaryStatCard.tsx
index 67b72b3c..cdd906da 100644
--- a/admin/src/components/reviewers/SummaryStatCard.tsx
+++ b/admin/src/components/reviewers/SummaryStatCard.tsx
@@ -15,8 +15,9 @@ export const SummaryStatCard = ({ label, value, emphasis }: Props) => (
export const SummaryStatCardRow = styled.div`
display: grid;
- grid-template-columns: repeat(4, 1fr);
gap: ${({ theme }) => theme.spacing.md};
+
+ grid-template-columns: repeat(4, 1fr);
`;
export const SummaryStatCardSkeleton = () => (
@@ -35,17 +36,17 @@ const Card = styled.div<{ $emphasis?: 'default' | 'error' }>`
${({ theme, $emphasis }) =>
$emphasis === 'error' ? theme.colors.error : theme.colors.primary};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+ box-shadow: ${({ theme }) => theme.shadows.sm};
background: ${({ theme }) => theme.colors.white};
- box-shadow: ${({ theme }) => theme.shadows.sm};
`;
const Label = styled.span`
display: block;
color: ${({ theme }) => theme.colors.gray500};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme }) => theme.fontWeight.medium};
+ font-size: ${({ theme }) => theme.fontSize.xs};
letter-spacing: 0.04em;
`;
@@ -56,13 +57,13 @@ const Value = styled.span<{ $emphasis?: 'default' | 'error' }>`
color: ${({ theme, $emphasis }) =>
$emphasis === 'error' ? theme.colors.error : theme.colors.gray900};
- font-size: 28px;
font-weight: ${({ theme }) => theme.fontWeight.bold};
+ font-size: 28px;
+
font-variant-numeric: tabular-nums;
`;
const SkeletonBlock = styled.div`
border-radius: ${({ theme }) => theme.borderRadius.sm};
-
background: ${({ theme }) => theme.colors.gray100};
`;
diff --git a/admin/src/pages/flyway/FlywayViewer.tsx b/admin/src/pages/flyway/FlywayViewer.tsx
index 3508a696..34d079e2 100644
--- a/admin/src/pages/flyway/FlywayViewer.tsx
+++ b/admin/src/pages/flyway/FlywayViewer.tsx
@@ -367,6 +367,7 @@ const collectTables = (migrations: MigrationItem[]) => {
const Header = styled.div`
margin-bottom: ${({ theme }) => theme.spacing.md};
+
display: flex;
gap: ${({ theme }) => theme.spacing.md};
align-items: center;
@@ -376,6 +377,7 @@ const Chip = styled.span`
padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md};
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.full};
+
color: ${({ theme }) => theme.colors.gray600};
font-size: ${({ theme }) => theme.fontSize.sm};
`;
@@ -386,52 +388,60 @@ const Spacer = styled.div`
const Pipeline = styled.div`
margin: ${({ theme }) => theme.spacing.md} 0;
+
display: flex;
gap: ${({ theme }) => theme.spacing.md};
`;
const Stage = styled.div`
- flex: 1;
padding: ${({ theme }) => theme.spacing.md};
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+
+ flex: 1;
+
background-color: ${({ theme }) => theme.colors.white};
`;
const StageTop = styled.div`
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const StageCount = styled.div`
margin-top: ${({ theme }) => theme.spacing.xs};
- font-size: ${({ theme }) => theme.fontSize['2xl']};
+
font-weight: ${({ theme }) => theme.fontWeight.bold};
+ font-size: ${({ theme }) => theme.fontSize['2xl']};
`;
const Banner = styled.div<{ $variant: 'error' | 'warning' }>`
margin-bottom: ${({ theme }) => theme.spacing.md};
padding: ${({ theme }) => theme.spacing.md};
border-radius: ${({ theme }) => theme.borderRadius.md};
- font-size: ${({ theme }) => theme.fontSize.sm};
+
background-color: ${({ $variant }) =>
$variant === 'error' ? '#FDEAEA' : '#FFF4E5'};
color: ${({ theme, $variant }) =>
$variant === 'error' ? theme.colors.error : '#B45309'};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const Toolbar = styled.div`
margin-bottom: ${({ theme }) => theme.spacing.md};
+
display: flex;
gap: ${({ theme }) => theme.spacing.md};
align-items: center;
`;
const SearchInput = styled.input`
- flex: 1;
padding: ${({ theme }) => theme.spacing.sm};
border: 1px solid ${({ theme }) => theme.colors.gray300};
border-radius: ${({ theme }) => theme.borderRadius.md};
+
+ flex: 1;
+
font-size: ${({ theme }) => theme.fontSize.sm};
`;
@@ -439,6 +449,7 @@ const Select = styled.select`
padding: ${({ theme }) => theme.spacing.sm};
border: 1px solid ${({ theme }) => theme.colors.gray300};
border-radius: ${({ theme }) => theme.borderRadius.md};
+
font-size: ${({ theme }) => theme.fontSize.sm};
`;
@@ -453,17 +464,19 @@ const NextSafe = styled.span`
`;
const Panes = styled.div`
- display: flex;
- gap: ${({ theme }) => theme.spacing.md};
height: calc(100vh - 400px);
min-height: 400px;
+
+ display: flex;
+ gap: ${({ theme }) => theme.spacing.md};
`;
const List = styled.div`
- width: 46%;
overflow: auto;
+ width: 46%;
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+
background-color: ${({ theme }) => theme.colors.white};
`;
@@ -490,27 +503,28 @@ const RowButton = styled.button<{
width: 100%;
padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md};
border-bottom: 1px solid ${({ theme }) => theme.colors.gray100};
+ box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) =>
+ $conflict
+ ? `inset 3px 0 0 ${theme.colors.error}`
+ : $leapfrogSeverity
+ ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}`
+ : $active
+ ? `inset 3px 0 0 ${theme.colors.primary}`
+ : 'none'};
display: flex;
gap: ${({ theme }) => theme.spacing.sm};
align-items: center;
- cursor: pointer;
- text-align: left;
background-color: ${({ theme, $active, $conflict, $leapfrogSeverity }) => {
if ($conflict) return '#FDEAEA';
const leap = leapfrogBackground($leapfrogSeverity, $active);
if (leap) return leap;
return $active ? theme.colors.gray50 : theme.colors.white;
}};
- box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) =>
- $conflict
- ? `inset 3px 0 0 ${theme.colors.error}`
- : $leapfrogSeverity
- ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}`
- : $active
- ? `inset 3px 0 0 ${theme.colors.primary}`
- : 'none'};
+ text-align: left;
+
+ cursor: pointer;
&:hover {
background-color: ${({ theme }) => theme.colors.gray50};
@@ -519,54 +533,62 @@ const RowButton = styled.button<{
const Version = styled.span`
min-width: 64px;
+
font-family: monospace;
font-weight: ${({ theme }) => theme.fontWeight.semibold};
font-size: ${({ theme }) => theme.fontSize.sm};
`;
const Desc = styled.span`
- flex: 1;
overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
+
+ flex: 1;
+
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.sm};
+ white-space: nowrap;
+
+ text-overflow: ellipsis;
`;
const LeapfrogBadge = styled.span<{ $severity: ConflictSeverity }>`
- flex-shrink: 0;
padding: 1px ${({ theme }) => theme.spacing.xs};
border-radius: ${({ theme }) => theme.borderRadius.sm};
- font-size: ${({ theme }) => theme.fontSize.xs};
- font-weight: ${({ theme }) => theme.fontWeight.semibold};
- white-space: nowrap;
- color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')};
+
+ flex-shrink: 0;
+
background-color: ${({ $severity }) =>
$severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'};
+ color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')};
+ font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
+ white-space: nowrap;
`;
const Tag = styled.span`
padding: 0 ${({ theme }) => theme.spacing.xs};
border-radius: ${({ theme }) => theme.borderRadius.sm};
+
background-color: ${({ theme }) => theme.colors.gray100};
color: ${({ theme }) => theme.colors.gray600};
font-size: ${({ theme }) => theme.fontSize.xs};
`;
const Source = styled.span`
+ color: ${({ theme }) => theme.colors.gray500};
font-family: monospace;
font-size: ${({ theme }) => theme.fontSize.xs};
- color: ${({ theme }) => theme.colors.gray500};
`;
const StatusBadge = styled.span<{ $status: MigrationStatus }>`
padding: 0 ${({ theme }) => theme.spacing.sm};
border-radius: ${({ theme }) => theme.borderRadius.sm};
- font-size: ${({ theme }) => theme.fontSize.xs};
+
+ background-color: ${({ $status }) => statusBackground($status)};
+ color: ${({ theme, $status }) => statusColor(theme, $status)};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
white-space: nowrap;
- color: ${({ theme, $status }) => statusColor(theme, $status)};
- background-color: ${({ $status }) => statusBackground($status)};
`;
const statusColor = (
@@ -587,30 +609,34 @@ const statusBackground = (status: MigrationStatus) => {
};
const Detail = styled.div`
- flex: 1;
overflow: auto;
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+
+ flex: 1;
+
background-color: ${({ theme }) => theme.colors.white};
`;
const DetailBar = styled.div`
padding: ${({ theme }) => theme.spacing.md};
border-bottom: 1px solid ${({ theme }) => theme.colors.gray200};
+
display: flex;
- align-items: center;
gap: ${({ theme }) => theme.spacing.md};
+ align-items: center;
`;
const FileName = styled.span`
font-family: monospace;
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const DetailMeta = styled.div`
padding: ${({ theme }) => theme.spacing.md};
border-bottom: 1px solid ${({ theme }) => theme.colors.gray100};
+
color: ${({ theme }) => theme.colors.gray600};
font-size: ${({ theme }) => theme.fontSize.sm};
`;
@@ -619,38 +645,43 @@ const LeapfrogWarning = styled.div<{ $severity: ConflictSeverity }>`
margin: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md}
0;
padding: ${({ theme }) => theme.spacing.md};
- border-radius: ${({ theme }) => theme.borderRadius.md};
border: 1px solid
${({ $severity }) => ($severity === 'COLUMN' ? '#FCA5A5' : '#FCD34D')};
+ border-radius: ${({ theme }) => theme.borderRadius.md};
+
background-color: ${({ $severity }) =>
$severity === 'COLUMN' ? '#FFF5F5' : '#FFFBEB'};
`;
const LeapfrogWarningTitle = styled.div`
- font-size: ${({ theme }) => theme.fontSize.sm};
- font-weight: ${({ theme }) => theme.fontWeight.semibold};
margin-bottom: ${({ theme }) => theme.spacing.xs};
+
+ font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const LeapfrogWarningBody = styled.p`
- font-size: ${({ theme }) => theme.fontSize.sm};
- color: ${({ theme }) => theme.colors.gray700};
margin-bottom: ${({ theme }) => theme.spacing.xs};
+
+ color: ${({ theme }) => theme.colors.gray700};
+ font-size: ${({ theme }) => theme.fontSize.sm};
line-height: 1.5;
`;
const LeapfrogWarningDetail = styled.div`
- font-size: ${({ theme }) => theme.fontSize.xs};
- color: ${({ theme }) => theme.colors.gray600};
display: flex;
- align-items: center;
gap: ${({ theme }) => theme.spacing.xs};
flex-wrap: wrap;
+ align-items: center;
+
+ color: ${({ theme }) => theme.colors.gray600};
+ font-size: ${({ theme }) => theme.fontSize.xs};
`;
const LeapfrogTag = styled.code<{ $severity: ConflictSeverity }>`
padding: 1px 5px;
border-radius: ${({ theme }) => theme.borderRadius.sm};
+
background-color: ${({ $severity }) =>
$severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'};
font-size: ${({ theme }) => theme.fontSize.xs};
@@ -662,6 +693,7 @@ const ScriptArea = styled.div`
const PendingNote = styled.p`
padding: ${({ theme }) => theme.spacing.lg};
+
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.sm};
`;
@@ -673,6 +705,7 @@ const StateText = styled.p`
const Empty = styled.p`
padding: ${({ theme }) => theme.spacing.lg};
+
color: ${({ theme }) => theme.colors.gray400};
font-size: ${({ theme }) => theme.fontSize.sm};
`;
diff --git a/admin/src/pages/flyway/WipRegisterForm.tsx b/admin/src/pages/flyway/WipRegisterForm.tsx
index d4c08d2d..eb2b8cd2 100644
--- a/admin/src/pages/flyway/WipRegisterForm.tsx
+++ b/admin/src/pages/flyway/WipRegisterForm.tsx
@@ -149,11 +149,13 @@ const Panel = styled.div`
padding: ${({ theme }) => theme.spacing.lg};
border: 1px solid ${({ theme }) => theme.colors.warning};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+
background-color: #fffdf7;
`;
const PanelHeader = styled.div`
margin-bottom: ${({ theme }) => theme.spacing.md};
+
display: flex;
gap: ${({ theme }) => theme.spacing.md};
align-items: center;
@@ -166,58 +168,66 @@ const PanelHeader = styled.div`
const CloseButton = styled.button`
margin-left: auto;
+
color: ${({ theme }) => theme.colors.gray500};
+
cursor: pointer;
`;
const Row = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing.md};
- align-items: flex-end;
flex-wrap: wrap;
+ align-items: flex-end;
`;
const Field = styled.div<{ $grow?: boolean }>`
+ min-width: 140px;
+
display: flex;
- flex-direction: column;
gap: ${({ theme }) => theme.spacing.xs};
flex: ${({ $grow }) => ($grow ? 1 : 'none')};
- min-width: 140px;
+ flex-direction: column;
`;
const Label = styled.span`
color: ${({ theme }) => theme.colors.gray500};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
`;
const Input = styled.input`
padding: ${({ theme }) => theme.spacing.sm};
border: 1px solid ${({ theme }) => theme.colors.gray300};
border-radius: ${({ theme }) => theme.borderRadius.md};
+
font-size: ${({ theme }) => theme.fontSize.sm};
`;
const Segment = styled.div`
- display: inline-flex;
+ overflow: hidden;
border: 1px solid ${({ theme }) => theme.colors.gray300};
border-radius: ${({ theme }) => theme.borderRadius.md};
- overflow: hidden;
+
+ display: inline-flex;
`;
const SegmentButton = styled.button<{ $active: boolean }>`
padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md};
- font-size: ${({ theme }) => theme.fontSize.sm};
- cursor: pointer;
+
background-color: ${({ theme, $active }) =>
$active ? theme.colors.primary : theme.colors.white};
color: ${({ theme, $active }) =>
$active ? theme.colors.white : theme.colors.gray600};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+
+ cursor: pointer;
`;
const Notice = styled.p<{ $error?: boolean }>`
margin-top: ${({ theme }) => theme.spacing.md};
- font-size: ${({ theme }) => theme.fontSize.sm};
+
color: ${({ theme, $error }) =>
$error ? theme.colors.error : theme.colors.success};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
diff --git a/admin/src/routes/_admin/reviewers/index.tsx b/admin/src/routes/_admin/reviewers/index.tsx
index 58d31d2c..a748dc87 100644
--- a/admin/src/routes/_admin/reviewers/index.tsx
+++ b/admin/src/routes/_admin/reviewers/index.tsx
@@ -227,9 +227,9 @@ const Panel = styled.section`
padding: ${({ theme }) => theme.spacing.lg};
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+ box-shadow: ${({ theme }) => theme.shadows.sm};
background: ${({ theme }) => theme.colors.white};
- box-shadow: ${({ theme }) => theme.shadows.sm};
`;
const FilterBar = styled.div`
@@ -254,17 +254,18 @@ const TabButton = styled.button<{ $selected: boolean }>`
padding: 6px 16px;
border: none;
border-radius: 6px;
+ box-shadow: ${({ theme, $selected }) =>
+ $selected ? theme.shadows.sm : 'none'};
background: ${({ theme, $selected }) =>
$selected ? theme.colors.white : 'transparent'};
- box-shadow: ${({ theme, $selected }) =>
- $selected ? theme.shadows.sm : 'none'};
color: ${({ theme, $selected }) =>
$selected ? theme.colors.gray900 : theme.colors.gray500};
- cursor: pointer;
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme, $selected }) =>
$selected ? theme.fontWeight.semibold : theme.fontWeight.normal};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+
+ cursor: pointer;
transition: all 0.2s ease-in-out;
`;
@@ -298,10 +299,11 @@ const ActionButton = styled.button`
background: ${({ theme }) => theme.colors.white};
color: ${({ theme }) => theme.colors.gray700};
- cursor: pointer;
font-size: ${({ theme }) => theme.fontSize.sm};
white-space: nowrap;
+ cursor: pointer;
+
&:hover {
background: ${({ theme }) => theme.colors.gray50};
}
@@ -309,14 +311,15 @@ const ActionButton = styled.button`
const ModalErrorOverlay = styled.div`
position: fixed;
- inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(17, 24, 39, 0.4);
+ background: rgb(17 24 39 / 40%);
+
+ inset: 0;
`;
const ModalErrorCard = styled.div`
@@ -365,12 +368,14 @@ const StripText = styled.span`
const StripNumber = styled.strong`
color: ${({ theme }) => theme.colors.primary};
font-weight: ${({ theme }) => theme.fontWeight.bold};
+
font-variant-numeric: tabular-nums;
`;
const StripLink = styled.a`
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.xs};
+
text-decoration: none;
&:hover {
@@ -404,8 +409,8 @@ const HeadRow = styled.tr`
background: ${({ theme }) => theme.colors.gray50};
color: ${({ theme }) => theme.colors.gray600};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
text-align: left;
}
@@ -421,6 +426,7 @@ const StatsLink = styled(Link)`
color: ${({ theme }) => theme.colors.gray700};
font-size: ${({ theme }) => theme.fontSize.sm};
+
text-decoration: none;
&:hover {
diff --git a/admin/src/routes/_admin/reviewers/stats.tsx b/admin/src/routes/_admin/reviewers/stats.tsx
index dce4e55c..2178b09c 100644
--- a/admin/src/routes/_admin/reviewers/stats.tsx
+++ b/admin/src/routes/_admin/reviewers/stats.tsx
@@ -297,17 +297,17 @@ const Panel = styled.section`
padding: ${({ theme }) => theme.spacing.lg};
border: 1px solid ${({ theme }) => theme.colors.gray200};
border-radius: ${({ theme }) => theme.borderRadius.lg};
+ box-shadow: ${({ theme }) => theme.shadows.sm};
background: ${({ theme }) => theme.colors.white};
- box-shadow: ${({ theme }) => theme.shadows.sm};
`;
const TwoColumn = styled.div`
display: grid;
- grid-template-columns: 2fr 1fr;
gap: ${({ theme }) => theme.spacing.md};
-
align-items: start;
+
+ grid-template-columns: 2fr 1fr;
`;
const PanelHead = styled.div`
@@ -320,8 +320,8 @@ const PanelHead = styled.div`
const PanelTitle = styled.h2`
color: ${({ theme }) => theme.colors.gray900};
- font-size: ${({ theme }) => theme.fontSize.base};
font-weight: ${({ theme }) => theme.fontWeight.semibold};
+ font-size: ${({ theme }) => theme.fontSize.base};
`;
const SelectRow = styled.div`
@@ -374,14 +374,14 @@ const RankBadge = styled.span<{ $first?: boolean }>`
$first ? theme.colors.primary : theme.colors.gray200};
color: ${({ theme, $first }) =>
$first ? theme.colors.white : theme.colors.gray600};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme }) => theme.fontWeight.bold};
+ font-size: ${({ theme }) => theme.fontSize.xs};
`;
const RankName = styled.span`
color: ${({ theme }) => theme.colors.gray900};
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.medium};
+ font-size: ${({ theme }) => theme.fontSize.sm};
`;
const RankMeta = styled.span`
@@ -389,6 +389,7 @@ const RankMeta = styled.span`
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.xs};
+
font-variant-numeric: tabular-nums;
`;
@@ -412,19 +413,20 @@ const PrCard = styled.div<{ $overdue: boolean }>`
border-radius: ${({ theme }) => theme.borderRadius.md};
display: grid;
- grid-template-columns: 1fr auto;
- grid-template-rows: auto auto;
- column-gap: ${({ theme }) => theme.spacing.md};
background: ${({ theme }) => theme.colors.white};
+
+ column-gap: ${({ theme }) => theme.spacing.md};
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
`;
const PrTitle = styled.a`
- grid-column: 1;
-
color: ${({ theme }) => theme.colors.gray900};
- font-size: ${({ theme }) => theme.fontSize.sm};
font-weight: ${({ theme }) => theme.fontWeight.medium};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+
+ grid-column: 1;
text-decoration: none;
&:hover {
@@ -435,24 +437,24 @@ const PrTitle = styled.a`
const PrMeta = styled.span`
margin-top: 2px;
- grid-column: 1;
-
color: ${({ theme }) => theme.colors.gray500};
font-size: ${({ theme }) => theme.fontSize.xs};
+
+ grid-column: 1;
`;
const PrDeadline = styled.span<{ $overdue: boolean }>`
- grid-row: 1 / 3;
- grid-column: 2;
-
align-self: center;
color: ${({ theme, $overdue }) =>
$overdue ? theme.colors.error : theme.colors.gray500};
- font-size: ${({ theme }) => theme.fontSize.xs};
font-weight: ${({ theme, $overdue }) =>
$overdue ? theme.fontWeight.semibold : theme.fontWeight.normal};
+ font-size: ${({ theme }) => theme.fontSize.xs};
+
font-variant-numeric: tabular-nums;
+ grid-column: 2;
+ grid-row: 1 / 3;
`;
const EmptyText = styled.p`
@@ -469,6 +471,7 @@ const BackLink = styled(Link)`
color: ${({ theme }) => theme.colors.gray700};
font-size: ${({ theme }) => theme.fontSize.sm};
+
text-decoration: none;
&:hover {
diff --git a/docs/ai-rules.md b/docs/ai-rules.md
index 670dc885..5af8d046 100644
--- a/docs/ai-rules.md
+++ b/docs/ai-rules.md
@@ -39,9 +39,11 @@ If any rule conflicts, higher priority rules ALWAYS win.
## Lint & Auto-Fix (Blocking)
- ESLint and Stylelint MUST be auto-fixed before task completion.
-- Auto-fix MUST be executed using workspace scripts:
+- Auto-fix MUST be executed using the workspace script:
- `pnpm --filter {workspace} lint:fix`
- - `pnpm --filter {workspace} stylelint:fix`
+- In workspaces with Stylelint, `lint:fix` MUST run ESLint first and Stylelint
+ second in the same script. They MUST NOT run in parallel because both tools
+ can write `.tsx` files.
- If auto-fix produces changes:
- Those changes MUST be reviewed.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 00000000..9860dc2b
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,119 @@
+# client 아키텍처 맵 (AI 리뷰어용)
+
+PR 리뷰 전에 읽는 코드베이스 지도다. 목표는 "아무 React/React Native 레포에나
+성립하는 리뷰"가 아니라 **봄봄의 실제 계약과 워크스페이스 경계를 근거로 한 리뷰**를
+가능하게 하는 것이다. 이 문서와 코드가 어긋나면 코드가 진실이다. 발견 시 문서 갱신을
+제안한다. 코딩 규칙은 `CONVENTIONS.md`와 그 문서가 가리키는 `docs/` 문서가 담당한다.
+
+## 저장소 구성
+
+| 워크스페이스 | 역할 | 주요 기술 |
+| --- | --- | --- |
+| `web` | 뉴스레터·아티클·챌린지·이벤트 사용자 웹 | React 19, Webpack, TanStack Router/Query, Emotion |
+| `app` | `web` WebView와 소셜 로그인·푸시·기기 기능을 제공하는 앱 | Expo 54, React Native 0.81, Firebase |
+| `admin` | 회원·콘텐츠·챌린지·이벤트 운영 웹 | React 19, Vite, TanStack Router/Query, Supabase 일부 사용 |
+| `maeil-mail` | 매일메일 구독·콘텐츠용 별도 웹 | React 19, Vite, TanStack Router/Query |
+| `shared` | theme·웹 UI·fetcher·생성 API·WebView 메시지 타입 | TypeScript package |
+
+`web`·`app`·`admin`·`maeil-mail`은 독립 배포 단위다. 같은 이름의 컴포넌트·훅·API가
+각 워크스페이스에 따로 있을 수 있으므로 한 곳의 패턴을 다른 곳에 무조건 적용하지 않는다.
+두 곳 이상이 실제로 공유하는 코드만 `shared` 영향 범위로 본다.
+
+## 공용 API 계약
+
+- 표준 HTTP 경로는 `shared/src/core/apis/fetcher.ts`의 `fetcher`다. 각 도메인은
+ `{domain}.api.ts`와 `{domain}.query.ts`로 감싸고 OpenAPI 생성 타입을 기준으로 한다.
+- 공용 fetcher는 기본 `ENV.baseUrl`, `credentials: 'include'`, JSON body를 사용한다.
+ 인증은 bearer token을 직접 보관하는 구조가 아니라 서버 세션 쿠키에 의존한다.
+- 실패 응답은 `ApiError(status, message, rawBody)`다. 호출부가 axios 전용 shape인
+ `error.response.status`를 전제하면 동작하지 않는다.
+- GET query는 값의 `toString()` 결과가 truthy인 항목만 직렬화한다. 배열은 쉼표 문자열이
+ 되며 빈 문자열·`undefined`는 사라진다. 다른 계약이 필요하면 모든 소비 API를 확인한다.
+- `shared/src/core/apis/generated/`, `openapi.d.ts`, `routeTree.gen.ts`는 생성물이다.
+ 직접 수정하지 않고 generator와 API spec을 통해 갱신한다.
+
+## 서버 상태와 오류 처리
+
+- 서버 상태는 TanStack Query가 담당한다. query factory를 사용하고 mutation은 커스텀
+ 훅으로 분리한다. mutation 후 관련 목록·상세·사용자 queryKey를 모두 무효화해야 한다.
+- `web`은 일반 네트워크/서버 오류를 최대 3회 재시도하지만 401은 재시도하지 않는다.
+ mutation은 중복 실행을 막기 위해 재시도하지 않는다.
+- `web`은 일부 비로그인 정상 쿼리의 401을 무시한다. 그 외 쿼리에서 로그인 profile
+ cache가 있는데 401이 나면 reload한다. queryKey나 profile cache 변경 시 새로고침 루프와
+ 오류 수집 누락을 함께 확인한다.
+- `admin`은 query 재시도 1회와 `refetchOnWindowFocus: false`, `maeil-mail`은 401 무재시도와
+ 일반 오류 최대 3회 정책이다. 캐시·재시도 정책은 워크스페이스별로 다르다.
+
+## web 라우팅·인증
+
+- TanStack Router file-based routing을 사용한다. 루트는 QueryClient·theme·`AuthProvider`를
+ 제공하고 WebView 인증/라우팅 listener와 분석 도구를 초기화한다.
+- `/_bombom` layout은 프로세스 전역 `isFirstVisit`에서 최초 한 번만 user profile을
+ 확인한다. 실패 시 `/`가 아닌 첫 진입 경로를 `/`로 돌린다. route별 상시 인증 guard가
+ 아니며 같은 SPA 세션의 이후 이동은 재검증하지 않는다.
+- 브라우저 로그인은 서버 OAuth 엔드포인트로 이동한다. 앱 로그인은 WebView 메시지로
+ native credential을 받아 `/auth/login/{provider}/native`를 호출한다.
+
+## WebView 양방향 계약 (`web` ↔ `app`)
+
+메시지 계약은 `shared/src/core/webview.ts`의 `WebToRNMessage`와 `RNToWebMessage`다.
+메시지 type·payload 변경 시 송신자와 수신자를 함께 본다. 한쪽만 바꾸면 런타임에서
+메시지가 조용히 무시될 수 있다.
+
+- `web → app`: 로그인 화면/결과, 외부 브라우저, device UUID, 알림 권한/설정, FCM 등록,
+ 이미지 저장 요청.
+- `app → web`: Google/Apple credential, 알림 목적지 URL, device UUID, 알림 권한 결과.
+- 웹은 브라우저 단독 실행도 지원한다. `window.ReactNativeWebView?.postMessage` optional
+ 접근과 `isWebView()` guard를 제거하면 일반 브라우저에서 오류가 난다.
+- 앱 `MainScreen`은 `ENV.webUrl`을 로드하고 message switch를 실행한다. 공유 cookie와 DOM
+ storage도 로그인·탐색 계약의 일부다.
+- 로그인 성공 후 웹은 미가입 사용자를 `/signup`으로 보내고 가입 사용자는 reload한다.
+ app은 overlay를 닫을 뿐 별도 인증 상태를 저장하지 않는다.
+
+## app 기기·푸시 계약
+
+- device UUID는 SecureStore에, FCM 등록용 memberId는 AsyncStorage에 저장한다. web의
+ `REGISTER_FCM_TOKEN` 메시지가 memberId/deviceUuid/token을 연결한다.
+- FCM 등록은 동시 중복을 막고 권한·세 값이 모두 있을 때만 서버에 올린다. token refresh도
+ 같은 경로를 사용한다.
+- 알림 클릭은 cold start/background/foreground 세 경로 모두 `NOTIFICATION_ROUTING`으로
+ 합쳐진다. 현재 `ARTICLE`(`/articles/{articleId}`)과 `EVENT`(`/event`)만 지원한다.
+- listener는 WebView `onLoadEnd` 후 등록되고 다음 load/unmount에서 해제된다. listener 추가
+ 시 cleanup 누락과 WebView 준비 전 메시지 유실을 확인한다.
+- 강제 업데이트 판정이 끝날 때까지 WebView를 렌더하지 않는다. 이 경로의 실패는 앱 진입
+ 전체를 막을 수 있다.
+
+## admin·maeil-mail 경계
+
+- admin의 `/_admin` layout은 회원 목록 API를 probe해 403일 때만 `/403`으로 보낸다.
+ 이를 완전한 로그인 guard로 과대 해석하지 않는다.
+- admin 일부 도구는 GitHub API·Lambda·Supabase 같은 별도 외부 계약을 쓴다. 직접 `fetch`가
+ 보여도 기존 adapter와 base URL을 먼저 확인한다.
+- Supabase는 reviewer 화면의 첫 사용 시 lazy 초기화한다. 환경변수 누락이 admin 전체
+ 장애가 되지 않도록 한 계약이므로 eager 초기화로 바꾸지 않는다.
+- `maeil-mail`은 `web` route가 아니라 별도 앱이다. route tree·QueryClient·환경변수·MSW를
+ 자체 소유한다. API shape 변경 시 `web`의 매일메일 소비처도 함께 검색한다.
+
+## AI 리뷰 시 고신호 확인 지점
+
+- `shared` 계약 변경 뒤 각 워크스페이스 소비처 누락.
+- WebView 메시지 송수신 한쪽만 변경하거나 일반 브라우저 guard 제거.
+- cookie 인증, 최초 방문 profile probe, 401 처리의 실제 전제를 깨는 변경.
+- mutation invalidation 누락, 워크스페이스별 캐시 정책 혼동, axios 오류 shape 전제.
+- FCM identity 연결, 알림 type→web route mapping, listener cleanup 누락.
+- 신규 route의 layout/guard 위치와 생성 route tree 수동 수정.
+- admin 외부 연동을 공용 API로 오인하거나 optional 의존성을 admin 전체 장애로 확대.
+
+## 리뷰어가 자주 확인할 파일
+
+| 파일 | 확인 이유 |
+| --- | --- |
+| `shared/src/core/apis/fetcher.ts` | cookie·오류·query 직렬화 공용 계약 |
+| `shared/src/core/webview.ts` | web/app 양방향 메시지 타입 |
+| `web/src/main.tsx` | QueryClient 재시도·401·오류 수집 정책 |
+| `web/src/routes/_bombom.tsx` | 최초 방문 profile 확인과 redirect |
+| `web/src/libs/webview/` | WebView 인증·routing·device/FCM 송신 |
+| `app/components/main/MainScreen.tsx` | WebView message 수신과 앱 진입 gate |
+| `app/hooks/useNotification.ts` | FCM 등록·수신·listener lifecycle |
+| `admin/src/routes/_admin.tsx` | admin 접근 probe |
+| `admin/src/lib/supabase.ts` | reviewer 기능의 optional 외부 의존성 |
diff --git a/maeil-mail/package.json b/maeil-mail/package.json
index 6c02c48d..89b4ce5f 100644
--- a/maeil-mail/package.json
+++ b/maeil-mail/package.json
@@ -8,8 +8,7 @@
"dev:msw": "VITE_ENABLE_MSW=true vite",
"build": "tsc --noEmit && vite build",
"lint": "eslint src --ext .ts,.tsx,.js,.jsx",
- "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix",
- "stylelint:fix": "stylelint './src/**/*.tsx' --fix",
+ "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix",
"format": "prettier --write . && stylelint './src/**/*.tsx' --fix",
"preview": "vite preview",
"type-check": "tsc --noEmit",
diff --git a/package.json b/package.json
index 196291d4..79a5e45a 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,6 @@
"web:build": "pnpm --filter web build",
"web:lint": "pnpm --filter web lint",
"web:lint:fix": "pnpm --filter web lint:fix",
- "web:stylelint:fix": "pnpm --filter web stylelint:fix",
"web:type-check": "pnpm --filter web type-check",
"web:test": "pnpm --filter web test",
"web:gen:api": "pnpm --filter web gen:api",
@@ -16,7 +15,6 @@
"admin:build": "pnpm --filter admin build",
"admin:lint": "pnpm --filter admin lint",
"admin:lint:fix": "pnpm --filter admin lint:fix",
- "admin:stylelint:fix": "pnpm --filter admin stylelint:fix",
"admin:type-check": "pnpm --filter admin type-check",
"admin:generate-openapi-types": "pnpm --filter admin generate-openapi-types",
"app:start": "pnpm --filter app start",
@@ -27,7 +25,6 @@
"maeil-mail:build": "pnpm --filter @bombom/maeil-mail build",
"maeil-mail:lint": "pnpm --filter @bombom/maeil-mail lint",
"maeil-mail:lint:fix": "pnpm --filter @bombom/maeil-mail lint:fix",
- "maeil-mail:stylelint:fix": "pnpm --filter @bombom/maeil-mail stylelint:fix",
"maeil-mail:format": "pnpm --filter @bombom/maeil-mail format",
"maeil-mail:type-check": "pnpm --filter @bombom/maeil-mail type-check",
"type-check": "pnpm -r type-check",
diff --git a/web/package.json b/web/package.json
index e6385eb8..50345772 100644
--- a/web/package.json
+++ b/web/package.json
@@ -8,8 +8,7 @@
"start:msw": "webpack serve --mode development --env ENABLE_MSW=true",
"build": "webpack --mode production",
"lint": "eslint src --ext .ts,.tsx,.js,.jsx",
- "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix",
- "stylelint:fix": "stylelint './src/**/*.tsx' --fix",
+ "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix",
"format": "prettier --write . && stylelint './src/**/*.tsx' --fix",
"gen:api": "dotenv -e .env -- bash ./scripts/generate-openapi-types.sh",
"type-check": "tsc --noEmit",
diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx
index b63f6220..ffe9f519 100644
--- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx
+++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx
@@ -157,7 +157,6 @@ const Weekday = styled(Text)`
const Divider = styled.div`
height: 1px;
-
background-color: ${COLORS.divider};
`;
@@ -168,7 +167,6 @@ const Grid = styled.div`
const DayCell = styled.div`
position: relative;
-
height: 28px;
display: flex;
@@ -186,7 +184,6 @@ const MostReadCircle = styled(Text)`
position: absolute;
top: 50%;
left: 50%;
-
width: 32px;
height: 32px;
border-radius: 50%;
@@ -204,7 +201,6 @@ const ReadUnderline = styled.span`
position: absolute;
bottom: 0;
left: 50%;
-
width: 26px;
height: 4px;
border-radius: 2px;
diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx
index 1d97af5c..59f2bba6 100644
--- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx
+++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx
@@ -146,9 +146,8 @@ const IconBox = styled.div`
height: 40px;
border-radius: 10px;
- flex-shrink: 0;
-
display: flex;
+ flex-shrink: 0;
align-items: center;
justify-content: center;
@@ -191,11 +190,12 @@ const StatUnit = styled(Text)`
`;
const ChangePill = styled(Text)`
- align-self: flex-start;
padding: 6px 12px;
border: 1px solid ${COLORS.pillBorder};
border-radius: 12px;
+ align-self: flex-start;
+
&& {
color: ${COLORS.pillText};
}
@@ -249,9 +249,8 @@ const RankBadge = styled(Text)`
height: 18px;
border-radius: 50%;
- flex-shrink: 0;
-
display: flex;
+ flex-shrink: 0;
align-items: center;
justify-content: center;
@@ -259,12 +258,12 @@ const RankBadge = styled(Text)`
`;
const NewsletterName = styled(Text)`
+ overflow: hidden;
min-width: 0;
- overflow: hidden;
+ white-space: nowrap;
text-overflow: ellipsis;
- white-space: nowrap;
&& {
color: ${COLORS.newsletterText};
@@ -273,7 +272,6 @@ const NewsletterName = styled(Text)`
const NewsletterCount = styled(Text)`
margin-left: auto;
-
flex-shrink: 0;
&& {
diff --git a/web/src/pages/system/components/NotFoundCard.tsx b/web/src/pages/system/components/NotFoundCard.tsx
index f5958af5..9edba29f 100644
--- a/web/src/pages/system/components/NotFoundCard.tsx
+++ b/web/src/pages/system/components/NotFoundCard.tsx
@@ -67,8 +67,8 @@ const DescriptionWrapper = styled.div`
const Description = styled.p``;
const GoHomeButton = styled(Button)`
- margin-top: 12px;
max-width: 380px;
+ margin-top: 12px;
font: ${({ theme }) => theme.fonts.t7Bold};
`;