Skip to content

Accessibility 추가 지원#214

Merged
baekteun merged 4 commits intomasterfrom
feature/accessibility-enchancement
Mar 29, 2026
Merged

Accessibility 추가 지원#214
baekteun merged 4 commits intomasterfrom
feature/accessibility-enchancement

Conversation

@baekteun
Copy link
Copy Markdown
Member

@baekteun baekteun commented Mar 15, 2026

💡 개요

Accessibility 추가 지원

Summary by CodeRabbit

  • 새로운 기능

    • 식사 정보를 텍스트 또는 이미지로 클립보드에 복사 가능
  • 기능 개선

    • 앱 전반 접근성 강화(레이블·포커스·액션·음성 안내 개선, 닫기/뒤로 등 명시적 라벨)
    • 동작 감소·투명도 감소 설정 지원(애니메이션·시각효과 자동 조정 및 대체 표현)
    • 리뷰 토스트에 명시적 닫기 동작 추가 및 접근성 기반 동작 개선
    • 시간표에 전체 요일 표기 및 오늘 강조 추가
  • 디자인

    • 색상 조정 및 UI 히트 영역·간격 등 미세조정

@github-actions
Copy link
Copy Markdown
Contributor

✅ PR의 Assign 자동 지정을 성공했어요!

@baekteun

@github-actions
Copy link
Copy Markdown
Contributor

🛠️ 이슈와 PR의 Labels 동기화를 스킵했어요.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 15, 2026

Walkthrough

앱 전반에 걸쳐 접근성(레이블·힌트·트레이트·작업)과 접근성 환경(감소된 모션·투명도) 반영을 추가하고, 몇몇 컴포넌트에 UI/동작 조정(터치 영역, 안전영역 버튼 배치, 색상, public API 추가)을 적용했습니다.

Changes

Cohort / File(s) Summary
위젯
Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift
위젯 행과 가이드 텍스트에 접근성 레이블·포커스 추가 및 초기 포커스 지연 설정.
알레르기 설정
Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift
스크롤/안전영역 구조 조정, 알레르기 항목에 접근성 라벨·트레이트·children 무시 추가 및 선택 상태 반영.
리뷰 토스트 / 메인 뷰
Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift, Projects/Feature/MainFeature/Sources/MainView.swift
ReviewToast에 onDismiss 추가, 접근성 라벨/힌트/액션 추가·감소된 투명도/모션 분기 처리, MainView에서 접근성 공지·트레이트·감소모션 고려한 애니메이션 보강.
식단 주간 뷰
Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
밀접한 공유 접근성 작업(텍스트·이미지 복사) 추가 및 접근성 라벨에 식사 텍스트 포함.
학교 설정
Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift
accessibilityReduceMotion 반영해 애니메이션 조건화, 전공 필드·학교 목록의 접근성 레이블/힌트 및 버튼 기반 행으로 변경.
설정 토글
Projects/Feature/SettingsFeature/Sources/SettingsView.swift
토글에 accessibilityElement/라벨/값/트레이트/액션 추가, iOS 버전별 토글 트레이트 처리 도입.
시간표: fullWeekdays 및 접근성
Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift, Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
WeeklyTimeTable에 public fullWeekdays 추가 및 전달, 열·셀 접근성 라벨과 오늘 기반 스타일링 적용.
디자인 시스템: 색상 변경
Projects/UserInterface/DesignSystem/Resources/Colors.xcassets/.../Contents.json
UnselectedPrimary 색상 RGB 값 조정.
디자인 시스템 컴포넌트 접근성·모션
Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift, Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift, Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift, Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift, Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift, Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
reduceMotion/reduceTransparency 환경값 추가, 조건부 애니메이션·오버레이·투명도 처리, 모달·닫기 액션 및 버튼 라벨 추가, TWButton에 iOS 26+ glassEffect 분기, 폰트 색상 적용 방식 변경.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

✨ Feature

Poem

🐰
발끝으로 소곤대는 작은 수정들,
모든 탭과 토글에 목소리를 불어넣네.
깡총깡총 접근성 길 따라,
눈부심 줄이고 동작 살며시,
모두가 더 가깝게 쓰게 된당 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목 '접근성 추가 지원'은 변경사항의 핵심 내용(접근성 개선)을 명확하게 반영하고 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/accessibility-enchancement
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 앱 전반에 걸쳐 접근성 지원을 강화하는 데 중점을 둡니다. 다양한 UI 컴포넌트에 스크린 리더를 위한 상세한 정보를 제공하고, 사용자의 접근성 설정에 따라 애니메이션 및 시각적 효과를 조절하여 더 많은 사용자가 앱을 편리하게 이용할 수 있도록 개선했습니다.

