From ae6f3353037831a500fd726c97cff3a94882c197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 00:39:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20AI=20=EB=A6=AC=EB=B7=B0=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/ai-review-workflow/SKILL.md | 54 ++++++-- AGENTS.md | 5 + .../components/reviewers/ReviewerModals.tsx | 21 ++-- .../reviewers/ReviewersTableBody.tsx | 25 ++-- .../components/reviewers/SummaryStatCard.tsx | 11 +- admin/src/pages/flyway/FlywayViewer.tsx | 115 +++++++++++------ admin/src/pages/flyway/WipRegisterForm.tsx | 28 +++-- admin/src/routes/_admin/reviewers/index.tsx | 24 ++-- admin/src/routes/_admin/reviewers/stats.tsx | 39 +++--- docs/architecture.md | 119 ++++++++++++++++++ .../MonthlyReadingCalendar.tsx | 4 - .../MonthlyReportStats.tsx | 14 +-- .../pages/system/components/NotFoundCard.tsx | 2 +- 13 files changed, 337 insertions(+), 124 deletions(-) create mode 100644 docs/architecture.md 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/AGENTS.md b/AGENTS.md index d6659122..5e79b49e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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/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/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}; `; From d97acdef06732818be54690aa62d07b9e0a27fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 00:57:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=88=98=EC=A0=95=20=EC=88=9C=EC=84=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=20=EC=B1=85=EC=9E=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierrc | 1 + AGENTS.md | 8 ++++---- admin/package.json | 4 ++-- docs/ai-rules.md | 8 ++++++-- maeil-mail/package.json | 2 +- web/package.json | 2 +- 6 files changed, 15 insertions(+), 10 deletions(-) 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 5e79b49e..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: diff --git a/admin/package.json b/admin/package.json index 77a19c89..29cebc87 100644 --- a/admin/package.json +++ b/admin/package.json @@ -8,9 +8,9 @@ "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", + "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", "stylelint:fix": "stylelint './src/**/*.tsx' --fix", - "format": "prettier --write .", + "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/docs/ai-rules.md b/docs/ai-rules.md index 670dc885..49f378ad 100644 --- a/docs/ai-rules.md +++ b/docs/ai-rules.md @@ -39,9 +39,13 @@ 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. +- `pnpm --filter {workspace} stylelint:fix` MAY be used separately when only + CSS-in-JS declarations need fixing. - If auto-fix produces changes: - Those changes MUST be reviewed. diff --git a/maeil-mail/package.json b/maeil-mail/package.json index 6c02c48d..763731f5 100644 --- a/maeil-mail/package.json +++ b/maeil-mail/package.json @@ -8,7 +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", + "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", "stylelint:fix": "stylelint './src/**/*.tsx' --fix", "format": "prettier --write . && stylelint './src/**/*.tsx' --fix", "preview": "vite preview", diff --git a/web/package.json b/web/package.json index e6385eb8..cf4b80ba 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +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", + "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", "stylelint:fix": "stylelint './src/**/*.tsx' --fix", "format": "prettier --write . && stylelint './src/**/*.tsx' --fix", "gen:api": "dotenv -e .env -- bash ./scripts/generate-openapi-types.sh", From f9c9f6138eaf716cfda3e269f5a388b7087d2d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Sun, 19 Jul 2026 01:03:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=A4=91=EB=B3=B5=20Stylelint=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/package.json | 1 - docs/ai-rules.md | 2 -- maeil-mail/package.json | 1 - package.json | 3 --- web/package.json | 1 - 5 files changed, 8 deletions(-) diff --git a/admin/package.json b/admin/package.json index 29cebc87..0ebfec94 100644 --- a/admin/package.json +++ b/admin/package.json @@ -9,7 +9,6 @@ "preview": "vite preview", "lint": "eslint src --ext .ts,.tsx,.js,.jsx", "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", - "stylelint: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/docs/ai-rules.md b/docs/ai-rules.md index 49f378ad..5af8d046 100644 --- a/docs/ai-rules.md +++ b/docs/ai-rules.md @@ -44,8 +44,6 @@ If any rule conflicts, higher priority rules ALWAYS win. - 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. -- `pnpm --filter {workspace} stylelint:fix` MAY be used separately when only - CSS-in-JS declarations need fixing. - If auto-fix produces changes: - Those changes MUST be reviewed. diff --git a/maeil-mail/package.json b/maeil-mail/package.json index 763731f5..89b4ce5f 100644 --- a/maeil-mail/package.json +++ b/maeil-mail/package.json @@ -9,7 +9,6 @@ "build": "tsc --noEmit && vite build", "lint": "eslint src --ext .ts,.tsx,.js,.jsx", "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", - "stylelint: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 cf4b80ba..50345772 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,6 @@ "build": "webpack --mode production", "lint": "eslint src --ext .ts,.tsx,.js,.jsx", "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix && stylelint './src/**/*.tsx' --fix", - "stylelint: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",