diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0854f47..a8d1cb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,6 @@ jobs: - run: flutter pub get - run: dart format --set-exit-if-changed lib test - run: flutter analyze + # 디자인 토큰 소비 규율(DQ-13/14/15) — raw 색·fontSize·radius 리터럴 차단. + - run: dart run tool/check_design_tokens.dart - run: flutter test --coverage --concurrency=1 diff --git a/lib/screens/debt/debt_screen.dart b/lib/screens/debt/debt_screen.dart index cc3777d..52a4869 100644 --- a/lib/screens/debt/debt_screen.dart +++ b/lib/screens/debt/debt_screen.dart @@ -173,9 +173,9 @@ class _DebtScreenState extends State { t.warningFg, Color.lerp( t.warningFg, - const Color(0xFF000000), + const Color(0xFF000000), // token-exempt: warningFg 어둡게 섞는 색연산 0.22, - )!, // token-exempt: warningFg 어둡게 섞는 색연산 + )!, ], ), borderRadius: BorderRadius.circular(t.radiusLg), diff --git a/lib/screens/login.dart b/lib/screens/login.dart index e4c3908..c62919d 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -117,7 +117,7 @@ class _LoginScreenState extends State { SocialProvider.kakao, '카카오로 시작하기', SeedSource.kakao, - Color(0xFF191600), + Color(0xFF191600), // token-exempt: 카카오 브랜드 라벨색(가이드라인) brand: 'kakao', ), _SocialSpec( diff --git a/lib/widgets/dk_icon.dart b/lib/widgets/dk_icon.dart index 0e51ee9..e617083 100644 --- a/lib/widgets/dk_icon.dart +++ b/lib/widgets/dk_icon.dart @@ -56,6 +56,7 @@ class DkIcon extends StatelessWidget { this.name, { super.key, this.size = 22, + // token-exempt: const 기본값(라이트 fg) — 호출부가 대부분 토큰으로 덮어쓴다. this.color = const Color(0xFF1A1B1E), this.strokeWidth = 1.9, this.fill, diff --git a/tool/check_design_tokens.dart b/tool/check_design_tokens.dart new file mode 100644 index 0000000..2d314c7 --- /dev/null +++ b/tool/check_design_tokens.dart @@ -0,0 +1,101 @@ +// 디자인 토큰 소비 규율 게이트(DQ-13/14/15, ADR-0032 소비 규율). +// +// lib/(theme/ 제외)에서 seed 토큰을 우회하는 리터럴을 찾으면 실패한다: +// 1. raw `Color(0x...)` — 투명(0x00000000) 제외. 예외는 해당 라인 또는 위아래 +// 1줄의 `token-exempt: <사유>` 주석으로만 허용. +// 2. `fontSize: <숫자>` — 문서화된 예외 값(디스플레이 숫자 등)만 허용. +// 3. `BorderRadius.circular(<숫자>)` — 문서화된 예외 값만 허용. +// +// 예외 값 목록의 권위: docs/01-디자인분석/디자인시스템-토큰.md 1·4장. +// 실행: `dart run tool/check_design_tokens.dart` (CI ci.yml 이 호출). +import 'dart:io'; + +/// fontSize 허용 리터럴 — 스케일 밖 문서화 예외(디스플레이 숫자·목업 미니 라벨). +const Set kFontSizeExempt = { + '9', + '10.5', + '26', + '27', + '40', + '46', + '50', + '52', + '60', + '64', + '104', +}; + +/// radius 허용 리터럴 — 스펙 고정 예외(행 카드 18, 레일 카드 20 등). +const Set kRadiusExempt = { + '3', + '5', + '18', + '20', + '22', + '28', + '40', +}; + +final RegExp _color = RegExp(r'Color\(0x([0-9A-Fa-f]{8})\)'); +final RegExp _fontSize = RegExp(r'fontSize:\s*(\d+(?:\.\d+)?)[,)\s]'); +final RegExp _radius = RegExp(r'BorderRadius\.circular\((\d+(?:\.\d+)?)\)'); + +bool _exempt(List lines, int i) { + for (int j = i - 1; j <= i + 1; j++) { + if (j >= 0 && j < lines.length && lines[j].contains('token-exempt')) { + return true; + } + } + return false; +} + +void main() { + final List violations = []; + final Iterable files = Directory('lib') + .listSync(recursive: true) + .whereType() + .where((File f) => f.path.endsWith('.dart')) + .where((File f) => !f.path.startsWith('lib/theme/')); + + for (final File f in files) { + final List lines = f.readAsLinesSync(); + // 파일 전체 예외 선언(브랜드 마크 등)은 파일을 통째로 건너뛴다. + if (lines.isNotEmpty && lines.first.contains('token-exempt(파일 전체)')) { + continue; + } + for (int i = 0; i < lines.length; i++) { + final String line = lines[i]; + for (final RegExpMatch m in _color.allMatches(line)) { + if (m.group(1)!.toUpperCase() == '00000000') continue; // 투명 + if (_exempt(lines, i)) continue; + violations.add( + '${f.path}:${i + 1}: raw ${m.group(0)} — 토큰 또는 token-exempt 주석 필요', + ); + } + for (final RegExpMatch m in _fontSize.allMatches(line)) { + if (kFontSizeExempt.contains(m.group(1))) continue; + if (_exempt(lines, i)) continue; + violations.add( + '${f.path}:${i + 1}: fontSize ${m.group(1)} — SeedType 스케일 사용', + ); + } + for (final RegExpMatch m in _radius.allMatches(line)) { + if (kRadiusExempt.contains(m.group(1))) continue; + if (_exempt(lines, i)) continue; + violations.add( + '${f.path}:${i + 1}: radius ${m.group(1)} — SeedRadius 스케일 사용', + ); + } + } + } + + if (violations.isNotEmpty) { + stderr.writeln('디자인 토큰 게이트 실패 (${violations.length}건):'); + for (final String v in violations) { + stderr.writeln(' ✗ $v'); + } + exitCode = 1; + return; + } + stdout.writeln('디자인 토큰 게이트 통과'); +}