Highlights

  • 접근성 레이블 및 동작 추가: 위젯, 알레르기 설정, 리뷰 토스트, 주간 식단 및 시간표 등 다양한 UI 요소에 접근성 레이블, 힌트, 특성 및 동작을 추가하여 스크린 리더 사용성을 개선했습니다.
  • 접근성 환경 설정 반영: 애니메이션 감소(accessibilityReduceMotion) 및 투명도 감소(accessibilityReduceTransparency) 환경 변수를 적용하여 사용자의 접근성 선호도에 따라 UI 동작 및 시각적 효과를 조절했습니다.
  • 공통 UI 컴포넌트 접근성 향상: 바텀 시트, 뒤로 가기 버튼, 텍스트 필드, 탭바 항목과 같은 공통 UI 컴포넌트의 접근성을 향상시켜 앱 전반의 일관된 접근성 경험을 제공합니다.
  • 상세한 정보 제공: 주간 식단 카드에 텍스트/이미지 복사 접근성 동작을 추가하고, 시간표 뷰의 요일 및 과목 셀에 상세한 정보를 포함하는 접근성 레이블을 적용하여 더 많은 정보를 제공합니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift
    • 위젯 미리보기를 접근성에서 숨김 처리했습니다.
    • 위젯 카드 전체에 접근성 레이블을 추가하고 자식 요소는 무시하도록 설정했습니다.
  • Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift
    • 알레르기 항목에 접근성 레이블과 버튼/선택됨 특성을 추가했습니다.
  • Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift
    • 리뷰 토스트에 onDismiss 클로저를 추가했습니다.
    • accessibilityReduceTransparency 환경 변수를 추가하고 투명도 감소 설정에 따라 배경색을 조절했습니다.
    • 별 아이콘을 접근성에서 숨김 처리했습니다.
    • 토스트에 접근성 레이블, 힌트, 그리고 닫기 동작을 추가했습니다.
  • Projects/Feature/MainFeature/Sources/MainView.swift
    • UIKit을 임포트했습니다.
    • accessibilityReduceMotion 환경 변수를 추가했습니다.
    • 학교/학년 정보에 정적 텍스트 접근성 특성을 추가했습니다.
    • 메뉴 탭의 기존 접근성 레이블과 힌트를 제거했습니다.
    • 탭 변경 시 접근성 알림을 게시하도록 onChange를 추가했습니다.
    • 리뷰 토스트의 애니메이션을 reduceMotion 설정에 따라 조건부로 적용했습니다.
    • 날짜 선택 버튼의 접근성 힌트를 제거하고 버튼 특성을 제거했습니다.
  • Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
    • 식단 카드에 식단 내용을 포함하는 상세한 접근성 레이블을 추가했습니다.
    • 식단 카드에 텍스트 및 이미지 복사 접근성 동작을 추가했습니다.
  • Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift
    • accessibilityReduceMotion 환경 변수를 추가하고 애니메이션에 적용했습니다.
    • 학과 선택 필드의 접근성 레이블과 힌트를 개선하고 버튼 특성을 추가했습니다.
    • 일부 프레임의 최소 높이를 44로 설정하여 터치 영역을 확보했습니다.
  • Projects/Feature/SettingsFeature/Sources/SettingsView.swift
    • 토글 스위치에 대한 포괄적인 접근성 레이블, 값, 특성 및 동작을 추가했습니다.
    • iOS 17 이상에서 isToggle 특성을 사용하는 헬퍼 프로퍼티를 추가했습니다.
  • Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift
    • WeeklyTimeTable 구조체에 fullWeekdays 속성을 추가하고 초기화 시 설정하도록 했습니다.
    • 주간 시간표 생성 로직에서 fullWeekdays 배열을 생성하도록 업데이트했습니다.
  • Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
    • 요일 헤더에 fullWeekdays를 사용하여 상세한 접근성 레이블을 추가했습니다.
    • 교시 번호에 접근성 레이블을 추가했습니다.
    • 각 시간표 셀에 요일, 교시, 과목 정보를 포함하는 상세한 접근성 레이블을 추가했습니다.
  • Projects/UserInterface/DesignSystem/Resources/Colors.xcassets/DesignSystem/UnselectedPrimary.colorset/Contents.json
    • UnselectedPrimary 색상의 RGB 값을 업데이트했습니다.
  • Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift
    • accessibilityReduceMotionaccessibilityReduceTransparency 환경 변수를 추가했습니다.
    • 투명도 감소 설정에 따라 바텀 시트 오버레이의 배경색을 조건부로 적용했습니다.
    • 오버레이에 '닫기' 접근성 레이블과 버튼 특성을 추가했습니다.
    • 바텀 시트 내용에 모달 특성과 Escape 키 동작을 추가했습니다.
    • 바텀 시트 애니메이션을 reduceMotion 설정에 따라 조건부로 적용했습니다.
  • Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift
    • 뒤로 가기 버튼에 '뒤로' 접근성 레이블을 추가했습니다.
  • Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift
    • accessibilityReduceMotion 환경 변수를 추가하고 애니메이션에 적용했습니다.
    • 텍스트 필드에 플레이스홀더를 기반으로 접근성 레이블을 추가했습니다.
    • 플레이스홀더 텍스트를 접근성에서 숨김 처리했습니다.
    • 입력 내용 삭제 버튼에 접근성 레이블을 추가하고, 필요할 때만 보이도록 설정했습니다.
  • Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift
    • 탭바 항목 텍스트의 최소 높이를 44로 설정했습니다.
    • 탭바 항목에 contentShape(Rectangle())을 추가하여 터치 영역을 명확히 했습니다.
Activity
  • 제공된 정보에 따르면, 이 PR에는 아직 활동(댓글, 리뷰 등)이 없습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이 PR은 앱 전반에 걸쳐 접근성 지원을 크게 향상시키는 중요한 변경 사항들을 포함하고 있습니다. VoiceOver 사용자를 위한 레이블, 힌트, 액션 추가, 동적 타입 및 '동작 줄이기'/'투명도 줄이기' 설정 존중, 터치 영역 확대 등 많은 부분이 개선되었습니다. 전반적으로 훌륭한 작업이지만, 컴파일 오류를 유발할 수 있는 코드와 혼란을 줄 수 있는 버전 체크 코드에 대한 몇 가지 수정 사항을 제안합니다.

Comment on lines 156 to 157
let accessibilityText: String = "\(titleText) \(calText) 칼로리. \(mealTexts.joined(separator: ", "))"
let mealTexts: [String] = subMeal.meals.map { mealDisplay(meal: $0) }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

상수 mealTexts가 정의되기 전에 accessibilityText에서 사용되어 컴파일 오류가 발생합니다. mealTexts의 정의를 accessibilityText 정의 앞으로 옮겨주세요.

Suggested change
let accessibilityText: String = "\(titleText) \(calText) 칼로리. \(mealTexts.joined(separator: ", "))"
let mealTexts: [String] = subMeal.meals.map { mealDisplay(meal: $0) }
let mealTexts: [String] = subMeal.meals.map { mealDisplay(meal: $0) }
let accessibilityText: String = "\(titleText) \(calText) 칼로리. \(mealTexts.joined(separator: ", "))"

.accessibilityAction(.escape) { onDismiss() }

if #available(iOS 26.0, *) {
if #available(iOS 26.0, *), !reduceTransparency {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

#available(iOS 26.0, *) 확인은 존재하지 않는 iOS 버전을 대상으로 하고 있어 혼란을 줄 수 있습니다. glassEffect는 visionOS에서 사용 가능하므로, #if os(visionOS)와 같은 컴파일러 지시문을 사용하거나, 특정 iOS 기능을 확인하는 것이라면 실제 버전을 사용해야 합니다. 예를 들어 iOS 17.0 이나 visionOS 1.0을 확인하는 것이 더 명확할 것 같습니다. 이 코드가 의도한 바가 무엇인지 확인하고 수정하는 것을 권장합니다.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (3)
Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift (1)

85-86: 일관성을 위해 .isButton 트레이트 추가를 고려해보세요.

AllergySettingView에서는 .accessibilityAddTraits(.isButton)을 명시적으로 추가하고 있습니다. Button 내부에서 accessibilityElement(children: .ignore)를 사용할 때 버튼 트레이트가 자동으로 상속되지 않을 수 있으므로, 일관성과 명확성을 위해 트레이트를 추가하는 것이 좋습니다.

♻️ 제안된 수정
             .accessibilityElement(children: .ignore)
             .accessibilityLabel("\(widget.kind.title), \(widget.family.title)")
