diff --git a/app/README.md b/app/README.md
index d382db4..ce3e2f3 100644
--- a/app/README.md
+++ b/app/README.md
@@ -23,7 +23,7 @@ cd app
flutter pub get
flutter run # 실기기·시뮬레이터
flutter analyze # 정적 분석
-flutter test # 스모크·회귀 테스트 9건
+flutter test # 스모크·인증 상태 전이·회귀 테스트 19건
flutter build apk --debug # Android 디버그 APK
```
@@ -141,12 +141,17 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|---|---|---|---|
| 카카오 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
| 네이버 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
-| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability |
+| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability + Android Service ID/redirect URI |
| Google | 실 코드 경로 | `GoogleSignInProvider` | Firebase 설정(S2) |
설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고
`AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다.
+Apple은 로그인 시도마다 암호학적 nonce를 만들고 SHA-256 해시를 Apple에, 원문 nonce를
+Firebase에 전달한다. Android에서는 Service ID와 redirect URI가 없으면 SDK를 호출하지 않는다.
+Firebase가 live가 되면 보존된 `currentUser`를 앱 시작 시 복원하고 `authStateChanges`를 계속 들어
+만료·원격 로그아웃도 라우터에 반영한다. 이 내부 계약은 fake 세션·provider로 자동 검증한다.
+
**이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에
구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는
플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다
@@ -188,7 +193,8 @@ app/
├── analysis_options.yaml flutter_lints + strict-casts/raw-types/inference
├── tool/gen_tokens.py design-tokens.md → tokens.dart 생성기
├── firebase/ T1(Firestore 설계) 영역 — 규칙·인덱스
-├── test/app_smoke_test.dart 스모크 3종
+├── test/app_smoke_test.dart 앱 기동·5탭·목 세션 회귀 10건
+├── test/auth_repository_test.dart provider 결과·세션 복원·로그아웃 9건
└── lib/
├── main.dart FirebaseBoot.init() → runApp
├── app.dart MaterialApp.router + 테마 주입
@@ -214,21 +220,14 @@ Analyzing app...
No issues found! (ran in 3.2s)
$ flutter test
-00:00 +0: loading /Users/Kinos/Projects/p-fancake/app/test/app_smoke_test.dart
-00:00 +0: 1. 앱이 예외 없이 뜨고 로그인 화면이 나온다
-00:00 +1: 2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다
-00:00 +2: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 목 모드 + 릴리스면 isMockSessionAvailable 이 false 다
-00:00 +3: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다
-00:00 +4: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스 빌드 로그인 화면에 목 세션 버튼이 없다
-00:00 +5: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)
-00:00 +6: 제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다
-00:00 +7: 화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다
-00:00 +8: 3. 탭을 전환하면 각 탭 루트 화면이 바뀐다
-00:00 +9: All tests passed!
+app_smoke_test.dart: 앱 기동·세션 복원·5탭·릴리스 목 우회 차단 10건 통과
+auth_repository_test.dart: 제공자 4종 성공/취소/실패/미설정·예외 안전성·
+ Android Apple 설정 게이트·세션 복원/만료·로그아웃/재로그인 9건 통과
+00:01 +19: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 219.5s (첫 빌드 — NDK 28.2 자동 설치 포함)
-✓ Built build/app/outputs/flutter-apk/app-debug.apk (164,432,730 bytes · 약 156.8 MiB · debug)
+✓ Built build/app/outputs/flutter-apk/app-debug.apk (164,473,984 bytes · 약 156.9 MiB · debug)
10.6s (증분 재빌드)
$ flutter build ios --debug --no-codesign
@@ -242,7 +241,7 @@ $ python3 tool/gen_tokens.py
전 쌍 AA 통과. (최저 color-on-neon-violet / color-neon-violet = 5.09:1)
```
-**테스트 9종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
+**테스트 19종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다.
@@ -296,7 +295,7 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
| L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 |
| L8 | **릴리스 서명 키 없음 → `flutter build apk --release` 는 의도적으로 실패한다** | 서명 키는 스토어 등록 명의(Q5·S3)가 정해져야 만들 수 있다. Flutter 기본 템플릿은 이 상태에서 **debug 키로 조용히 서명**해 Play 가 거부하는 산출물을 "성공"으로 내놓는다 — 그 경로를 막고 사유를 띄우도록 `android/app/build.gradle.kts` 를 고쳤다. 키가 생기면 `android/key.properties`(storeFile·storePassword·keyAlias·keyPassword)만 놓으면 된다. 해당 파일과 `*.jks`/`*.keystore` 는 `app/android/.gitignore` 가 이미 막고 있다(확인함) | Q5·S3 |
-| L8-1 | debug APK 164,432,730 bytes(약 156.8 MiB) | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
+| L8-1 | debug APK 164,473,984 bytes(약 156.9 MiB) | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
| L9 | 첫 Gradle 빌드에서 Kotlin 데몬 경고 1회 | `e: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0` — 자동 재시도로 성공했고 산출물에 영향 없음. 증분 빌드에서는 재현되지 않음 | — |
| L9-1 | iOS 런치 화면 색 변경은 **컴파일 검증만** 했다 | `LaunchScreen.storyboard` 배경을 흰색 → `color-cosmic-void`(#000)로 바꿨다. Android 는 에뮬레이터에서 눈으로 확인했지만 iOS 는 시뮬레이터 실행을 하지 않아 빌드 성공까지만 확인했다 | 3주차 실기기 빌드 |
| L10 | 온보딩 6화면 라우트 없음 | S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS | 3주차 |
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index c4518bb..6552c21 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -25,6 +25,19 @@
+
+
+
+
+
+
+
+
+
+
});
});
+describe('단결형 아이템 접근 경계', () => {
+ beforeEach(async () => {
+ await seedDocuments(testEnvironment, {
+ 'fandoms/fandom-a/solidarityBoosts/boost-1': {
+ triggerUid: 'alice',
+ multiplier: 2,
+ startsAt: '2026-08-14T00:00:00Z',
+ endsAt: '2026-08-14T01:00:00Z',
+ scope: 'fandom',
+ status: 'active',
+ },
+ });
+ });
+
+ test('로그인 사용자는 단결형 활성 상태를 읽을 수 있다', async () => {
+ await assertSucceeds(
+ getDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
+ );
+ });
+
+ test('미인증 사용자는 단결형 활성 상태를 읽을 수 없다', async () => {
+ await assertFails(
+ getDoc(doc(anonymousDb(), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
+ );
+ });
+
+ test('일반 사용자는 단결형 상태를 생성할 수 없다', async () => {
+ await assertFails(
+ setDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/forged'), {
+ triggerUid: 'alice',
+ multiplier: 999,
+ status: 'active',
+ }),
+ );
+ });
+
+ test('master도 단결형 상태를 수정할 수 없다', async () => {
+ await assertFails(
+ updateDoc(
+ doc(dbFor('master-user', { role: 'master' }), 'fandoms/fandom-a/solidarityBoosts/boost-1'),
+ { multiplier: 999 },
+ ),
+ );
+ });
+
+ test('일반 사용자는 단결형 상태를 삭제할 수 없다', async () => {
+ await assertFails(
+ deleteDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
+ );
+ });
+});
+
describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
diff --git a/app/lib/core/ui/tab_placeholder.dart b/app/lib/core/ui/tab_placeholder.dart
index 3ac5ee4..92b0570 100644
--- a/app/lib/core/ui/tab_placeholder.dart
+++ b/app/lib/core/ui/tab_placeholder.dart
@@ -6,21 +6,19 @@ import '../theme/tokens.dart';
///
/// 2주차 범위는 「앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템」이다(별첨2).
/// 각 탭의 실제 화면은 3주차 이후이므로, 여기서는 어느 화면이 들어올 자리인지와
-/// 근거 명세 경로만 보여 준다. 빈 화면을 두면 검수 때 무엇이 빠진 건지 알 수 없다.
+/// 다음 구현 시점을 보여 준다. 빈 화면을 두면 검수 때 무엇이 빠진 건지 알 수 없다.
class TabPlaceholder extends StatelessWidget {
const TabPlaceholder({
super.key,
required this.tab,
required this.screenId,
required this.screenName,
- required this.specPath,
});
/// 계약 고정 탭 라벨(견적서 v2.1 5탭).
final String tab;
final String screenId;
final String screenName;
- final String specPath;
@override
Widget build(BuildContext context) {
@@ -53,7 +51,7 @@ class TabPlaceholder extends StatelessWidget {
const SizedBox(height: AppSpace.s2),
Text('화면 구현은 3주차 이후', style: text.bodySmall),
const SizedBox(height: AppSpace.s3),
- Text(specPath, style: text.bodySmall),
+ Text('승인된 화면 정의를 기준으로 구현합니다.', style: text.bodySmall),
],
),
),
diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart
index e73bbc5..f3f7fde 100644
--- a/app/lib/features/auth/auth.dart
+++ b/app/lib/features/auth/auth.dart
@@ -100,3 +100,15 @@ abstract interface class SocialAuthProvider {
Future signOut();
}
+
+/// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계.
+///
+/// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에
+/// 외부 설정 파일 없이도 세션 복원과 만료 전이를 단위 테스트할 수 있다.
+abstract interface class AuthSession {
+ AuthAccount? get currentAccount;
+
+ Stream get accountChanges;
+
+ Future signOut();
+}
diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart
index c549a14..06a221e 100644
--- a/app/lib/features/auth/auth_repository.dart
+++ b/app/lib/features/auth/auth_repository.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart';
@@ -6,13 +8,22 @@ import 'social_providers.dart';
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
class AuthRepository extends ChangeNotifier {
- AuthRepository({List? providers, bool? releaseMode})
- : providers = providers ?? _defaultProviders(),
- // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
- // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
- _releaseMode = releaseMode ?? kReleaseMode;
+ AuthRepository({
+ List? providers,
+ AuthSession? session,
+ bool? releaseMode,
+ }) : providers = providers ?? _defaultProviders(),
+ _session = session,
+ _account = session?.currentAccount,
+ // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
+ // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
+ _releaseMode = releaseMode ?? kReleaseMode {
+ _sessionSubscription = session?.accountChanges.listen(_replaceAccount);
+ }
final bool _releaseMode;
+ final AuthSession? _session;
+ StreamSubscription? _sessionSubscription;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
final List providers;
@@ -21,27 +32,38 @@ class AuthRepository extends ChangeNotifier {
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
- static List _defaultProviders() => const [
- KeylessSocialProvider(SocialProvider.kakao, [
+ static List _defaultProviders() => [
+ const KeylessSocialProvider(SocialProvider.kakao, [
'카카오 네이티브 앱 키',
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
- KeylessSocialProvider(SocialProvider.naver, [
+ const KeylessSocialProvider(SocialProvider.naver, [
'네이버 client ID / client secret',
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
]),
- AppleSignInProvider(),
- GoogleSignInProvider(),
+ AppleSignInProvider(
+ webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
+ ),
+ const GoogleSignInProvider(),
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다.
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고.
];
Future signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
- final outcome = await impl.signIn();
+ late final AuthOutcome outcome;
+ try {
+ outcome = await impl.signIn();
+ } on Object catch (error) {
+ // SDK가 문서화하지 않은 플랫폼 예외를 던져도 로그인 화면이 busy 상태에
+ // 고착되지 않게 사용자용 결과로 닫는다. 원문 예외·토큰은 화면에 노출하지 않는다.
+ debugPrint(
+ '[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}',
+ );
+ outcome = const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
+ }
if (outcome is AuthSucceeded) {
- _account = outcome.account;
- notifyListeners();
+ _replaceAccount(outcome.account);
}
return outcome;
}
@@ -71,10 +93,49 @@ class AuthRepository extends ChangeNotifier {
}
Future signOut() async {
- for (final p in providers) {
- await p.signOut();
+ final currentProvider = _account?.provider;
+ SocialAuthProvider? provider;
+ if (currentProvider != null) {
+ for (final candidate in providers) {
+ if (candidate.provider == currentProvider) {
+ provider = candidate;
+ break;
+ }
+ }
}
- _account = null;
+
+ try {
+ await Future.wait([
+ if (provider != null) provider.signOut(),
+ if (_session != null) _session.signOut(),
+ ], eagerError: false);
+ } finally {
+ // 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
+ _replaceAccount(null);
+ }
+ }
+
+ void _replaceAccount(AuthAccount? next) {
+ if (_sameAccount(_account, next)) return;
+ _account = next;
notifyListeners();
}
+
+ bool _sameAccount(AuthAccount? a, AuthAccount? b) =>
+ identical(a, b) ||
+ (a != null &&
+ b != null &&
+ a.uid == b.uid &&
+ a.provider == b.provider &&
+ a.displayName == b.displayName &&
+ a.email == b.email &&
+ a.isMock == b.isMock);
+
+ @override
+ void dispose() {
+ final subscription = _sessionSubscription;
+ _sessionSubscription = null;
+ if (subscription != null) unawaited(subscription.cancel());
+ super.dispose();
+ }
}
diff --git a/app/lib/features/auth/firebase_auth_session.dart b/app/lib/features/auth/firebase_auth_session.dart
new file mode 100644
index 0000000..2b08fba
--- /dev/null
+++ b/app/lib/features/auth/firebase_auth_session.dart
@@ -0,0 +1,47 @@
+import 'package:firebase_auth/firebase_auth.dart' as fb;
+
+import 'auth.dart';
+
+/// Firebase Auth가 보존한 세션을 앱의 인증 계약으로 변환한다.
+///
+/// [FirebaseBoot]가 live인 경우에만 생성해야 한다. 설정이 없는 목 모드에서는
+/// Firebase 인스턴스에 접근하지 않고 [AuthRepository]에 null 세션을 전달한다.
+final class FirebaseAuthSession implements AuthSession {
+ FirebaseAuthSession({fb.FirebaseAuth? firebaseAuth})
+ : _firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance;
+
+ final fb.FirebaseAuth _firebaseAuth;
+
+ @override
+ AuthAccount? get currentAccount => _toAccount(_firebaseAuth.currentUser);
+
+ @override
+ Stream get accountChanges =>
+ _firebaseAuth.authStateChanges().map(_toAccount);
+
+ @override
+ Future signOut() => _firebaseAuth.signOut();
+}
+
+AuthAccount? _toAccount(fb.User? user) {
+ if (user == null) return null;
+ return AuthAccount(
+ uid: user.uid,
+ provider: _providerOf(user),
+ displayName: user.displayName,
+ email: user.email,
+ );
+}
+
+SocialProvider? _providerOf(fb.User user) {
+ final ids = user.providerData.map((data) => data.providerId).toSet();
+ if (ids.contains('google.com')) return SocialProvider.google;
+ if (ids.contains('apple.com')) return SocialProvider.apple;
+ if (ids.any((id) => id.toLowerCase().contains('kakao'))) {
+ return SocialProvider.kakao;
+ }
+ if (ids.any((id) => id.toLowerCase().contains('naver'))) {
+ return SocialProvider.naver;
+ }
+ return null;
+}
diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart
index a3e9c2a..ceee328 100644
--- a/app/lib/features/auth/social_providers.dart
+++ b/app/lib/features/auth/social_providers.dart
@@ -1,4 +1,8 @@
+import 'dart:convert';
+
+import 'package:crypto/crypto.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
+import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart' as gsi;
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
@@ -12,6 +16,8 @@ import 'auth.dart';
final class GoogleSignInProvider implements SocialAuthProvider {
const GoogleSignInProvider();
+ static Future? _initialization;
+
@override
SocialProvider get provider => SocialProvider.google;
@@ -32,14 +38,16 @@ final class GoogleSignInProvider implements SocialAuthProvider {
try {
final signIn = gsi.GoogleSignIn.instance;
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
- await signIn.initialize();
+ await (_initialization ??= signIn.initialize());
final account = await signIn.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
- final result = await fb.FirebaseAuth.instance.signInWithCredential(credential);
+ final result = await fb.FirebaseAuth.instance.signInWithCredential(
+ credential,
+ );
return AuthSucceeded(_toAccount(result.user, provider));
} on gsi.GoogleSignInException catch (error) {
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
@@ -54,8 +62,9 @@ final class GoogleSignInProvider implements SocialAuthProvider {
@override
Future signOut() async {
if (!FirebaseBoot.isLive) return;
+ await (_initialization ??= gsi.GoogleSignIn.instance.initialize());
await gsi.GoogleSignIn.instance.signOut();
- await fb.FirebaseAuth.instance.signOut();
+ // Firebase 세션 종료는 AuthSession이 한 번만 담당한다.
}
}
@@ -65,7 +74,11 @@ final class GoogleSignInProvider implements SocialAuthProvider {
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
final class AppleSignInProvider implements SocialAuthProvider {
- const AppleSignInProvider();
+ const AppleSignInProvider({this.webAuthenticationOptions});
+
+ /// Android·Web에서 필요한 Service ID와 검증된 callback URL.
+ /// 값이 없으면 SDK를 호출하지 않고 [AuthNotConfigured]로 닫는다.
+ final apple.WebAuthenticationOptions? webAuthenticationOptions;
@override
SocialProvider get provider => SocialProvider.apple;
@@ -74,9 +87,12 @@ final class AppleSignInProvider implements SocialAuthProvider {
bool get isLive => true;
@override
- List get missingConfig => FirebaseBoot.isLive
- ? const []
- : [...FirebaseBoot.missingLabels, 'Apple 개발자 설정'];
+ List get missingConfig => [
+ if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
+ if (_requiresWebAuthentication && webAuthenticationOptions == null)
+ 'Apple Android 웹 인증 설정',
+ if (!FirebaseBoot.isLive) 'Apple 개발자 설정',
+ ];
@override
Future signIn() async {
@@ -87,11 +103,17 @@ final class AppleSignInProvider implements SocialAuthProvider {
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
}
try {
+ final rawNonce = apple.generateNonce();
+ final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
final credential = await apple.SignInWithApple.getAppleIDCredential(
scopes: const [
apple.AppleIDAuthorizationScopes.email,
apple.AppleIDAuthorizationScopes.fullName,
],
+ webAuthenticationOptions: _requiresWebAuthentication
+ ? webAuthenticationOptions
+ : null,
+ nonce: hashedNonce,
);
final idToken = credential.identityToken;
if (idToken == null) {
@@ -100,6 +122,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
final oauth = fb.OAuthProvider('apple.com').credential(
idToken: idToken,
accessToken: credential.authorizationCode,
+ rawNonce: rawNonce,
);
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
return AuthSucceeded(_toAccount(result.user, provider));
@@ -108,6 +131,9 @@ final class AppleSignInProvider implements SocialAuthProvider {
return const AuthCancelled();
}
return AuthFailed('E-AUTH-03', error.message);
+ } on apple.SignInWithAppleException catch (error) {
+ debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
+ return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
}
@@ -115,9 +141,11 @@ final class AppleSignInProvider implements SocialAuthProvider {
@override
Future signOut() async {
- if (!FirebaseBoot.isLive) return;
- await fb.FirebaseAuth.instance.signOut();
+ // Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다.
}
+
+ bool get _requiresWebAuthentication =>
+ kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버.
@@ -137,12 +165,32 @@ final class KeylessSocialProvider implements SocialAuthProvider {
bool get isLive => false;
@override
- Future signIn() async => AuthNotConfigured(provider, missingConfig);
+ Future signIn() async =>
+ AuthNotConfigured(provider, missingConfig);
@override
Future signOut() async {}
}
+/// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다.
+/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다.
+apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() {
+ const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID');
+ const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI');
+ if (clientId.isEmpty || redirect.isEmpty) return null;
+
+ final redirectUri = Uri.tryParse(redirect);
+ if (redirectUri == null ||
+ !redirectUri.hasScheme ||
+ !redirectUri.hasAuthority) {
+ return null;
+ }
+ return apple.WebAuthenticationOptions(
+ clientId: clientId,
+ redirectUri: redirectUri,
+ );
+}
+
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
uid: user?.uid ?? '',
provider: provider,
diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart
index 9621a2a..8317782 100644
--- a/app/lib/features/galaxy/galaxy_screen.dart
+++ b/app/lib/features/galaxy/galaxy_screen.dart
@@ -11,6 +11,5 @@ class GalaxyScreen extends StatelessWidget {
tab: 'GALAXY',
screenId: 'S-G-PLANET',
screenName: '내 행성',
- specPath: 'wireframe/planning/screen-spec/galaxy/S-G-PLANET.md',
);
}
diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart
index 9ebc046..210bc76 100644
--- a/app/lib/features/home/home_screen.dart
+++ b/app/lib/features/home/home_screen.dart
@@ -11,6 +11,5 @@ class HomeScreen extends StatelessWidget {
tab: 'HOME',
screenId: 'S-H-HOME',
screenName: '오늘의 퀘스트',
- specPath: 'wireframe/planning/screen-spec/home/S-H-HOME.md',
);
}
diff --git a/app/lib/features/inside/inside_screen.dart b/app/lib/features/inside/inside_screen.dart
index 222fdc7..ba1ea15 100644
--- a/app/lib/features/inside/inside_screen.dart
+++ b/app/lib/features/inside/inside_screen.dart
@@ -11,6 +11,5 @@ class InsideScreen extends StatelessWidget {
tab: 'INSIDE',
screenId: 'S-I-LOUNGE',
screenName: '팬덤 라운지',
- specPath: 'wireframe/planning/screen-spec/inside/S-I-LOUNGE.md',
);
}
diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart
index 8739581..1623342 100644
--- a/app/lib/features/my/my_screen.dart
+++ b/app/lib/features/my/my_screen.dart
@@ -11,6 +11,5 @@ class MyScreen extends StatelessWidget {
tab: 'MY',
screenId: 'S-M-HOME',
screenName: '마이 페이지',
- specPath: 'wireframe/planning/screen-spec/my/S-M-HOME.md',
);
}
diff --git a/app/lib/features/station/station_screen.dart b/app/lib/features/station/station_screen.dart
index 8dd3a61..021dd1e 100644
--- a/app/lib/features/station/station_screen.dart
+++ b/app/lib/features/station/station_screen.dart
@@ -11,6 +11,5 @@ class StationScreen extends StatelessWidget {
tab: 'STATION',
screenId: 'S-S-HUB',
screenName: 'STATION 허브',
- specPath: 'wireframe/planning/screen-spec/station/S-S-HUB.md',
);
}
diff --git a/app/lib/main.dart b/app/lib/main.dart
index 2fcd1a1..4307e62 100644
--- a/app/lib/main.dart
+++ b/app/lib/main.dart
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'app.dart';
import 'core/firebase/firebase_boot.dart';
import 'features/auth/auth_repository.dart';
+import 'features/auth/firebase_auth_session.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
await FirebaseBoot.init();
- runApp(FancakeApp(auth: AuthRepository()));
+ final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null;
+ runApp(FancakeApp(auth: AuthRepository(session: session)));
}
diff --git a/app/pubspec.lock b/app/pubspec.lock
index cf459e3..77737d0 100644
--- a/app/pubspec.lock
+++ b/app/pubspec.lock
@@ -81,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
+ crypto:
+ dependency: "direct main"
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
fake_async:
dependency: transitive
description:
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index dac69e0..775ea05 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -21,6 +21,9 @@ dependencies:
firebase_core: ^4.13.0
firebase_auth: ^6.5.7
+ # Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
+ crypto: ^3.0.7
+
# 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3).
google_sign_in: ^7.2.0
sign_in_with_apple: ^8.1.0
diff --git a/app/test/app_smoke_test.dart b/app/test/app_smoke_test.dart
index ca49b1f..c28d052 100644
--- a/app/test/app_smoke_test.dart
+++ b/app/test/app_smoke_test.dart
@@ -11,7 +11,10 @@ import 'package:flutter_test/flutter_test.dart';
/// 2주차 스모크 — Firebase 설정 파일이 **없는 상태**(목 모드)에서 전부 통과해야 한다.
void main() {
setUp(() {
- FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '스모크 테스트 — 설정 파일 미투입 상황 재현');
+ FirebaseBoot.debugSetMode(
+ FirebaseMode.mock,
+ reason: '스모크 테스트 — 설정 파일 미투입 상황 재현',
+ );
});
testWidgets('1. 앱이 예외 없이 뜨고 로그인 화면이 나온다', (tester) async {
@@ -26,6 +29,20 @@ void main() {
}
});
+ testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async {
+ final session = _SmokeSession(
+ const AuthAccount(uid: 'restored-user', provider: SocialProvider.google),
+ );
+ final auth = AuthRepository(session: session, releaseMode: true);
+
+ await tester.pumpWidget(FancakeApp(auth: auth));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(NavigationBar), findsOneWidget);
+ expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
+ auth.dispose();
+ });
+
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
await _enterShell(tester);
@@ -61,17 +78,25 @@ void main() {
});
testWidgets('릴리스 빌드 로그인 화면에 목 세션 버튼이 없다', (tester) async {
- await tester.pumpWidget(FancakeApp(auth: AuthRepository(releaseMode: true)));
+ await tester.pumpWidget(
+ FancakeApp(auth: AuthRepository(releaseMode: true)),
+ );
await tester.pumpAndSettle();
expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
- expect(find.byType(NavigationBar), findsNothing, reason: '셸에 진입할 수 없어야 한다');
+ expect(
+ find.byType(NavigationBar),
+ findsNothing,
+ reason: '셸에 진입할 수 없어야 한다',
+ );
// 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다.
expect(find.text(SocialProvider.google.label), findsOneWidget);
});
testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async {
- await tester.pumpWidget(FancakeApp(auth: AuthRepository(releaseMode: false)));
+ await tester.pumpWidget(
+ FancakeApp(auth: AuthRepository(releaseMode: false)),
+ );
await tester.pumpAndSettle();
expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget);
@@ -81,7 +106,13 @@ void main() {
// 화면에 나가는 문자열 전체를 훑는다. reason 만 고쳤다가 제공자 missingConfig 로
// 리포 경로가 그대로 새어 나간 적이 있다(에뮬레이터 실기동 중 발견).
test('제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다', () {
- const forbidden = ['google-services.json', 'GoogleService-Info.plist', 'app/', '.plist', '.json'];
+ const forbidden = [
+ 'google-services.json',
+ 'GoogleService-Info.plist',
+ 'app/',
+ '.plist',
+ '.json',
+ ];
for (final p in AuthRepository().providers) {
for (final item in p.missingConfig) {
for (final bad in forbidden) {
@@ -126,9 +157,27 @@ void main() {
await tester.pumpAndSettle();
final (screenId, screenName) = entry.value;
- expect(find.text(screenId), findsOneWidget, reason: '${entry.key} → $screenId');
+ expect(
+ find.text(screenId),
+ findsOneWidget,
+ reason: '${entry.key} → $screenId',
+ );
// AppBar 제목과 본문 제목 두 군데에 나온다.
- expect(find.text(screenName), findsWidgets, reason: '${entry.key} → $screenName');
+ expect(
+ find.text(screenName),
+ findsWidgets,
+ reason: '${entry.key} → $screenName',
+ );
+ expect(
+ find.textContaining('wireframe/'),
+ findsNothing,
+ reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다',
+ );
+ expect(
+ find.textContaining('screen-spec'),
+ findsNothing,
+ reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다',
+ );
}
});
}
@@ -156,8 +205,22 @@ List _contractTabs() {
final file = File('../design/craft-allowlist.yaml');
final line = file.readAsLinesSync().firstWhere(
(l) => l.trimLeft().startsWith('APP_TABS:'),
- orElse: () => throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
+ orElse: () =>
+ throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
);
final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!;
return inner.split(',').map((s) => s.trim()).toList();
}
+
+final class _SmokeSession implements AuthSession {
+ const _SmokeSession(this.currentAccount);
+
+ @override
+ final AuthAccount? currentAccount;
+
+ @override
+ Stream get accountChanges => const Stream.empty();
+
+ @override
+ Future signOut() async {}
+}
diff --git a/app/test/auth_repository_test.dart b/app/test/auth_repository_test.dart
new file mode 100644
index 0000000..82f2524
--- /dev/null
+++ b/app/test/auth_repository_test.dart
@@ -0,0 +1,281 @@
+import 'dart:async';
+
+import 'package:fancake/core/firebase/firebase_boot.dart';
+import 'package:fancake/features/auth/auth.dart';
+import 'package:fancake/features/auth/auth_repository.dart';
+import 'package:fancake/features/auth/social_providers.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ setUp(() {
+ FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '인증 저장소 단위 테스트');
+ });
+
+ tearDown(() {
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ group('provider 결과 계약', () {
+ test('4개 provider의 성공 결과가 계정 상태로 반영된다', () async {
+ for (final provider in SocialProvider.values) {
+ final account = AuthAccount(
+ uid: 'uid-${provider.name}',
+ provider: provider,
+ );
+ final auth = AuthRepository(
+ providers: [_FakeProvider(provider, result: AuthSucceeded(account))],
+ releaseMode: true,
+ );
+
+ final outcome = await auth.signIn(provider);
+
+ expect(outcome, isA(), reason: provider.name);
+ expect(auth.account?.uid, account.uid, reason: provider.name);
+ expect(auth.isSignedIn, isTrue, reason: provider.name);
+ auth.dispose();
+ }
+ });
+
+ test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async {
+ for (final provider in SocialProvider.values) {
+ final auth = AuthRepository(
+ providers: [_FakeProvider(provider, result: const AuthCancelled())],
+ releaseMode: true,
+ );
+
+ expect(
+ await auth.signIn(provider),
+ isA(),
+ reason: provider.name,
+ );
+ expect(auth.isSignedIn, isFalse, reason: provider.name);
+ auth.dispose();
+ }
+ });
+
+ test('4개 provider의 SDK 실패가 E-AUTH-03으로 유지된다', () async {
+ for (final provider in SocialProvider.values) {
+ final auth = AuthRepository(
+ providers: [
+ _FakeProvider(
+ provider,
+ result: const AuthFailed('E-AUTH-03', 'SDK 실패'),
+ ),
+ ],
+ releaseMode: true,
+ );
+
+ final outcome = await auth.signIn(provider);
+
+ expect(outcome, isA(), reason: provider.name);
+ expect(
+ (outcome as AuthFailed).code,
+ 'E-AUTH-03',
+ reason: provider.name,
+ );
+ expect(auth.isSignedIn, isFalse, reason: provider.name);
+ auth.dispose();
+ }
+ });
+
+ test('4개 provider의 미설정 상태가 provider와 누락 항목을 보존한다', () async {
+ for (final provider in SocialProvider.values) {
+ final auth = AuthRepository(
+ providers: [
+ _FakeProvider(
+ provider,
+ result: AuthNotConfigured(provider, const ['테스트 설정']),
+ ),
+ ],
+ releaseMode: true,
+ );
+
+ final outcome = await auth.signIn(provider);
+
+ expect(outcome, isA(), reason: provider.name);
+ expect((outcome as AuthNotConfigured).provider, provider);
+ expect(outcome.missing, ['테스트 설정']);
+ expect(auth.isSignedIn, isFalse, reason: provider.name);
+ auth.dispose();
+ }
+ });
+
+ test('문서화되지 않은 SDK 예외도 사용자용 실패로 닫힌다', () async {
+ final auth = AuthRepository(
+ providers: [
+ _FakeProvider(
+ SocialProvider.google,
+ signInError: StateError('raw SDK error'),
+ ),
+ ],
+ releaseMode: true,
+ );
+
+ final outcome = await auth.signIn(SocialProvider.google);
+
+ expect(outcome, isA());
+ expect((outcome as AuthFailed).code, 'E-AUTH-03');
+ expect(outcome.message, '소셜 로그인에 실패했습니다.');
+ expect(auth.isSignedIn, isFalse);
+ auth.dispose();
+ });
+
+ test('Android Apple 로그인은 웹 인증 설정 전 SDK 호출을 차단한다', () {
+ FirebaseBoot.debugSetMode(
+ FirebaseMode.live,
+ reason: 'Android 설정 게이트 테스트',
+ );
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+
+ const provider = AppleSignInProvider();
+
+ expect(provider.missingConfig, contains('Apple Android 웹 인증 설정'));
+ });
+ });
+
+ group('세션 복원과 로그아웃', () {
+ test('현재 세션을 생성 즉시 복원하고 변경·만료를 계속 반영한다', () async {
+ final initial = const AuthAccount(
+ uid: 'restored-user',
+ provider: SocialProvider.google,
+ );
+ final session = _FakeSession(initial);
+ final auth = AuthRepository(
+ providers: const [],
+ session: session,
+ releaseMode: true,
+ );
+
+ expect(auth.account?.uid, 'restored-user');
+ expect(auth.isSignedIn, isTrue);
+
+ session.emit(
+ const AuthAccount(uid: 'apple-user', provider: SocialProvider.apple),
+ );
+ await Future.delayed(Duration.zero);
+ expect(auth.account?.uid, 'apple-user');
+
+ session.emit(null);
+ await Future.delayed(Duration.zero);
+ expect(auth.isSignedIn, isFalse);
+
+ auth.dispose();
+ await session.close();
+ });
+
+ test('로그아웃 뒤 같은 provider로 다시 로그인할 수 있다', () async {
+ final provider = _FakeProvider(
+ SocialProvider.google,
+ result: const AuthSucceeded(
+ AuthAccount(uid: 'relogin-user', provider: SocialProvider.google),
+ ),
+ );
+ final session = _FakeSession(
+ const AuthAccount(uid: 'old-user', provider: SocialProvider.google),
+ );
+ final auth = AuthRepository(
+ providers: [provider],
+ session: session,
+ releaseMode: true,
+ );
+
+ await auth.signOut();
+ expect(auth.isSignedIn, isFalse);
+ expect(provider.signOutCalls, 1);
+ expect(session.signOutCalls, 1);
+
+ expect(await auth.signIn(SocialProvider.google), isA());
+ expect(auth.account?.uid, 'relogin-user');
+
+ auth.dispose();
+ await session.close();
+ });
+
+ test('원격 로그아웃 실패가 나도 로컬 세션은 반드시 정리된다', () async {
+ final provider = _FakeProvider(
+ SocialProvider.apple,
+ result: const AuthCancelled(),
+ signOutError: StateError('provider sign-out failed'),
+ );
+ final session = _FakeSession(
+ const AuthAccount(uid: 'apple-user', provider: SocialProvider.apple),
+ );
+ final auth = AuthRepository(
+ providers: [provider],
+ session: session,
+ releaseMode: true,
+ );
+
+ await expectLater(auth.signOut(), throwsA(isA()));
+
+ expect(auth.isSignedIn, isFalse);
+ expect(provider.signOutCalls, 1);
+ expect(session.signOutCalls, 1);
+ auth.dispose();
+ await session.close();
+ });
+ });
+}
+
+final class _FakeProvider implements SocialAuthProvider {
+ _FakeProvider(
+ this.provider, {
+ this.result,
+ this.signInError,
+ this.signOutError,
+ });
+
+ @override
+ final SocialProvider provider;
+ final AuthOutcome? result;
+ final Object? signInError;
+ final Object? signOutError;
+ int signOutCalls = 0;
+
+ @override
+ bool get isLive => true;
+
+ @override
+ List get missingConfig => const [];
+
+ @override
+ Future signIn() async {
+ if (signInError case final error?) throw error;
+ return result!;
+ }
+
+ @override
+ Future signOut() async {
+ signOutCalls += 1;
+ if (signOutError case final error?) throw error;
+ }
+}
+
+final class _FakeSession implements AuthSession {
+ _FakeSession(this._current);
+
+ AuthAccount? _current;
+ final StreamController _changes =
+ StreamController.broadcast();
+ int signOutCalls = 0;
+
+ @override
+ AuthAccount? get currentAccount => _current;
+
+ @override
+ Stream get accountChanges => _changes.stream;
+
+ void emit(AuthAccount? account) {
+ _current = account;
+ _changes.add(account);
+ }
+
+ @override
+ Future signOut() async {
+ signOutCalls += 1;
+ _current = null;
+ }
+
+ Future close() => _changes.close();
+}
diff --git a/design/design-tokens.md b/design/design-tokens.md
index 22a65e1..1a567a3 100644
--- a/design/design-tokens.md
+++ b/design/design-tokens.md
@@ -24,22 +24,34 @@
- **서비스명은 `FANUZU`, 국문 병행 표기는 `팬우주`로 확정됐다.** 발주사 공식 검토 회신서의
표지 지시에 따라 종전 CD-01(`Fancake` 확정)은 철회·대체했다(W-01).
+## 디자인 언어 — 「포카 온 스테이지」 (2026-08-17 전면 재설계)
+- 은유: **암전한 공연장(꺼진 LED 전광판) 위에 놓인 실물 팬 굿즈.** 종전 코스믹(성운·별밭·유리·발광)
+ 언어를 전면 폐기했다 — "검정 + 네온 + 글래스"는 AI 다크 템플릿의 전형이었고, mobbin 리서치 결과
+ K-POP 팬덤이 실제로 열광하는 건 분위기가 아니라 **물성**(포토카드·홀로 포일·전광판·다이컷 스티커)이다.
+- 리서치 근거(mobbin MCP, 2026-08-17): Weverse 팬레터의 Y2K 팬시 물성 / Alan 의 홀로그램 수집품 리빌 /
+ Tolan 의 LED 도트 전광판 무대 / Lapse 의 순흑+앰버 tabular 카운트다운 / TikTok LIVE 랭킹 문법.
+- 4대 재료: ① 캔버스 = 중성 콜드 블랙 + 26px LED 도트 그리드 ② 콘텐츠 = **불투명 순백 포토카드**
+ (다크 프레임 안의 라이트 아일랜드) ③ 프리미엄 = **홀로 포일**(`--foil`, 2px 라인·노브 전용)
+ ④ 숫자 신호 = **전광판 앰버**(`color-gold`, 카운트다운·재화 스코어 전용).
+
## 컬러 운용 규율 (60-30-10)
-- 60 배경/서피스 · 30 중립 텍스트·보더 · **10 악센트**. 악센트는 **행동·선택·포커스에만** 쓴다.
-- 브랜드색으로 배경을 도배하지 않는다. 위계는 색이 아니라 타입 스케일과 여백으로 만든다.
-- 순흑(`#000`)은 **텍스트가 아니라 캔버스**다. 배경은 `color-cosmic-void`(#000), 글자는 `color-on-cosmic`.
-- 네온(시안·에메랄드·마젠타·바이올렛)은 **의미가 있을 때만** 쓴다 — 진행·성공·경고·프리미엄.
- 네 색을 한 화면에 다 풀지 않는다. 화면당 네온 악센트는 하나를 주로 하고 나머지는 보조로만.
+- 60 암전 블랙 캔버스 · 30 흰 포토카드 면 · **10 공식 로고 핑크**(행동·선택·포커스 전용).
+- 프레임 위 주 버튼 = 핑크 필, **포토카드 안 주 버튼 = 잉크 스탬프 필**(#201826) — 흰 면에 흰 필 금지.
+- 앰버는 색이 아니라 **신호**다: 대표 지표·재화 잔액·카운트다운 숫자에만. 장식 사용 금지.
+- 홀로 포일은 면에 풀지 않는다 — 2px 라인(모달 상단 박·KPI 모서리·섹션 바·아이브로 틱)과
+ 부스터 노브까지만. 시안·에메랄드·마젠타는 상태 의미(실시간·온에어·이클립스) 전용, **글로우는
+ 온에어 도트 한 곳뿐**(`--glow-live`).
## 서피스 전략 — 왜 두 벌인가
| surface | 기본 서피스 | 근거 |
|---|---|---|
-| app (네이티브) | **코스믹 보이드**(`color-cosmic-void` + 네뷸라 + 별밭) | 행성·갤럭시 맵·이클립스가 우주 배경을 전제로 한 연출이다. 인수 데모 프로토타입의 `body{background:#000}` 을 그대로 쓴다(결정 D-19). |
-| web (마케팅·사전예약) | 코스믹 보이드 (네뷸라 약하게) | 2026-08-02 변경. 랜딩에서 앱으로 넘어갈 때 브랜드가 끊기지 않게 같은 캔버스를 쓴다. |
-| admin (운영 콘솔) | 코스믹 서피스 (네뷸라 제외·별밭만) | 2026-08-02 변경. 색면이 표·수치와 경쟁하지 않도록 배경 광원을 걷어내고 별밭만 남긴다. |
+| app (네이티브) | **LED 스테이지**(콜드 블랙 `#0A0A0E` + 도트 그리드 + 상단 핑크 스포트라이트), 콘텐츠는 흰 포토카드 | 화면 자체가 공연장 전광판이라는 은유. 굿즈(카드·스티커 행성)가 주인공이 되려면 바닥이 무채색이어야 한다. |
+| web (마케팅·사전예약) | LED 스테이지 동일 | 랜딩에서 앱으로 넘어갈 때 브랜드가 끊기지 않게 같은 캔버스를 쓴다. |
+| admin (운영 콘솔) | 그라파이트 콘솔 (도트 더 옅게·스포트라이트 제외·굿즈 연출 제외) | 색면이 표·수치와 경쟁하지 않게. 포토카드 반전도 admin 에는 적용하지 않는다 — 데이터가 주인공. |
-> 세 surface 모두 다크로 통일한 이유는 `아트디렉션-예외.md` 에 적었다. 요약하면 PRD-13 「Non-Verbal
-> Cinematic UI」가 계약 요건이고, 발주사 인수 데모가 검정 캔버스·네온·글래스를 이미 확정했기 때문이다.
+> 세 surface 모두 다크로 통일한 이유는 `아트디렉션-예외.md` 에 적었다. PRD-13 「Non-Verbal
+> Cinematic UI」가 계약 요건이다. 2026-08-17 재설계는 다크 시네마틱을 유지하되 표현을
+> "분위기(성운·발광)"에서 "물성(굿즈)"으로 옮긴 것이다.
## 타이포 — 디스플레이 페어링
- 본문·UI: **Pretendard Variable** (한글 가독성) 고정.
@@ -55,76 +67,76 @@
- 깊이는 `elev-border`(헤어라인)가 1순위. 떠 있는 요소만 `elev-1~3`.
- 오버레이 순서는 `z-*` 토큰을 따른다.
-# ℹ primary 가독성 보정: #ff85d9 → #ac5992 (흰/검정 라벨이 둘 다 AA 미달인 중간톤 → 흰 글자 대비 4.5+ 확보)
+# ℹ primary 가독성 보정(라이트 면 한정): 로고 핑크 #ff98d7 → 딥핑크 #b93074 (흰 면 위 대비 5.6:1 · AA)
# ℹ 타입 디렉션: geometric — 디스플레이='Gmarket Sans' / 본문=Pretendard (예: 배달의민족 · 쿠팡 · 올리브영). 다른 디렉션: neutral, editorial, technical, warm
-
+
```yaml
colors:
- # --- app (MD3) ---
- color-primary: "#ac5992"
+ # --- app (MD3 라이트 대체 경로) ---
+ color-primary: "#b93074"
color-on-primary: "#ffffff"
- color-primary-container: "#f0e1eb"
- color-on-primary-container: "#924c7c"
- color-surface: "#f8f5fa"
- color-surface-container: "#ebe7ed"
- color-on-surface: "#1a1c1e"
- color-on-surface-variant: "#4b4753"
- color-outline: "#7a7481"
+ color-primary-container: "#ffe3f3"
+ color-on-primary-container: "#8a2e62"
+ color-surface: "#faf7f9"
+ color-surface-container: "#f0eaee"
+ color-on-surface: "#201826"
+ color-on-surface-variant: "#55495a"
+ color-outline: "#7d7383"
color-error: "#c53030"
color-on-error: "#ffffff"
- # --- web (Shadcn) · 고객 web + 관리자 admin 공용 · 중립은 브랜드 미세 틴트 ---
+ # --- web (Shadcn) · 고객 web + 관리자 admin 공용 · 포토카드(라이트 아일랜드)의 기본값 ---
web-background: "#ffffff"
- web-foreground: "#1a1c1e"
- web-primary: "#ac5992"
+ web-foreground: "#201826"
+ web-primary: "#b93074"
web-primary-foreground: "#ffffff"
- web-muted: "#f1eef4"
- web-muted-foreground: "#4b4753"
- web-border: "#ded7df"
- # --- 코스믹 다크 서피스 (전 surface 기본 · 결정 D-19 · 인수 데모 시각 언어 복원 2026-08-02) ---
- # 데모(FANUZU_prototype.tsx STYLES · FANUZU_HUD Proto.html)의 순수 검정 캔버스를 그대로 쓴다.
- color-cosmic-void: "#000000"
- color-cosmic-surface: "#05070F"
- color-cosmic-surface-container: "#0D1220"
- color-cosmic-surface-raised: "#141A2B"
- color-on-cosmic: "#F4F0F7"
- color-on-cosmic-variant: "#B4AEC0"
- # 네온 악센트 — 검정 위에서만 쓴다(전부 검정 대비 5:1 이상, 대비 보정 없이 원색 유지)
- color-cosmic-accent: "#FF85D9"
- color-on-cosmic-accent: "#05070F"
- color-neon-cyan: "#00F2FF"
+ web-muted: "#f6f0f4"
+ web-muted-foreground: "#6e6878"
+ web-border: "#e9e2e8"
+ # --- LED 스테이지 (전 surface 기본 · 2026-08-17 「포카 온 스테이지」 재설계) ---
+ # 보랏빛 웜 블랙을 폐기하고 꺼진 LED 패널의 중성 콜드 블랙으로 통일. 흰 포토카드가 서는 바닥.
+ color-cosmic-void: "#050508"
+ color-cosmic-surface: "#0A0A0E"
+ color-cosmic-surface-container: "#101016"
+ color-cosmic-surface-raised: "#16161D"
+ color-on-cosmic: "#F4F4F6"
+ color-on-cosmic-variant: "#A6A4B0"
+ # 악센트 — 공식 로고 실측 핑크 하나. 시안·에메랄드·마젠타는 상태 의미(실시간·온에어·이클립스) 전용.
+ # 바이올렛은 홀로 라일락(포일 스펙트럼 추출색)으로 재정의, 골드는 전광판 앰버(신호색)로 전환.
+ color-cosmic-accent: "#FF98D7"
+ color-on-cosmic-accent: "#2B0E1F"
+ color-neon-cyan: "#6FE8FF"
color-on-neon-cyan: "#05070F"
- color-neon-emerald: "#34D399"
+ color-neon-emerald: "#3DDC97"
color-on-neon-emerald: "#05070F"
- color-neon-magenta: "#FF007A"
+ color-neon-magenta: "#FF2E7E"
color-on-neon-magenta: "#05070F"
- color-neon-violet: "#A855F7"
+ color-neon-violet: "#C9A7FF"
color-on-neon-violet: "#05070F"
- color-cosmic-mint: "#C1FFED"
+ color-cosmic-mint: "#A9FFE0"
color-on-cosmic-mint: "#05070F"
- color-cosmic-outline: "#2A3348"
+ color-cosmic-outline: "#26262E"
+ color-gold: "#FFD34D"
+ color-on-gold: "#241A02"
cosmic_fx:
- # 인수 데모의 시각 언어를 토큰으로 고정한 그룹.
- # 킷 art-direction.md §0 은 글로우·그라데이션·글래스를 금지 신호로 두지만,
- # 이 프로젝트는 PRD-13 「Non-Verbal Cinematic UI」가 계약 요건이고 발주사 인수 데모가
- # 그 표현을 이미 확정했으므로 프로젝트 예외로 허용한다(근거: design/아트디렉션-예외.md).
- glass-fill: "rgba(255,255,255,.05)"
- glass-fill-strong: "rgba(255,255,255,.08)"
+ # 「포카 온 스테이지」 FX 그룹 (2026-08-17). 유리 블러는 상단바 한 곳, 발광은 온에어 도트 한 곳.
+ # PRD-13 다크 시네마틱 계약 요건은 캔버스(암전 무대)로 충족한다(근거: design/아트디렉션-예외.md).
+ glass-fill: "rgba(255,255,255,.06)"
+ glass-fill-strong: "rgba(255,255,255,.09)"
glass-stroke: "rgba(255,255,255,.10)"
glass-blur: "16px"
- glow-sm: "0 0 12px"
- glow-md: "0 0 24px"
- glow-lg: "0 0 48px"
- glow-accent: "0 0 24px rgba(255,133,217,.45)"
- glow-cyan: "0 0 24px rgba(0,242,255,.40)"
- glow-emerald: "0 0 24px rgba(52,211,153,.40)"
- glow-magenta: "0 0 28px rgba(255,0,122,.45)"
- grad-action: "linear-gradient(135deg,#34D399 0%,#4F46E5 100%)"
- grad-brand: "linear-gradient(135deg,#FF85D9 0%,#A855F7 100%)"
- grad-eclipse: "linear-gradient(135deg,#FF007A 0%,#7C1D3F 100%)"
+ glow-live: "0 0 12px rgba(61,220,151,.45)"
+ grad-action: "linear-gradient(135deg,#FF98D7 0%,#E24FA5 100%)"
+ grad-brand: "linear-gradient(135deg,#FF98D7 0%,#C9A7FF 100%)"
+ grad-gold: "linear-gradient(135deg,#FFE08A 0%,#FFC93C 100%)"
+ grad-eclipse: "linear-gradient(135deg,#FF2E7E 0%,#701A38 100%)"
grad-veil: "linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.85) 100%)"
- nebula: "radial-gradient(720px 460px at 10% -6%, rgba(168,85,247,.34), transparent 70%), radial-gradient(600px 400px at 94% 8%, rgba(0,242,255,.24), transparent 70%), radial-gradient(760px 520px at 74% 52%, rgba(255,0,122,.20), transparent 72%), radial-gradient(640px 460px at 16% 62%, rgba(88,60,180,.24), transparent 72%), radial-gradient(820px 560px at 82% 96%, rgba(255,133,217,.20), transparent 72%), radial-gradient(600px 440px at 8% 98%, rgba(52,211,153,.14), transparent 70%)"
- starfield: "radial-gradient(1.6px 1.6px at 33.1% 16.5%,rgba(255,255,255,0.44),transparent), radial-gradient(1px 1px at 53.4% 37.1%,rgba(255,133,217,0.52),transparent), radial-gradient(1px 1px at 10.3% 42.1%,rgba(255,255,255,0.7),transparent), radial-gradient(1px 1px at 7.7% 56.3%,rgba(168,85,247,0.75),transparent), radial-gradient(1.2px 1.2px at 93.0% 57.4%,rgba(255,255,255,0.94),transparent), radial-gradient(1px 1px at 6.5% 84.4%,rgba(0,242,255,0.48),transparent), radial-gradient(1.8px 1.8px at 13.3% 31.6%,rgba(168,85,247,0.5),transparent), radial-gradient(1px 1px at 57.8% 63.3%,rgba(255,255,255,0.7),transparent), radial-gradient(1px 1px at 8.0% 7.7%,rgba(0,242,255,0.77),transparent), radial-gradient(1.4px 1.4px at 43.0% 32.2%,rgba(0,242,255,0.6),transparent), radial-gradient(1.8px 1.8px at 25.8% 19.3%,rgba(255,255,255,0.45),transparent), radial-gradient(1px 1px at 30.8% 49.5%,rgba(168,85,247,0.65),transparent), radial-gradient(1.4px 1.4px at 60.5% 9.0%,rgba(0,242,255,0.49),transparent), radial-gradient(1.2px 1.2px at 34.8% 91.6%,rgba(255,255,255,0.93),transparent), radial-gradient(1.8px 1.8px at 9.5% 55.6%,rgba(52,211,153,0.57),transparent), radial-gradient(1.4px 1.4px at 68.7% 59.1%,rgba(52,211,153,0.65),transparent), radial-gradient(1.2px 1.2px at 82.6% 92.7%,rgba(168,85,247,0.77),transparent), radial-gradient(1.6px 1.6px at 7.8% 69.3%,rgba(255,133,217,0.95),transparent), radial-gradient(1.2px 1.2px at 80.9% 29.3%,rgba(168,85,247,0.59),transparent), radial-gradient(1.4px 1.4px at 92.3% 36.1%,rgba(255,255,255,0.67),transparent), radial-gradient(1.6px 1.6px at 22.9% 29.6%,rgba(255,255,255,0.62),transparent), radial-gradient(1px 1px at 90.0% 49.7%,rgba(0,242,255,0.62),transparent), radial-gradient(1.2px 1.2px at 28.7% 15.1%,rgba(52,211,153,0.7),transparent), radial-gradient(1.6px 1.6px at 69.8% 96.7%,rgba(0,242,255,0.93),transparent), radial-gradient(1px 1px at 16.5% 18.9%,rgba(168,85,247,0.53),transparent), radial-gradient(1px 1px at 48.6% 58.6%,rgba(255,255,255,0.4),transparent), radial-gradient(1.4px 1.4px at 42.2% 37.4%,rgba(255,255,255,0.92),transparent), radial-gradient(1.4px 1.4px at 68.3% 51.5%,rgba(168,85,247,0.77),transparent), radial-gradient(1.8px 1.8px at 7.2% 88.4%,rgba(52,211,153,0.77),transparent), radial-gradient(1.2px 1.2px at 55.7% 40.2%,rgba(255,255,255,0.66),transparent), radial-gradient(1px 1px at 40.4% 20.3%,rgba(0,242,255,0.49),transparent), radial-gradient(1px 1px at 34.6% 7.0%,rgba(255,133,217,0.48),transparent), radial-gradient(1px 1px at 11.7% 36.9%,rgba(255,255,255,0.88),transparent), radial-gradient(1px 1px at 61.0% 16.3%,rgba(255,255,255,0.73),transparent), radial-gradient(1.2px 1.2px at 47.5% 13.1%,rgba(0,242,255,0.66),transparent), radial-gradient(1.6px 1.6px at 31.9% 15.8%,rgba(255,255,255,0.81),transparent), radial-gradient(1.4px 1.4px at 47.9% 68.4%,rgba(255,255,255,0.51),transparent), radial-gradient(1.6px 1.6px at 93.4% 36.7%,rgba(255,133,217,0.9),transparent), radial-gradient(1.6px 1.6px at 74.8% 30.6%,rgba(52,211,153,0.45),transparent), radial-gradient(1px 1px at 83.2% 51.8%,rgba(255,255,255,0.82),transparent), radial-gradient(1px 1px at 53.1% 76.8%,rgba(168,85,247,0.52),transparent), radial-gradient(1.8px 1.8px at 79.9% 96.6%,rgba(255,255,255,0.84),transparent), radial-gradient(1px 1px at 80.6% 73.0%,rgba(255,255,255,0.68),transparent), radial-gradient(1px 1px at 36.1% 4.8%,rgba(52,211,153,0.55),transparent), radial-gradient(1px 1px at 26.9% 68.5%,rgba(0,242,255,0.84),transparent), radial-gradient(1px 1px at 71.4% 35.6%,rgba(255,255,255,0.52),transparent)"
+ # 무대 조명 — 프레임 상단 홑 핑크 스포트라이트(성운 수프의 후임, 단일 레이어).
+ nebula: "radial-gradient(1200px 540px at 50% -20%, rgba(255,152,215,.09), transparent 62%)"
+ # LED 도트 그리드 — 별밭의 후임. 이 레이어만은 타일이 맞다: size 26px 26px + repeat.
+ starfield: "radial-gradient(rgba(255,255,255,.05) 1px, transparent 1.4px)"
orbit-stroke: "rgba(255,255,255,.10)"
+ # 홀로 포일 — 포토카드 홀로그램. 프리미엄·위계 라인 전용(2px 라인·부스터 노브, 넓은 면 금지).
+ foil: "linear-gradient(90deg,#FF98D7 0%,#C9A7FF 28%,#8FE8FF 52%,#A9FFE0 74%,#FFD9EC 100%)"
brand:
brand-kakao: "#FEE500"
brand-kakao-label: "#000000"
@@ -137,11 +149,11 @@ brand:
brand-google-stroke: "#747775"
brand-google-label: "#1F1F1F"
font:
- font-display: "'Gmarket Sans', Pretendard Variable, sans-serif"
- font-body: "Pretendard Variable, system-ui, sans-serif"
- font-mono: "JetBrains Mono, IBM Plex Mono, ui-monospace, monospace"
+ font-display: "'Gmarket Sans', 'Pretendard Variable', sans-serif"
+ font-body: "'Pretendard Variable', system-ui, sans-serif"
+ font-mono: "'Pretendard Variable', ui-monospace, monospace"
font-direction: "geometric"
- font-source: "Gmarket Sans = webfontworld/jsdelivr · 본문 Pretendard = jsdelivr"
+ font-source: "Gmarket Sans = projectnoonnu woff 동봉 · Pretendard Variable = woff2 동봉 (시안/assets/ · file:// 오프라인 렌더용, 2026-08-14)"
type:
type-display-l: {family: font-display, size: 44, weight: 700, tracking: -0.01em, leading: 1.06}
type-display-m: {family: font-display, size: 32, weight: 700, tracking: -0.01em, leading: 1.11}
@@ -238,6 +250,7 @@ contrast_checks:
| 일자 | 내용 |
|---|---|
+| 2026-08-17 | **「포카 온 스테이지」 전면 재설계.** 사용자 지시로 2주차 코스믹 언어(성운·별밭·유리·발광·보랏빛 웜 블랙·골드 프리미엄)를 폐기하고, mobbin 리서치 기반의 굿즈 물성 언어(LED 도트 캔버스·흰 포토카드 반전·홀로 포일·전광판 앰버)로 교체했다. 공식 로고 핑크·5탭·다크 시네마틱(PRD-13)·동봉 폰트는 유지 |
| 2026-08-13 | **공식 자산 반영.** `고객사 준비 데모/LOGO/fanuzu logo only.png`를 공식 FANUZU 심볼 원천으로 확정하고 시안 로고·앱 아이콘에 반영했다. 제안 로고/Q6 대기는 종료했다 |
| 2026-08-11 | **W-01 브랜드 정정.** 발주사 공식 검토 회신서 표지에 따라 서비스명을 `FANUZU`(국문 `팬우주`)로 교체하고, 종전 CD-01(`Fancake` 확정)을 철회했다 |
| 2026-08-08 | **당시 CD-01 기준 정리.** 인수 데모를 근거로 `Fancake`를 확정값으로 보아 서비스명 확인 질문을 삭제했다. 이후 발주사 공식 회신으로 이 판단이 철회됐으며 위 2026-08-11 행이 현재값이다 |