+            .accessibilityAddTraits(.isButton)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift` around lines
85 - 86, In AddWidgetView update the accessibility setup to explicitly add the
button trait: when using .accessibilityElement(children: .ignore) and setting
.accessibilityLabel("\(widget.kind.title), \(widget.family.title)"), also call
.accessibilityAddTraits(.isButton) (matching AllergySettingView) so the element
is always exposed as a button to assistive technologies; locate the chain where
accessibilityElement and accessibilityLabel are applied and append
accessibilityAddTraits(.isButton).
Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift (1)

51-51: 감소된 투명도 모드에서 반투명 오버레이가 유지됩니다.

Line 51에서 reduceTransparency == true일 때 Color.black.opacity(0.6)를 사용하면 배경이 계속 비쳐 보입니다. 이 분기는 불투명 색상을 써서 대비를 확실히 높이는 편이 더 안전합니다.

제안 diff
-                    (reduceTransparency ? Color.black.opacity(0.6) : Color.lightBox)
+                    (reduceTransparency ? Color.black : Color.lightBox)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift`
at line 51, In TWBottomSheet.swift the reduceTransparency branch currently uses
Color.black.opacity(0.6) which still allows background bleed; change the branch
handling for reduceTransparency (the variable/condition named reduceTransparency
in the TWBottomSheet overlay code) to use a fully opaque color (e.g.,
Color.black without opacity) or another high-contrast opaque color to ensure the
overlay is not translucent; update the conditional expression that returns
(reduceTransparency ? Color.black.opacity(0.6) : Color.lightBox) to return an
opaque color when reduceTransparency is true and verify visually or with the
provided [request_verification].
Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift (1)

35-35: 빈 placeholder 기본값일 때 접근성 라벨이 비어질 수 있습니다.

Line 35는 placeholder를 그대로 라벨로 쓰므로, 기본값("") 경로에서 무라벨 컨트롤이 될 수 있습니다. 기본 fallback 라벨을 두는 편이 안전합니다.

개선 예시
-                .accessibilityLabel(placeholder)
+                .accessibilityLabel(placeholder.isEmpty ? "텍스트 입력" : placeholder)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift` at
line 35, placeholder가 비어있을 때 접근성 라벨이 빈 문자열이 되는 문제입니다; TWTextField에서
.accessibilityLabel(placeholder)를 그대로 쓰는 대신 placeholder가 비어있으면 안전한 fallback 라벨을
사용하도록 수정하세요 (예: localized 기본 라벨 또는 accessibilityPlaceholder 변수). 즉 TWTextField의
placeholder 값을 검사하고 빈 문자열일 경우 대체 텍스트를 전달하도록 변경하세요.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Projects/Feature/MainFeature/Sources/MainView.swift`:
- Around line 99-107: The dismiss path currently passes a fixed animation
(.default) when calling viewStore.send(.hideReviewToast, animation: .default)
from the ReviewToast usage, which ignores the user's reduce motion setting;
change the view to read the accessibilityReduceMotion environment (e.g.
`@Environment`(\.accessibilityReduceMotion) var reduceMotion) and conditionally
pass animation: reduceMotion ? nil : .default (or use withAnimation only when
reduceMotion is false) for all places where you call
viewStore.send(.hideReviewToast, animation: .default) and similar dismiss/send
calls inside ReviewToast usage so dismiss respects reduce-motion.
- Line 201: Remove the .accessibilityRemoveTraits(.isButton) modifier from the
menu trigger in MainView so VoiceOver can correctly announce the control as a
button; locate the occurrence inside the MainView (the menu trigger in the view
body) and delete that modifier (do not replace it with another trait removal),
ensuring the element retains its default .isButton accessibility trait.

In `@Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift`:
- Around line 220-229: The accessibility action ".accessibilityAction(named:
\"이미지로 복사\")" is always registered but only works on iOS 16+; move the
availability check outwards so the modifier is only applied on iOS 16 and above.
Concretely, wrap the view/modifier application that adds the accessibilityAction
in an if `#available`(iOS 16.0, *) branch (or apply the modifier conditionally
using a Group) so that ImageRenderer(content: mealCardView), renderer.scale =
displayScale, UIPasteboard.general.image assignment and
TWLog.event(ShareMealImageEventLog()) are only reachable when the action is
actually registered. Ensure you reference the existing symbols
accessibilityAction, ImageRenderer, mealCardView, displayScale,
UIPasteboard.general, TWLog and ShareMealImageEventLog when updating the view
modifiers.

In `@Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift`:
- Around line 50-52: Several animation calls in SchoolSettingView still force
motion; update every hardcoded .animation(.default, ...) and every withAnimation
{ ... } to respect the reduceMotion flag. Replace occurrences of
.animation(.default, value: ...) with .animation(reduceMotion ? .none :
.default, value: ...) and change withAnimation { ... } to
withAnimation(reduceMotion ? nil : .default) { ... } (or
withAnimation(reduceMotion ? nil : .default) around the exact closure) for the
remaining spots (the other .animation usages and all withAnimation blocks in
SchoolSettingView) so all paths consistently honor reduceMotion.

In `@Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift`:
- Around line 36-45: Public init in WeeklyTimeTable must enforce array-length
invariants: inside WeeklyTimeTable.init validate that weekdays.count ==
fullWeekdays.count && weekdays.count == dates.count && weekdays.count ==
subjects.count (and if todayIndex != nil assert 0 <= todayIndex! <
weekdays.count); on violation call preconditionFailure (or throw/failable init
per project style) with a clear message so malformed inputs fail early; update
the initializer that assigns self.weekdays, self.fullWeekdays, self.dates,
self.periods, self.subjects, and self.todayIndex to run these checks before
assignment.

In `@Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift`:
- Around line 265-267: The accessibility label construction for accessLabel
currently appends ", 오늘" only when subject is non-empty; modify the ternary
branches that build accessLabel (the subject.isEmpty case and the non-empty case
in WeeklyTimeTableView where accessLabel is formed using fullWeekdayName,
period, subject and isToday) so that both branches include the isToday context —
e.g., append the same ", 오늘" suffix when isToday is true even for the
"\(fullWeekdayName) \(period)교시 수업 없음" branch — ensuring consistent vocal
feedback.

In
`@Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift`:
- Line 93: Add a performAnimation helper that checks reduceMotion and either
runs the closure directly or wraps it in withAnimation, then replace all
explicit withAnimation(...) calls in this file (the spots currently toggling
isShowing / related state) to call performAnimation { ... } so every state
change respects reduceMotion; keep the existing .animation(reduceMotion ? .none
: .default, value: isShowing) for implicit animations but ensure all explicit
withAnimation usages are swapped to performAnimation to enforce the
accessibility setting.

In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift`:
- Around line 63-65: The withAnimation call incorrectly disables animation in
both branches because it uses `reduceMotion ? .none : nil`; change it to enable
animation only when `reduceMotion` is false by swapping branches (e.g.,
`withAnimation(reduceMotion ? nil : .default) { text = "" }`), updating the
`withAnimation` invocation in TWTextField where `text` is cleared and
`reduceMotion` is checked so animation is suppressed only when `reduceMotion` is
true.

---

Nitpick comments:
In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift`:
- Around line 85-86: In AddWidgetView update the accessibility setup to
explicitly add the button trait: when using .accessibilityElement(children:
.ignore) and setting .accessibilityLabel("\(widget.kind.title),
\(widget.family.title)"), also call .accessibilityAddTraits(.isButton) (matching
AllergySettingView) so the element is always exposed as a button to assistive
technologies; locate the chain where accessibilityElement and accessibilityLabel
are applied and append accessibilityAddTraits(.isButton).

In
`@Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift`:
- Line 51: In TWBottomSheet.swift the reduceTransparency branch currently uses
Color.black.opacity(0.6) which still allows background bleed; change the branch
handling for reduceTransparency (the variable/condition named reduceTransparency
in the TWBottomSheet overlay code) to use a fully opaque color (e.g.,
Color.black without opacity) or another high-contrast opaque color to ensure the
overlay is not translucent; update the conditional expression that returns
(reduceTransparency ? Color.black.opacity(0.6) : Color.lightBox) to return an
opaque color when reduceTransparency is true and verify visually or with the
provided [request_verification].

In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift`:
- Line 35: placeholder가 비어있을 때 접근성 라벨이 빈 문자열이 되는 문제입니다; TWTextField에서
.accessibilityLabel(placeholder)를 그대로 쓰는 대신 placeholder가 비어있으면 안전한 fallback 라벨을
사용하도록 수정하세요 (예: localized 기본 라벨 또는 accessibilityPlaceholder 변수). 즉 TWTextField의
placeholder 값을 검사하고 빈 문자열일 경우 대체 텍스트를 전달하도록 변경하세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 04f30487-8e3d-4f00-bb72-a5f8aa251c68

📥 Commits

Reviewing files that changed from the base of the PR and between 831a1b5 and dda8412.

📒 Files selected for processing (14)
  • Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift
  • Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift
  • Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift
  • Projects/Feature/MainFeature/Sources/MainView.swift
  • Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
  • Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift
  • Projects/Feature/SettingsFeature/Sources/SettingsView.swift
  • Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift
  • Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift
  • Projects/UserInterface/DesignSystem/Resources/Colors.xcassets/DesignSystem/UnselectedPrimary.colorset/Contents.json
  • Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift
  • Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift
  • Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift
  • Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift

Comment on lines +99 to +107
ReviewToast(
onTap: {
viewStore.send(.requestReview)
TWLog.event(ClickReviewEventLog())
},
onDismiss: {
viewStore.send(.hideReviewToast, animation: .default)
}
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

리뷰 토스트 dismiss 경로가 reduceMotion 설정을 완전히 따르지 않습니다.

Line 105(그리고 동일한 Line 127)에서 animation: .default를 고정 사용하면, reduceMotion 활성 시에도 dismiss 애니메이션이 발생할 수 있습니다.

🔧 제안 수정
 ReviewToast(
     onTap: {
         viewStore.send(.requestReview)
         TWLog.event(ClickReviewEventLog())
     },
     onDismiss: {
-        viewStore.send(.hideReviewToast, animation: .default)
+        viewStore.send(.hideReviewToast, animation: reduceMotion ? .none : .default)
     }
 )
 ...
 DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) {
-    viewStore.send(.hideReviewToast, animation: .default)
+    viewStore.send(.hideReviewToast, animation: reduceMotion ? .none : .default)
 }

Also applies to: 111-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MainFeature/Sources/MainView.swift` around lines 99 - 107,
The dismiss path currently passes a fixed animation (.default) when calling
viewStore.send(.hideReviewToast, animation: .default) from the ReviewToast
usage, which ignores the user's reduce motion setting; change the view to read
the accessibilityReduceMotion environment (e.g.
`@Environment`(\.accessibilityReduceMotion) var reduceMotion) and conditionally
pass animation: reduceMotion ? nil : .default (or use withAnimation only when
reduceMotion is false) for all places where you call
viewStore.send(.hideReviewToast, animation: .default) and similar dismiss/send
calls inside ReviewToast usage so dismiss respects reduce-motion.

}
.accessibilityLabel("날짜 선택")
.accessibilityHint("클릭하여 날짜를 선택할 수 있습니다")
.accessibilityRemoveTraits(.isButton)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

메뉴 트리거에서 버튼 트레이트 제거는 접근성 인지성을 떨어뜨릴 수 있습니다.

Line 201의 .accessibilityRemoveTraits(.isButton)로 인해 상호작용 가능한 컨트롤임을 VoiceOver가 덜 명확히 전달할 수 있습니다. 이 modifier는 제거하는 편이 안전합니다.

🔧 제안 수정
- .accessibilityRemoveTraits(.isButton)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.accessibilityRemoveTraits(.isButton)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MainFeature/Sources/MainView.swift` at line 201, Remove the
.accessibilityRemoveTraits(.isButton) modifier from the menu trigger in MainView
so VoiceOver can correctly announce the control as a button; locate the
occurrence inside the MainView (the menu trigger in the view body) and delete
that modifier (do not replace it with another trait removal), ensuring the
element retains its default .isButton accessibility trait.

Comment on lines +220 to +229
.accessibilityAction(named: "이미지로 복사") {
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: mealCardView)
renderer.scale = displayScale
if let image = renderer.uiImage {
UIPasteboard.general.image = image
TWLog.event(ShareMealImageEventLog())
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 접근성 액션의 가용성 처리 위치 확인
nl -ba Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift | sed -n '214,232p'

# 기대 결과:
# - accessibilityAction(named: "이미지로 복사") modifier가 조건 없이 붙어 있음
# - `#available`(iOS 16.0, *) 체크가 액션 클로저 내부에만 존재
# 이 경우 iOS 16 미만에서 no-op 액션 노출 가능성 있음

Repository: todaywhat/TodayWhat-iOS

Length of output: 107


🏁 Script executed:

cat -n Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift | sed -n '214,232p'

Repository: todaywhat/TodayWhat-iOS

Length of output: 1037


iOS 16 미만에서 "이미지로 복사" 접근성 액션이 노출되지만 실제로 동작하지 않습니다.

Line 220의 .accessibilityAction(named: "이미지로 복사") 수정자는 조건 없이 항상 등록되고, iOS 16 가용성 체크는 액션 클로저 내부(line 221)에만 있습니다. 이로 인해 iOS 16 미만 사용자의 접근성 트리에 no-op 액션이 노출되어 혼란을 초래할 수 있습니다. 액션 등록 자체를 조건부로 분기하여 iOS 16 미만에서는 액션을 노출하지 않는 것이 안전합니다.

제안 수정안
-        mealCardView
-            .accessibilityElement(children: .combine)
-            .accessibilityLabel(accessibilityText)
-            .accessibilityAction(named: "텍스트로 복사") {
-                UIPasteboard.general.string = shareText
-                TWLog.event(ShareMealEventLog())
-            }
-            .accessibilityAction(named: "이미지로 복사") {
-                if `#available`(iOS 16.0, *) {
-                    let renderer = ImageRenderer(content: mealCardView)
-                    renderer.scale = displayScale
-                    if let image = renderer.uiImage {
-                        UIPasteboard.general.image = image
-                        TWLog.event(ShareMealImageEventLog())
-                    }
-                }
-            }
+        let baseCard = mealCardView
+            .accessibilityElement(children: .combine)
+            .accessibilityLabel(accessibilityText)
+            .accessibilityAction(named: "텍스트로 복사") {
+                UIPasteboard.general.string = shareText
+                TWLog.event(ShareMealEventLog())
+            }
+
+        Group {
+            if `#available`(iOS 16.0, *) {
+                baseCard
+                    .accessibilityAction(named: "이미지로 복사") {
+                        let renderer = ImageRenderer(content: mealCardView)
+                        renderer.scale = displayScale
+                        if let image = renderer.uiImage {
+                            UIPasteboard.general.image = image
+                            TWLog.event(ShareMealImageEventLog())
+                        }
+                    }
+            } else {
+                baseCard
+            }
+        }
             .contextMenu {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift` around
lines 220 - 229, The accessibility action ".accessibilityAction(named: \"이미지로
복사\")" is always registered but only works on iOS 16+; move the availability
check outwards so the modifier is only applied on iOS 16 and above. Concretely,
wrap the view/modifier application that adds the accessibilityAction in an if
`#available`(iOS 16.0, *) branch (or apply the modifier conditionally using a
Group) so that ImageRenderer(content: mealCardView), renderer.scale =
displayScale, UIPasteboard.general.image assignment and
TWLog.event(ShareMealImageEventLog()) are only reachable when the action is
actually registered. Ensure you reference the existing symbols
accessibilityAction, ImageRenderer, mealCardView, displayScale,
UIPasteboard.general, TWLog and ShareMealImageEventLog when updating the view
modifiers.

Comment on lines +50 to +52
.animation(reduceMotion ? .none : .default, value: viewStore.grade)
.animation(reduceMotion ? .none : .default, value: viewStore.class)
.animation(reduceMotion ? .none : .default, value: viewStore.school)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

감소된 모션 처리가 일부 경로에서만 적용됩니다.

Line 50~52는 잘 반영됐지만, Line 56/71/131/222/245의 animation: .defaultwithAnimation은 여전히 모션을 강제합니다. 접근성 설정 일관성을 위해 동일하게 reduceMotion 분기 적용이 필요합니다.

🔧 제안 수정
 .onChange(of: focusField) { newValue in
-    viewStore.send(.schoolFocusedChanged(newValue == .school), animation: .default)
+    viewStore.send(.schoolFocusedChanged(newValue == .school), animation: reduceMotion ? .none : .default)
 }
 ...
 .onAppear {
     viewStore.send(.onAppear)
-    withAnimation {
+    withAnimation(reduceMotion ? .none : .default) {
         focusField = .school
     }
 }
 ...
 .onTapGesture {
-    viewStore.send(.majorTextFieldDidTap, animation: .default)
+    viewStore.send(.majorTextFieldDidTap, animation: reduceMotion ? .none : .default)
     focusField = nil
 }
 ...
 .onTapGesture {
-    viewStore.send(.schoolRowDidSelect(school), animation: .default)
+    viewStore.send(.schoolRowDidSelect(school), animation: reduceMotion ? .none : .default)
     focusField = .grade
 }
 ...
 TWButton(title: viewStore.nextButtonTitle, style: .wide) {
-    viewStore.send(.nextButtonDidTap, animation: .default)
+    viewStore.send(.nextButtonDidTap, animation: reduceMotion ? .none : .default)
     focusField = nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift` around
lines 50 - 52, Several animation calls in SchoolSettingView still force motion;
update every hardcoded .animation(.default, ...) and every withAnimation { ... }
to respect the reduceMotion flag. Replace occurrences of .animation(.default,
value: ...) with .animation(reduceMotion ? .none : .default, value: ...) and
change withAnimation { ... } to withAnimation(reduceMotion ? nil : .default) {
... } (or withAnimation(reduceMotion ? nil : .default) around the exact closure)
for the remaining spots (the other .animation usages and all withAnimation
blocks in SchoolSettingView) so all paths consistently honor reduceMotion.

Comment on lines 36 to +45
public init(
weekdays: [String],
fullWeekdays: [String],
dates: [String],
periods: [Int],
subjects: [[String]],
todayIndex: Int? = nil
) {
self.weekdays = weekdays
self.fullWeekdays = fullWeekdays
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

WeeklyTimeTable 배열 길이 불변식을 강제해주세요.

weekdays, fullWeekdays, dates, subjects 길이가 불일치하면 이후 인덱싱에서 런타임 크래시가 발생할 수 있습니다. 공개 이니셜라이저에서 길이 검증을 넣어 불변식을 보장하는 게 안전합니다.

수정 예시
         public init(
             weekdays: [String],
             fullWeekdays: [String],
             dates: [String],
             periods: [Int],
             subjects: [[String]],
             todayIndex: Int? = nil
         ) {
+            precondition(weekdays.count == fullWeekdays.count, "weekdays/fullWeekdays count mismatch")
+            precondition(weekdays.count == dates.count, "weekdays/dates count mismatch")
+            precondition(weekdays.count == subjects.count, "weekdays/subjects count mismatch")
+
             self.weekdays = weekdays
             self.fullWeekdays = fullWeekdays
             self.dates = dates
             self.periods = periods
             self.subjects = subjects
             self.todayIndex = todayIndex
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public init(
weekdays: [String],
fullWeekdays: [String],
dates: [String],
periods: [Int],
subjects: [[String]],
todayIndex: Int? = nil
) {
self.weekdays = weekdays
self.fullWeekdays = fullWeekdays
public init(
weekdays: [String],
fullWeekdays: [String],
dates: [String],
periods: [Int],
subjects: [[String]],
todayIndex: Int? = nil
) {
precondition(weekdays.count == fullWeekdays.count, "weekdays/fullWeekdays count mismatch")
precondition(weekdays.count == dates.count, "weekdays/dates count mismatch")
precondition(weekdays.count == subjects.count, "weekdays/subjects count mismatch")
self.weekdays = weekdays
self.fullWeekdays = fullWeekdays
self.dates = dates
self.periods = periods
self.subjects = subjects
self.todayIndex = todayIndex
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift`
around lines 36 - 45, Public init in WeeklyTimeTable must enforce array-length
invariants: inside WeeklyTimeTable.init validate that weekdays.count ==
fullWeekdays.count && weekdays.count == dates.count && weekdays.count ==
subjects.count (and if todayIndex != nil assert 0 <= todayIndex! <
weekdays.count); on violation call preconditionFailure (or throw/failable init
per project style) with a clear message so malformed inputs fail early; update
the initializer that assigns self.weekdays, self.fullWeekdays, self.dates,
self.periods, self.subjects, and self.todayIndex to run these checks before
assignment.

Comment on lines +265 to +267
let accessLabel: String = subject.isEmpty
? "\(fullWeekdayName) \(period)교시 수업 없음"
: "\(fullWeekdayName) \(period)교시 \(subject)\(isToday ? ", 오늘" : "")"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

빈 셀 접근성 라벨에도 ‘오늘’ 맥락을 일관되게 포함해주세요.

현재는 과목이 있을 때만 , 오늘이 붙고, 수업 없음 케이스에는 오늘 정보가 빠집니다. 오늘 컬럼 탐색 시 음성 피드백 일관성이 깨집니다.

수정 예시
-                let accessLabel: String = subject.isEmpty
-                    ? "\(fullWeekdayName) \(period)교시 수업 없음"
-                    : "\(fullWeekdayName) \(period)교시 \(subject)\(isToday ? ", 오늘" : "")"
+                let todaySuffix = isToday ? ", 오늘" : ""
+                let accessLabel: String = subject.isEmpty
+                    ? "\(fullWeekdayName) \(period)교시 수업 없음\(todaySuffix)"
+                    : "\(fullWeekdayName) \(period)교시 \(subject)\(todaySuffix)"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let accessLabel: String = subject.isEmpty
? "\(fullWeekdayName) \(period)교시 수업 없음"
: "\(fullWeekdayName) \(period)교시 \(subject)\(isToday ? ", 오늘" : "")"
let todaySuffix = isToday ? ", 오늘" : ""
let accessLabel: String = subject.isEmpty
? "\(fullWeekdayName) \(period)교시 수업 없음\(todaySuffix)"
: "\(fullWeekdayName) \(period)교시 \(subject)\(todaySuffix)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift`
around lines 265 - 267, The accessibility label construction for accessLabel
currently appends ", 오늘" only when subject is non-empty; modify the ternary
branches that build accessLabel (the subject.isEmpty case and the non-empty case
in WeeklyTimeTableView where accessLabel is formed using fullWeekdayName,
period, subject and isToday) so that both branches include the isToday context —
e.g., append the same ", 오늘" suffix when isToday is true even for the
"\(fullWeekdayName) \(period)교시 수업 없음" branch — ensuring consistent vocal
feedback.

.ignoresSafeArea()
}
.animation(.default, value: isShowing)
.animation(reduceMotion ? .none : .default, value: isShowing)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# read-only verification: explicit animation 경로 확인
rg -n --type=swift -C2 'withAnimation\s*\(' Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift
rg -n --type=swift -C2 'accessibilityReduceMotion|\.animation\s*\(' Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift

Repository: todaywhat/TodayWhat-iOS

Length of output: 865


🏁 Script executed:

#!/bin/bash
# Find all withAnimation calls with line numbers
rg -n 'withAnimation' Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift

# Check file length and structure
wc -l Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift

# Show the entire file context to understand all animations
cat -n Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift

Repository: todaywhat/TodayWhat-iOS

Length of output: 4915


모든 애니메이션이 Reduce Motion 설정을 존중하도록 수정 필요

Line 93의 .animation(reduceMotion ? .none : .default, value: isShowing)만으로는 불충분합니다. 이는 오직 isShowing 상태 변경으로 인한 애니메이션만 제어하고, 명시적 withAnimation 호출(Line 16, 21, 26, 54, 81)은 접근성 설정과 관계없이 계속 실행됩니다.

performAnimation 헬퍼 함수를 추가하여 모든 애니메이션이 reduceMotion 설정을 존중하도록 리팩토링하세요:

제안 diff
 struct TWBottomSheet<T: View>: ViewModifier {
     `@Binding` var isShowing: Bool
     `@State` var dragHeight: CGFloat = 0
     var content: () -> T
     var height: CGFloat
     var backgroundColor: Color
     `@Environment`(\.accessibilityReduceMotion) private var reduceMotion
     `@Environment`(\.accessibilityReduceTransparency) private var reduceTransparency
+
+    private func performAnimation(
+        _ animation: Animation? = .default,
+        _ action: `@escaping` () -> Void
+    ) {
+        if reduceMotion {
+            action()
+        } else {
+            withAnimation(animation, action)
+        }
+    }
     var sheetDragging: some Gesture {
         DragGesture(minimumDistance: 0, coordinateSpace: .global)
             .onChanged { value in
-                withAnimation(.spring()) {
+                performAnimation(.spring()) {
                     dragHeight = min(30, -value.translation.height)
                 }
             }
             .onEnded { value in
-                withAnimation(.spring()) {
+                performAnimation(.spring()) {
                     dragHeight = 0
                 }
                 let verticalAmount = value.translation.height
                 if verticalAmount > 100 {
-                    withAnimation {
+                    performAnimation {
                         isShowing = false
                     }
                 }
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/UserInterface/DesignSystem/Sources/TWBottomSheet/TWBottomSheet.swift`
at line 93, Add a performAnimation helper that checks reduceMotion and either
runs the closure directly or wraps it in withAnimation, then replace all
explicit withAnimation(...) calls in this file (the spots currently toggling
isShowing / related state) to call performAnimation { ... } so every state
change respects reduceMotion; keep the existing .animation(reduceMotion ? .none
: .default, value: isShowing) for implicit animations but ensure all explicit
withAnimation usages are swapped to performAnimation to enforce the
accessibility setting.

Comment on lines +63 to 65
withAnimation(reduceMotion ? .none : nil) {
text = ""
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In SwiftUI withAnimation(_ animation: Animation?, _ body: () -> Result), are .noneandnilequivalent forAnimation?, and does reduceMotion ? .none : nil always disable animation?

💡 Result:

.none and nil are not equivalent values for Animation?:

  • .none is a real Animation value that represents “no animation”.
  • nil means “no animation specified” (i.e., the transaction’s animation is absent/cleared). In SwiftUI APIs that take Animation?, nil disables the animation. [1][2]

So reduceMotion ? .none : nil does not mean “disable when reduced motion, otherwise use the default” — it means “no animation in both branches”, i.e. it will always disable animation. [1][3]

If you want “default animation unless reduced motion”, do e.g.:

  • withAnimation(reduceMotion ? nil : .default) { ... } (or your chosen animation), since nil disables. [1]

Sources:
[1] Use Your Loaf — Reducing Motion of Animations (shows withAnimation(linearAnimation) where returning nil “disables the animation”)
[2] Swift Forums (Composable Architecture thread) — notes that passing nil to SwiftUI’s animation-optional APIs means “do not animate at all”
[3] Fatbobman — notes (re SwiftUI animation optionals) “Both .none and nil indicate no animation”


reduceMotion 조건부에도 불구하고 항상 애니메이션이 비활성화됩니다.

Line 63의 reduceMotion ? .none : nil은 두 분기 모두 애니메이션을 비활성화하므로, 의도한 조건부 애니메이션이 작동하지 않습니다. reduceMotion이 참이어도 거짓이어도 항상 애니메이션이 꺼집니다.

수정 제안
-                    withAnimation(reduceMotion ? .none : nil) {
+                    withAnimation(reduceMotion ? nil : .default) {
                         text = ""
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift`
around lines 63 - 65, The withAnimation call incorrectly disables animation in
both branches because it uses `reduceMotion ? .none : nil`; change it to enable
animation only when `reduceMotion` is false by swapping branches (e.g.,
`withAnimation(reduceMotion ? nil : .default) { text = "" }`), updating the
`withAnimation` invocation in TWTextField where `text` is cleared and
`reduceMotion` is checked so animation is suppressed only when `reduceMotion` is
true.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift (1)

63-65: ⚠️ Potential issue | 🟡 Minor

withAnimation 분기 로직이 여전히 항상 애니메이션을 비활성화합니다.

Line 63의 reduceMotion ? .none : nil은 두 분기 모두 “애니메이션 없음”으로 해석되어, reduceMotion == false여도 텍스트 삭제 애니메이션이 실행되지 않습니다.

수정 제안
-                    withAnimation(reduceMotion ? .none : nil) {
+                    withAnimation(reduceMotion ? nil : .default) {
                         text = ""
                     }
SwiftUI의 `withAnimation(_ animation: Animation?, _ body:)`에서 `nil`과 `.none`의 의미 차이, 그리고 기본 애니메이션을 활성화하는 권장 패턴을 확인해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift`
around lines 63 - 65, The withAnimation call currently uses reduceMotion ? .none
: nil which results in no animation in both branches; change it to pass a real
animation when reduceMotion is false (for example withAnimation(reduceMotion ?
nil : .default) { text = "" }) or explicitly branch (if reduceMotion {
withAnimation(nil) { text = "" } } else { withAnimation { text = "" } }) so that
reduceMotion, withAnimation and text in TWTextField.swift behave correctly.
🧹 Nitpick comments (5)
Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift (3)

179-181: DispatchQueue.main.asyncAfter 대신 Task 기반 접근 고려.

현재 구현은 뷰가 사라진 후에도 비동기 블록이 실행될 수 있습니다. @AccessibilityFocusState는 뷰가 없으면 무시되므로 크래시는 발생하지 않지만, 구조화된 동시성을 위해 Task를 사용하면 뷰 생명주기와 더 잘 연동됩니다.

♻️ Task 기반 대안
             .onAppear {
                 timerActive = true
                 elapsedTime = 0
-                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
-                    isGuideFocused = true
-                }
             }
+            .task {
+                try? await Task.sleep(nanoseconds: 500_000_000)
+                isGuideFocused = true
+            }

Task는 뷰가 사라지면 자동으로 취소되어 불필요한 실행을 방지합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift` around lines
179 - 181, Replace the DispatchQueue.main.asyncAfter block with a Task-based
delay to tie the work to structured concurrency and the view lifecycle: in the
code using isGuideFocused (the `@AccessibilityFocusState` binding), remove
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isGuideFocused = true }
and instead create a Task that awaits Task.sleep(nanoseconds: 500_000_000) and
then sets isGuideFocused on the main actor (e.g., await MainActor.run {
isGuideFocused = true }) so the work is cancellable when the view disappears.

146-147: 접근성 레이블이 중복될 수 있습니다.

Text(currentGuideText)는 기본적으로 텍스트 내용을 접근성 레이블로 사용합니다. .accessibilityLabel(currentGuideText)를 추가하면 동일한 값을 중복 설정하게 됩니다. 의도적으로 명시한 것이라면 문제없지만, 불필요한 코드일 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift` around lines
146 - 147, Text(currentGuideText) already exposes its string to VoiceOver, so
calling .accessibilityLabel(currentGuideText) duplicates the label; remove the
redundant modifier on the view that chains
.accessibilityFocused($isGuideFocused) and .accessibilityLabel(currentGuideText)
(or replace the label argument with a distinct, intentional string only if you
need a different spoken value) so keep .accessibilityFocused($isGuideFocused)
and either delete .accessibilityLabel(currentGuideText) or supply a unique
accessibility label.

64-87: 접근성 구현이 적절합니다.

위젯 프리뷰를 VoiceOver에서 숨기고 행 전체를 하나의 접근성 요소로 처리한 것은 좋은 접근입니다. kind.titlefamily.title이 이미 한국어 지역화된 문자열을 반환하므로 접근성 레이블로 적합합니다.

선택적으로 개선할 수 있는 부분:

  • 버튼 동작에 대한 힌트 추가 (예: .accessibilityHint("위젯 추가 가이드를 표시합니다"))
  • .accessibilityAddTraits(.isButton) 추가로 버튼임을 명확히 할 수 있습니다 (children: .ignore 사용 시 기존 트레이트가 유지되지 않을 수 있음)
♻️ 선택적 개선안
             .padding(.horizontal, 16)
             .accessibilityElement(children: .ignore)
             .accessibilityLabel("\(widget.kind.title), \(widget.family.title)")
+            .accessibilityAddTraits(.isButton)
+            .accessibilityHint("위젯 추가 가이드를 표시합니다")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift` around lines
64 - 87, 현재 widgetPreview(for:)를 VoiceOver에서 숨기고 .accessibilityElement(children:
.ignore)와 .accessibilityLabel("\(widget.kind.title), \(widget.family.title)")로 행
전체를 접근성 요소로 만든 부분에 대해 접근성 힌트와 버튼 트레잇을 추가하세요: 해당 뷰(해당 버튼 또는 래핑된 컨테이너)에
.accessibilityHint("위젯 추가 가이드를 표시합니다")를 설정하고 .accessibilityAddTraits(.isButton)를
호출하여 동작 힌트를 제공하고 이 요소가 버튼임을 명확히 표시하도록 수정합니다; 참조 지점은 widgetPreview(for:),
.accessibilityElement(children: .ignore), .accessibilityLabel(...)입니다.
Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift (2)

43-65: 하단 저장 버튼 뷰 중복을 추출하는 것이 좋겠습니다.

Line 45-53과 Line 56-64의 버튼 내용이 동일해 유지보수 시 드리프트가 생기기 쉽습니다. 공통 뷰로 추출하면 안전합니다.

변경 예시
+    `@ViewBuilder`
+    private var saveButton: some View {
+        if viewStore.allergyDidTap {
+            TWButton(title: "저장") {
+                viewStore.send(.saveButtonDidTap)
+            }
+            .padding(.horizontal, 16)
+            .padding(.bottom, 8)
+        }
+    }
+
     public var body: some View {
         let scrollView = ScrollView {
             ...
         }

         if `#available`(iOS 26.0, *) {
             scrollView
                 .safeAreaBar(edge: .bottom) {
-                    if viewStore.allergyDidTap {
-                        TWButton(title: "저장") {
-                            viewStore.send(.saveButtonDidTap)
-                        }
-                        .padding(.horizontal, 16)
-                        .padding(.bottom, 8)
-                    }
+                    saveButton
                 }
         } else {
             scrollView
                 .safeAreaInset(edge: .bottom) {
-                    if viewStore.allergyDidTap {
-                        TWButton(title: "저장") {
-                            viewStore.send(.saveButtonDidTap)
-                        }
-                        .padding(.horizontal, 16)
-                        .padding(.bottom, 8)
-                    }
+                    saveButton
                 }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift`
around lines 43 - 65, Extract the duplicated bottom save-button view into a
single reusable view (e.g., a computed var or private func like
saveButtonView()) that returns the TWButton wrapped with the padding and the
conditional based on viewStore.allergyDidTap; then replace both uses of
scrollView.safeAreaBar(...) and scrollView.safeAreaInset(...) so they call the
same saveButtonView() inside their respective safeAreaBar/safeAreaInset closures
(keep the iOS-availability branching for safeAreaBar vs safeAreaInset but move
the TWButton creation and padding into the shared saveButtonView to avoid
duplication).

24-27: allCases 중복 조회와 fallback 기본값을 정리해 주세요.

Line 24-27은 동일 소스를 두 번 조회하고, 불필요한 기본값(.turbulence)으로 오류를 숨길 수 있습니다. 로컬 배열을 한 번만 만들고 직접 인덱싱하는 쪽이 안전하고 읽기 쉽습니다.

변경 예시
-            LazyVGrid(columns: columns, spacing: 8) {
-                ForEach(AllergyType.allCases.indices, id: \.self) { index in
-                    let allergy = AllergyType.allCases[safe: index] ?? .turbulence
+            let allergies = AllergyType.allCases
+            LazyVGrid(columns: columns, spacing: 8) {
+                ForEach(allergies.indices, id: \.self) { index in
+                    let allergy = allergies[index]
                     
                     allergyColumnView(index: index, allergy: allergy)
                         .onTapGesture {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift`
around lines 24 - 27, Replace the duplicate AllergyType.allCases lookups and the
unsafe fallback by capturing the cases once into a local constant (e.g., let
allCases = AllergyType.allCases) and iterate that collection; inside the ForEach
use direct indexing into that local array (or iterate the array elements
themselves) to produce the allergy variable passed to allergyColumnView(index:
index, allergy: allergy) so you no longer call AllergyType.allCases twice or
rely on the .turbulence fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift`:
- Around line 63-65: The withAnimation call currently uses reduceMotion ? .none
: nil which results in no animation in both branches; change it to pass a real
animation when reduceMotion is false (for example withAnimation(reduceMotion ?
nil : .default) { text = "" }) or explicitly branch (if reduceMotion {
withAnimation(nil) { text = "" } } else { withAnimation { text = "" } }) so that
reduceMotion, withAnimation and text in TWTextField.swift behave correctly.

---

Nitpick comments:
In `@Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift`:
- Around line 179-181: Replace the DispatchQueue.main.asyncAfter block with a
Task-based delay to tie the work to structured concurrency and the view
lifecycle: in the code using isGuideFocused (the `@AccessibilityFocusState`
binding), remove DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isGuideFocused = true } and instead create a Task that awaits
Task.sleep(nanoseconds: 500_000_000) and then sets isGuideFocused on the main
actor (e.g., await MainActor.run { isGuideFocused = true }) so the work is
cancellable when the view disappears.
- Around line 146-147: Text(currentGuideText) already exposes its string to
VoiceOver, so calling .accessibilityLabel(currentGuideText) duplicates the
label; remove the redundant modifier on the view that chains
.accessibilityFocused($isGuideFocused) and .accessibilityLabel(currentGuideText)
(or replace the label argument with a distinct, intentional string only if you
need a different spoken value) so keep .accessibilityFocused($isGuideFocused)
and either delete .accessibilityLabel(currentGuideText) or supply a unique
accessibility label.
- Around line 64-87: 현재 widgetPreview(for:)를 VoiceOver에서 숨기고
.accessibilityElement(children: .ignore)와
.accessibilityLabel("\(widget.kind.title), \(widget.family.title)")로 행 전체를 접근성
요소로 만든 부분에 대해 접근성 힌트와 버튼 트레잇을 추가하세요: 해당 뷰(해당 버튼 또는 래핑된 컨테이너)에
.accessibilityHint("위젯 추가 가이드를 표시합니다")를 설정하고 .accessibilityAddTraits(.isButton)를
호출하여 동작 힌트를 제공하고 이 요소가 버튼임을 명확히 표시하도록 수정합니다; 참조 지점은 widgetPreview(for:),
.accessibilityElement(children: .ignore), .accessibilityLabel(...)입니다.

In `@Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift`:
- Around line 43-65: Extract the duplicated bottom save-button view into a
single reusable view (e.g., a computed var or private func like
saveButtonView()) that returns the TWButton wrapped with the padding and the
conditional based on viewStore.allergyDidTap; then replace both uses of
scrollView.safeAreaBar(...) and scrollView.safeAreaInset(...) so they call the
same saveButtonView() inside their respective safeAreaBar/safeAreaInset closures
(keep the iOS-availability branching for safeAreaBar vs safeAreaInset but move
the TWButton creation and padding into the shared saveButtonView to avoid
duplication).
- Around line 24-27: Replace the duplicate AllergyType.allCases lookups and the
unsafe fallback by capturing the cases once into a local constant (e.g., let
allCases = AllergyType.allCases) and iterate that collection; inside the ForEach
use direct indexing into that local array (or iterate the array elements
themselves) to produce the allergy variable passed to allergyColumnView(index:
index, allergy: allergy) so you no longer call AllergyType.allCases twice or
rely on the .turbulence fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 84f0b5d7-3d2e-438b-91eb-82cca951d1e2

📥 Commits

Reviewing files that changed from the base of the PR and between 0813b5b and 6d6c09e.

📒 Files selected for processing (6)
  • Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift
  • Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift
  • Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift
  • Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift
  • Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
  • Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift
✅ Files skipped from review due to trivial changes (2)
  • Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift
  • Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift

@baekteun baekteun merged commit 8e6dc86 into master Mar 29, 2026
3 checks passed
@baekteun baekteun deleted the feature/accessibility-enchancement branch March 29, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant