diff --git a/.swiftlint.yml b/.swiftlint.yml index 467ebdb..ab13113 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,37 +1,35 @@ -# Mozi SwiftLint draft -# 기준: StyleShare 계열 가이드 + 현재 레포(SwiftUI/TCA) -# 상태: 초안. 세부 룰은 추후 조정 가능 +# Mozi SwiftLint +# 기준: 안전성 우선 + SwiftUI/TCA/DesignSystem 실무 균형 +# strict CI를 유지하되, 스타일 노이즈는 완화 disabled_rules: - # 초안 단계 노이즈 완화 + # 포맷/스타일 노이즈 - trailing_whitespace - todo - multiple_closures_with_trailing_closure - trailing_comma - file_length - # 포맷터 없이 과도한 스타일 강제 완화 - opening_brace - statement_position + - sorted_imports + - nesting opt_in_rules: + # 안전성 - force_unwrapping - implicitly_unwrapped_optional - - closure_spacing - unowned_variable_capture + - weak_delegate + # 실용 품질 - private_over_fileprivate - empty_enum_arguments - - weak_delegate - first_where - contains_over_filter_count - contains_over_filter_is_empty - overridden_super_call - toggle_bool - - closure_body_length - - sorted_imports - - vertical_whitespace_closing_braces - modifier_order - redundant_nil_coalescing - - unused_import # 안전성 force_unwrapping: error @@ -39,7 +37,7 @@ implicitly_unwrapped_optional: error force_cast: warning force_try: warning -# 한 줄 길이: 초안 단계 노이즈 완화를 위해 상향 +# 가독성: 너무 타이트하지 않게 완화 line_length: warning: 120 error: 140 @@ -47,17 +45,14 @@ line_length: ignores_urls: true function_body_length: - warning: 60 - error: 100 + # --strict promotes warnings to errors, so warning is the practical gate + warning: 90 + error: 120 type_body_length: warning: 250 error: 350 -file_length: - warning: 400 - error: 600 - # 테스트 코드의 짧은 타입명(OK 등) 허용을 위해 최소 길이 완화 type_name: min_length: @@ -67,7 +62,7 @@ type_name: warning: 50 error: 60 -# 테스트 코드의 짧은 식별자(a/b/c 등) 허용을 위해 최소 길이 완화 +# 디자인 토큰 shade 이름(_50, _300)과 짧은 식별자 허용 identifier_name: min_length: warning: 1 @@ -75,6 +70,7 @@ identifier_name: max_length: warning: 50 error: 60 + allowed_symbols: "_" excluded: - id - x @@ -86,11 +82,18 @@ identifier_name: - to - at - vc - -nesting: - type_level: - warning: 2 - error: 3 + - _0 + - _50 + - _100 + - _200 + - _300 + - _400 + - _500 + - _600 + - _700 + - _800 + - _900 + - _1000 cyclomatic_complexity: warning: 12 diff --git a/Projects/App/Sources/DI/AppBootstrap.swift b/Projects/App/Sources/DI/AppBootstrap.swift index 4117fac..af778cf 100644 --- a/Projects/App/Sources/DI/AppBootstrap.swift +++ b/Projects/App/Sources/DI/AppBootstrap.swift @@ -1,10 +1,12 @@ import Foundation +import SharedDesignSystem import SharedLogger import ThirdParty enum AppBootstrap { @MainActor static func run() { + _ = DesignSystemFontRegistration.registerIfNeeded() let infra = InfraContainer.live() prepareDependencies { Dependencies.register(&$0, infra: infra) diff --git a/Projects/Shared/DesignSystem/Project.swift b/Projects/Shared/DesignSystem/Project.swift index b2d7d62..c9da7ca 100644 --- a/Projects/Shared/DesignSystem/Project.swift +++ b/Projects/Shared/DesignSystem/Project.swift @@ -6,5 +6,6 @@ let project = ProjectFactory.framework( dependencies: [ .sharedUtils, ], - resources: ["Resources/**"] + resources: ["Resources/**"], + includesTests: true ) diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Contents.json b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/Contents.json b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Apple.svg b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Apple.svg new file mode 100644 index 0000000..f689d28 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Apple.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Contents.json new file mode 100644 index 0000000..f3dc5cd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_apple.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Apple.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Contents.json new file mode 100644 index 0000000..3bf37ca --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Kakao.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Kakao.svg b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Kakao.svg new file mode 100644 index 0000000..e4b9c21 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Assets.xcassets/Icon/Social/icon_social_kakao.imageset/Kakao.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Bold.otf b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Bold.otf differ diff --git a/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Medium.otf b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Medium.otf differ diff --git a/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Regular.otf b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-Regular.otf differ diff --git a/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf differ diff --git a/Projects/Shared/DesignSystem/Sources/Access/CGFloat+DesignSystem.swift b/Projects/Shared/DesignSystem/Sources/Access/CGFloat+DesignSystem.swift new file mode 100644 index 0000000..a85e5a5 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Access/CGFloat+DesignSystem.swift @@ -0,0 +1,61 @@ +import CoreGraphics + +public extension CGFloat { + static let ds = DesignSystemMetrics() +} + +public struct DesignSystemMetrics: Sendable { + public let radius = RadiusMetrics() + public let spacing = SpacingMetrics() + public let border = BorderMetrics() + public let controlHeight = ControlHeightMetrics() + public let dim = DimMetrics() + + public init() {} +} + +public struct RadiusMetrics: Sendable { + public let none = SemanticNumber.Radius.none + public let xsm = SemanticNumber.Radius.xsm + public let sm = SemanticNumber.Radius.sm + public let md = SemanticNumber.Radius.md + public let lg = SemanticNumber.Radius.lg + public let xxlg = SemanticNumber.Radius.xxlg + public let full = SemanticNumber.Radius.full + + public init() {} +} + +public struct SpacingMetrics: Sendable { + public let xs = SemanticNumber.Spacing.xs + public let sm = SemanticNumber.Spacing.sm + public let md = SemanticNumber.Spacing.md + public let lg = SemanticNumber.Spacing.lg + public let xl = SemanticNumber.Spacing.xl + public let xxl = SemanticNumber.Spacing.xxl + + public init() {} +} + +public struct BorderMetrics: Sendable { + public let thin = SemanticNumber.Border.thin + public let thick = SemanticNumber.Border.thick + + public init() {} +} + +public struct ControlHeightMetrics: Sendable { + public let sm = SemanticNumber.ControlHeight.sm + public let md = SemanticNumber.ControlHeight.md + public let lg = SemanticNumber.ControlHeight.lg + + public init() {} +} + +public struct DimMetrics: Sendable { + public let light = SemanticNumber.Dim.light + public let `default` = SemanticNumber.Dim.default + public let heavy = SemanticNumber.Dim.heavy + + public init() {} +} diff --git a/Projects/Shared/DesignSystem/Sources/Access/Color+DesignSystem.swift b/Projects/Shared/DesignSystem/Sources/Access/Color+DesignSystem.swift new file mode 100644 index 0000000..40b8801 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Access/Color+DesignSystem.swift @@ -0,0 +1,212 @@ +import SwiftUI + +public extension Color { + static let ds = DesignSystemColors() +} + +public struct DesignSystemColors: Sendable { + public let button = ButtonColors() + public let text = TextColors() + public let background = BackgroundColors() + public let border = BorderColors() + public let dim = DimColors() + public let social = SocialColors() + + public init() {} +} + +public struct ButtonColors: Sendable { + public let primary = ButtonPrimaryColors() + public let secondary = ButtonSecondaryColors() + public let outlined = ButtonOutlinedColors() + public let text = ButtonTextColors() + + public init() {} +} + +public struct ButtonPrimaryColors: Sendable { + public let background = ButtonPrimaryBackgroundColors() + public let content = ButtonPrimaryContentColors() + + public init() {} +} + +public struct ButtonPrimaryBackgroundColors: Sendable { + public let `default` = SemanticColor.Button.Primary.background.default.color + public let pressed = SemanticColor.Button.Primary.background.pressed.color + public let disabled = SemanticColor.Button.Primary.background.disabled.color + + public init() {} +} + +public struct ButtonPrimaryContentColors: Sendable { + public let `default` = SemanticColor.Button.Primary.content.default.color + public let pressed = SemanticColor.Button.Primary.content.pressed.color + public let disabled = SemanticColor.Button.Primary.content.disabled.color + + public init() {} +} + +public struct ButtonSecondaryColors: Sendable { + public let background = ButtonSecondaryBackgroundColors() + public let content = ButtonSecondaryContentColors() + + public init() {} +} + +public struct ButtonSecondaryBackgroundColors: Sendable { + public let `default` = SemanticColor.Button.Secondary.background.default.color + public let pressed = SemanticColor.Button.Secondary.background.pressed.color + public let disabled = SemanticColor.Button.Secondary.background.disabled.color + + public init() {} +} + +public struct ButtonSecondaryContentColors: Sendable { + public let `default` = SemanticColor.Button.Secondary.content.default.color + public let pressed = SemanticColor.Button.Secondary.content.pressed.color + public let disabled = SemanticColor.Button.Secondary.content.disabled.color + + public init() {} +} + +public struct ButtonOutlinedColors: Sendable { + public let background = ButtonOutlinedBackgroundColors() + public let content = ButtonOutlinedContentColors() + public let border = ButtonOutlinedBorderColors() + + public init() {} +} + +public struct ButtonOutlinedBackgroundColors: Sendable { + public let `default` = SemanticColor.Button.Outlined.background.default.color + public let pressed = SemanticColor.Button.Outlined.background.pressed.color + public let disabled = SemanticColor.Button.Outlined.background.disabled.color + + public init() {} +} + +public struct ButtonOutlinedContentColors: Sendable { + public let `default` = SemanticColor.Button.Outlined.content.default.color + public let pressed = SemanticColor.Button.Outlined.content.pressed.color + public let disabled = SemanticColor.Button.Outlined.content.disabled.color + + public init() {} +} + +public struct ButtonOutlinedBorderColors: Sendable { + public let `default` = SemanticColor.Button.Outlined.border.default.color + public let pressed = SemanticColor.Button.Outlined.border.pressed.color + public let disabled = SemanticColor.Button.Outlined.border.disabled.color + + public init() {} +} + +public struct ButtonTextColors: Sendable { + public let background = ButtonTextBackgroundColors() + public let content = ButtonTextContentColors() + + public init() {} +} + +public struct ButtonTextBackgroundColors: Sendable { + public let `default` = SemanticColor.Button.Text.background.default.color + public let pressed = SemanticColor.Button.Text.background.pressed.color + public let disabled = SemanticColor.Button.Text.background.disabled.color + + public init() {} +} + +public struct ButtonTextContentColors: Sendable { + public let `default` = SemanticColor.Button.Text.content.default.color + public let pressed = SemanticColor.Button.Text.content.pressed.color + public let disabled = SemanticColor.Button.Text.content.disabled.color + + public init() {} +} + +public struct TextColors: Sendable { + public let neutral = TextNeutralColors() + public let primary = TextPrimaryColors() + public let secondary = TextSecondaryColors() + + public init() {} +} + +public struct TextNeutralColors: Sendable { + public let black = SemanticColor.Text.Neutral.black.color + public let darker = SemanticColor.Text.Neutral.darker.color + public let dark = SemanticColor.Text.Neutral.dark.color + public let basic = SemanticColor.Text.Neutral.basic.color + public let light = SemanticColor.Text.Neutral.light.color + public let lighter = SemanticColor.Text.Neutral.lighter.color + public let white = SemanticColor.Text.Neutral.white.color + + public init() {} +} + +public struct TextPrimaryColors: Sendable { + public let basic = SemanticColor.Text.Primary.basic.color + public let dark = SemanticColor.Text.Primary.dark.color + public let darker = SemanticColor.Text.Primary.darker.color + + public init() {} +} + +public struct TextSecondaryColors: Sendable { + public let basic = SemanticColor.Text.Secondary.basic.color + public let darker = SemanticColor.Text.Secondary.darker.color + + public init() {} +} + +public struct BackgroundColors: Sendable { + public let black = SemanticColor.Background.black.color + public let grayDarker = SemanticColor.Background.grayDarker.color + public let grayDark = SemanticColor.Background.grayDark.color + + public init() {} +} + +public struct BorderColors: Sendable { + public let primary = BorderPrimaryColors() + public let neutral = BorderNeutralColors() + + public init() {} +} + +public struct BorderPrimaryColors: Sendable { + public let basic = SemanticColor.Border.Primary.basic.color + public let dark = SemanticColor.Border.Primary.dark.color + public let darker = SemanticColor.Border.Primary.darker.color + + public init() {} +} + +public struct BorderNeutralColors: Sendable { + public let lighter = SemanticColor.Border.Neutral.lighter.color + public let light = SemanticColor.Border.Neutral.light.color + public let basic = SemanticColor.Border.Neutral.basic.color + public let dark = SemanticColor.Border.Neutral.dark.color + public let darker = SemanticColor.Border.Neutral.darker.color + + public init() {} +} + +public struct DimColors: Sendable { + public let light = SemanticColor.Dim.light.color + public let `default` = SemanticColor.Dim.default.color + public let heavy = SemanticColor.Dim.heavy.color + + public init() {} +} + +public struct SocialColors: Sendable { + public let kakao = SemanticColor.Social.kakao.color + public let kakaoContent = SemanticColor.Social.kakaoContent.color + public let appleBackground = SemanticColor.Social.appleBackground.color + public let appleBorder = SemanticColor.Social.appleBorder.color + public let appleContent = SemanticColor.Social.appleContent.color + + public init() {} +} diff --git a/Projects/Shared/DesignSystem/Sources/Access/Font+DesignSystem.swift b/Projects/Shared/DesignSystem/Sources/Access/Font+DesignSystem.swift new file mode 100644 index 0000000..d1dd648 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Access/Font+DesignSystem.swift @@ -0,0 +1,64 @@ +import SwiftUI + +public extension Font { + static let ds = DesignSystemFonts() +} + +public struct DesignSystemFonts: Sendable { + public let heading = HeadingFonts() + public let title = TitleFonts() + public let body = BodyFonts() + public let label = LabelFonts() + public let button = ButtonFonts() + + public init() {} +} + +public struct HeadingFonts: Sendable { + public var large32Bold: Font { TextStyle.ds.heading.large32Bold.font } + public var large32Semibold: Font { TextStyle.ds.heading.large32Semibold.font } + public var medium28Bold: Font { TextStyle.ds.heading.medium28Bold.font } + public var medium28Semibold: Font { TextStyle.ds.heading.medium28Semibold.font } + + public init() {} +} + +public struct TitleFonts: Sendable { + public var large24Bold: Font { TextStyle.ds.title.large24Bold.font } + public var large24Semibold: Font { TextStyle.ds.title.large24Semibold.font } + public var medium22Bold: Font { TextStyle.ds.title.medium22Bold.font } + public var medium22Semibold: Font { TextStyle.ds.title.medium22Semibold.font } + public var small20Bold: Font { TextStyle.ds.title.small20Bold.font } + public var small20Semibold: Font { TextStyle.ds.title.small20Semibold.font } + public var xsmall18Bold: Font { TextStyle.ds.title.xsmall18Bold.font } + public var xsmall18Semibold: Font { TextStyle.ds.title.xsmall18Semibold.font } + + public init() {} +} + +public struct BodyFonts: Sendable { + public var large18Semibold: Font { TextStyle.ds.body.large18Semibold.font } + public var large18Regular: Font { TextStyle.ds.body.large18Regular.font } + public var medium16Semibold: Font { TextStyle.ds.body.medium16Semibold.font } + public var medium16Regular: Font { TextStyle.ds.body.medium16Regular.font } + public var small14Semibold: Font { TextStyle.ds.body.small14Semibold.font } + public var small14Regular: Font { TextStyle.ds.body.small14Regular.font } + + public init() {} +} + +public struct LabelFonts: Sendable { + public var large16Medium: Font { TextStyle.ds.label.large16Medium.font } + public var medium14Medium: Font { TextStyle.ds.label.medium14Medium.font } + public var small12Medium: Font { TextStyle.ds.label.small12Medium.font } + + public init() {} +} + +public struct ButtonFonts: Sendable { + public var sm14Semibold: Font { TextStyle.ds.button.sm14Semibold.font } + public var md16Semibold: Font { TextStyle.ds.button.md16Semibold.font } + public var lg18Semibold: Font { TextStyle.ds.button.lg18Semibold.font } + + public init() {} +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift new file mode 100644 index 0000000..477e1c7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift @@ -0,0 +1,76 @@ +import SwiftUI + +public struct DesignButton: View { + private let title: String + private let variant: DesignButtonVariant + private let size: DesignButtonSize + private let isEnabled: Bool + private let isFullWidth: Bool + private let leadingIcon: Image? + private let trailingIcon: Image? + private let action: () -> Void + + public init( + _ title: String, + variant: DesignButtonVariant = .primary, + size: DesignButtonSize = .md, + isEnabled: Bool = true, + isFullWidth: Bool = false, + leadingIcon: Image? = nil, + trailingIcon: Image? = nil, + action: @escaping () -> Void + ) { + self.title = title + self.variant = variant + self.size = size + self.isEnabled = isEnabled + self.isFullWidth = isFullWidth + self.leadingIcon = leadingIcon + self.trailingIcon = trailingIcon + self.action = action + } + + public var body: some View { + Button(action: action) { + labelContent + } + .buttonStyle( + DesignButtonChromeStyle( + variant: variant, + size: size, + isFullWidth: isFullWidth + ) + ) + .disabled(!isEnabled) + } + + private var labelContent: some View { + let textStyle = size.textStyle + + // Width/height ownership is intentionally in DesignButtonChromeStyle + // so padding/frame order stays consistent for full-width layout. + return HStack(spacing: size.contentGap) { + if let leadingIcon { + leadingIcon + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: size.iconSize, height: size.iconSize) + } + + Text(title) + .font(textStyle.font) + .kerning(textStyle.letterSpacing) + .lineSpacing(textStyle.additionalLineSpacing) + .lineLimit(1) + + if let trailingIcon { + trailingIcon + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: size.iconSize, height: size.iconSize) + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonSize.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonSize.swift new file mode 100644 index 0000000..1b7d745 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonSize.swift @@ -0,0 +1,64 @@ +import CoreGraphics + +public enum DesignButtonSize: Sendable { + case sm + case md + case lg + + public var height: CGFloat { + switch self { + case .sm: + SemanticNumber.ControlHeight.sm + case .md: + SemanticNumber.ControlHeight.md + case .lg: + SemanticNumber.ControlHeight.lg + } + } + + public var horizontalPadding: CGFloat { + switch self { + case .sm: + 12 + case .md: + 16 + case .lg: + 20 + } + } + + public var cornerRadius: CGFloat { + switch self { + case .sm, .md: + SemanticNumber.Radius.xsm + case .lg: + SemanticNumber.Radius.sm + } + } + + public var iconSize: CGFloat { + switch self { + case .sm: + 14 + case .md: + 16 + case .lg: + 18 + } + } + + public var textStyle: TextStyle { + switch self { + case .sm: + TextStyle.ds.button.sm14Semibold + case .md: + TextStyle.ds.button.md16Semibold + case .lg: + TextStyle.ds.button.lg18Semibold + } + } + + public var contentGap: CGFloat { + SemanticNumber.Spacing.sm + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift new file mode 100644 index 0000000..66dee3c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift @@ -0,0 +1,212 @@ +import CoreGraphics +import SwiftUI + +enum DesignButtonInteractionState: Sendable { + case `default` + case pressed + case disabled +} + +struct DesignButtonResolvedStyle: Equatable, Sendable { + let background: TokenColor + let content: TokenColor + let border: TokenColor? + let borderWidth: CGFloat +} + +enum DesignButtonStyleResolver { + static func resolve( + variant: DesignButtonVariant, + state: DesignButtonInteractionState + ) -> DesignButtonResolvedStyle { + switch variant { + case .primary: + resolvePrimary(state: state) + case .secondary: + resolveSecondary(state: state) + case .outlined: + resolveOutlined(state: state) + case .text: + resolveText(state: state) + } + } + + private static func resolvePrimary( + state: DesignButtonInteractionState + ) -> DesignButtonResolvedStyle { + switch state { + case .default: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Primary.background.default, + content: SemanticColor.Button.Primary.content.default, + border: nil, + borderWidth: 0 + ) + case .pressed: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Primary.background.pressed, + content: SemanticColor.Button.Primary.content.pressed, + border: nil, + borderWidth: 0 + ) + case .disabled: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Primary.background.disabled, + content: SemanticColor.Button.Primary.content.disabled, + border: nil, + borderWidth: 0 + ) + } + } + + private static func resolveSecondary( + state: DesignButtonInteractionState + ) -> DesignButtonResolvedStyle { + switch state { + case .default: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Secondary.background.default, + content: SemanticColor.Button.Secondary.content.default, + border: nil, + borderWidth: 0 + ) + case .pressed: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Secondary.background.pressed, + content: SemanticColor.Button.Secondary.content.pressed, + border: nil, + borderWidth: 0 + ) + case .disabled: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Secondary.background.disabled, + content: SemanticColor.Button.Secondary.content.disabled, + border: nil, + borderWidth: 0 + ) + } + } + + private static func resolveOutlined( + state: DesignButtonInteractionState + ) -> DesignButtonResolvedStyle { + switch state { + case .default: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Outlined.background.default, + content: SemanticColor.Button.Outlined.content.default, + border: SemanticColor.Button.Outlined.border.default, + borderWidth: SemanticNumber.Border.thin + ) + case .pressed: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Outlined.background.pressed, + content: SemanticColor.Button.Outlined.content.pressed, + border: SemanticColor.Button.Outlined.border.pressed, + borderWidth: SemanticNumber.Border.thin + ) + case .disabled: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Outlined.background.disabled, + content: SemanticColor.Button.Outlined.content.disabled, + border: SemanticColor.Button.Outlined.border.disabled, + borderWidth: SemanticNumber.Border.thin + ) + } + } + + private static func resolveText( + state: DesignButtonInteractionState + ) -> DesignButtonResolvedStyle { + switch state { + case .default: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Text.background.default, + content: SemanticColor.Button.Text.content.default, + border: nil, + borderWidth: 0 + ) + case .pressed: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Text.background.pressed, + content: SemanticColor.Button.Text.content.pressed, + border: nil, + borderWidth: 0 + ) + case .disabled: + DesignButtonResolvedStyle( + background: SemanticColor.Button.Text.background.disabled, + content: SemanticColor.Button.Text.content.disabled, + border: nil, + borderWidth: 0 + ) + } + } +} + +enum DesignButtonInteractionStateResolver { + static func resolve(isEnabled: Bool, isPressed: Bool) -> DesignButtonInteractionState { + if !isEnabled { + return .disabled + } + return isPressed ? .pressed : .default + } +} + +struct DesignButtonChromeStyle: ButtonStyle { + let variant: DesignButtonVariant + let size: DesignButtonSize + let isFullWidth: Bool + + func makeBody(configuration: Configuration) -> some View { + // Nested content reads @Environment(\.isEnabled) so parent .disabled + // and DesignButton(isEnabled:) both select disabled tokens. + ChromeContent( + configuration: configuration, + variant: variant, + size: size, + isFullWidth: isFullWidth + ) + } +} + +private struct ChromeContent: View { + let configuration: ButtonStyle.Configuration + let variant: DesignButtonVariant + let size: DesignButtonSize + let isFullWidth: Bool + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + let style = DesignButtonStyleResolver.resolve( + variant: variant, + state: DesignButtonInteractionStateResolver.resolve( + isEnabled: isEnabled, + isPressed: configuration.isPressed + ) + ) + + // Layout ownership lives here: + // label → horizontal padding → expand/height → chrome. + // Padding must come before maxWidth expansion so full-width + // buttons do not request parent-width + padding. + configuration.label + .foregroundStyle(style.content.color) + .padding(.horizontal, size.horizontalPadding) + .frame( + maxWidth: isFullWidth ? .infinity : nil, + minHeight: size.height, + maxHeight: size.height + ) + .background(style.background.color) + .clipShape(RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous)) + .overlay { + if let border = style.border { + RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous) + .strokeBorder(border.color, lineWidth: style.borderWidth) + } + } + .contentShape(RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonVariant.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonVariant.swift new file mode 100644 index 0000000..92a05ec --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonVariant.swift @@ -0,0 +1,6 @@ +public enum DesignButtonVariant: Sendable { + case primary + case secondary + case outlined + case text +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift new file mode 100644 index 0000000..98b3534 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift @@ -0,0 +1,106 @@ +import SwiftUI + +public struct SocialLoginButton: View { + private let provider: SocialLoginProvider + private let action: () -> Void + + public init( + provider: SocialLoginProvider, + action: @escaping () -> Void + ) { + self.provider = provider + self.action = action + } + + public var body: some View { + Button(action: action) { + labelContent + } + .buttonStyle( + SocialLoginButtonChromeStyle( + background: provider.background, + content: provider.content, + border: provider.border + ) + ) + } + + private var labelContent: some View { + let textStyle = TextStyle.ds.button.lg18Semibold + + return HStack(spacing: provider.contentGap) { + providerIcon + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame( + width: provider.iconSize.width, + height: provider.iconSize.height + ) + + Text(provider.title) + .font(textStyle.font) + .kerning(textStyle.letterSpacing) + .lineSpacing(textStyle.additionalLineSpacing) + .lineLimit(1) + } + } + + private var providerIcon: Image { + switch provider { + case .kakao: + SharedDesignSystemAsset.iconSocialKakao.swiftUIImage + case .apple: + SharedDesignSystemAsset.iconSocialApple.swiftUIImage + } + } +} + +private struct SocialLoginButtonChromeStyle: ButtonStyle { + let background: TokenColor + let content: TokenColor + let border: TokenColor? + + func makeBody(configuration: Configuration) -> some View { + // Full-width social CTAs expand first, then paint chrome. + configuration.label + .foregroundStyle(content.color) + .frame( + maxWidth: .infinity, + minHeight: SocialLoginButtonMetrics.height, + maxHeight: SocialLoginButtonMetrics.height + ) + .background(background.color) + .clipShape( + RoundedRectangle( + cornerRadius: SocialLoginButtonMetrics.cornerRadius, + style: .continuous + ) + ) + .overlay { + if let border { + RoundedRectangle( + cornerRadius: SocialLoginButtonMetrics.cornerRadius, + style: .continuous + ) + .strokeBorder( + border.color, + lineWidth: SocialLoginButtonMetrics.borderWidth + ) + } + } + .contentShape( + RoundedRectangle( + cornerRadius: SocialLoginButtonMetrics.cornerRadius, + style: .continuous + ) + ) + .opacity(configuration.isPressed ? 0.88 : 1) + } +} + +private enum SocialLoginButtonMetrics { + static let height: CGFloat = SemanticNumber.ControlHeight.lg + static let cornerRadius: CGFloat = SemanticNumber.Radius.sm + static let borderWidth: CGFloat = SemanticNumber.Border.thin +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginProvider.swift b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginProvider.swift new file mode 100644 index 0000000..64a8dc2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginProvider.swift @@ -0,0 +1,60 @@ +import CoreGraphics + +public enum SocialLoginProvider: Sendable { + case kakao + case apple + + public var title: String { + switch self { + case .kakao: + "카카오로 시작하기" + case .apple: + "Apple로 로그인" + } + } + + var background: TokenColor { + switch self { + case .kakao: + SemanticColor.Social.kakao + case .apple: + SemanticColor.Social.appleBackground + } + } + + var content: TokenColor { + switch self { + case .kakao: + SemanticColor.Social.kakaoContent + case .apple: + SemanticColor.Social.appleContent + } + } + + var border: TokenColor? { + switch self { + case .kakao: + nil + case .apple: + SemanticColor.Social.appleBorder + } + } + + var contentGap: CGFloat { + switch self { + case .kakao: + 12 + case .apple: + 9 + } + } + + var iconSize: CGSize { + switch self { + case .kakao: + CGSize(width: 18, height: 18) + case .apple: + CGSize(width: 15, height: 18) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Text/DesignText.swift b/Projects/Shared/DesignSystem/Sources/Components/Text/DesignText.swift new file mode 100644 index 0000000..e0caf1e --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Text/DesignText.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public struct DesignText: View { + private let text: String + private let style: TextStyle + private let color: Color + private let alignment: TextAlignment + private let lineLimit: Int? + + public init( + _ text: String, + style: TextStyle, + color: Color = Color.ds.text.neutral.white, + alignment: TextAlignment = .leading, + lineLimit: Int? = nil + ) { + self.text = text + self.style = style + self.color = color + self.alignment = alignment + self.lineLimit = lineLimit + } + + public var body: some View { + Text(text) + .font(style.font) + .kerning(style.letterSpacing) + .lineSpacing(style.additionalLineSpacing) + .foregroundStyle(color) + .multilineTextAlignment(alignment) + .lineLimit(lineLimit) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Foundation/Color+Hex.swift b/Projects/Shared/DesignSystem/Sources/Foundation/Color+Hex.swift new file mode 100644 index 0000000..ae53d5c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Foundation/Color+Hex.swift @@ -0,0 +1,10 @@ +import SwiftUI + +extension Color { + init(hex: UInt32, alpha: Double = 1.0) { + let red = Double((hex >> 16) & 0xFF) / 255.0 + let green = Double((hex >> 8) & 0xFF) / 255.0 + let blue = Double(hex & 0xFF) / 255.0 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift b/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift new file mode 100644 index 0000000..082a7b7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift @@ -0,0 +1,67 @@ +import CoreText +import Foundation + +public enum DesignSystemFontRegistration { + private static let lock = NSLock() + // Protected by `lock`; Swift 6 requires an explicit escape hatch for mutable static state. + nonisolated(unsafe) private static var didRegister = false + + private static let fileNames = [ + "Pretendard-Regular", + "Pretendard-Medium", + "Pretendard-SemiBold", + "Pretendard-Bold", + ] + + @discardableResult + public static func registerIfNeeded() -> Bool { + lock.lock() + defer { lock.unlock() } + + if didRegister { + return true + } + + var success = true + for fileName in fileNames { + success = registerFont(named: fileName) && success + } + + didRegister = success + return success + } + + private static func registerFont(named fileName: String) -> Bool { + let extensions = ["otf", "ttf"] + let bundle = resourceBundle + + guard + let url = extensions + .compactMap({ bundle.url(forResource: fileName, withExtension: $0) }) + .first + else { + return false + } + + var error: Unmanaged? + if CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) { + return true + } + + guard let cfError = error?.takeRetainedValue() else { + return false + } + + let nsError = cfError as Error as NSError + // Already present in the process is fine for retries / multi-entry bootstrap. + return nsError.domain == (kCTFontManagerErrorDomain as String) + && ( + nsError.code == CTFontManagerError.alreadyRegistered.rawValue + || nsError.code == CTFontManagerError.duplicatedName.rawValue + ) + } + + private static var resourceBundle: Bundle { + SharedDesignSystemResources.bundle + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Placeholder.swift b/Projects/Shared/DesignSystem/Sources/Placeholder.swift deleted file mode 100644 index 532ecd0..0000000 --- a/Projects/Shared/DesignSystem/Sources/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -public enum SharedDesignSystemPlaceholder {} diff --git a/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift b/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift new file mode 100644 index 0000000..05fc8f1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift @@ -0,0 +1,298 @@ +#if DEBUG +import SwiftUI + +/// DEBUG-only visual catalog for SharedDesignSystem tokens/components. +struct DesignSystemGallery: View { + @State private var selectedSection: GallerySection? = .colors + + var body: some View { + NavigationSplitView { + List(GallerySection.allCases, selection: $selectedSection) { section in + Text(section.title) + .foregroundStyle(Color.ds.text.neutral.white) + .tag(section) + } + .navigationTitle("Design System") + .scrollContentBackground(.hidden) + .background(Color.ds.background.black) + } detail: { + Group { + if let selectedSection { + sectionDetail(selectedSection) + } else { + ContentUnavailableView( + "Select a section", + systemImage: "sidebar.left", + description: Text("Choose a design system section from the list.") + ) + .foregroundStyle(Color.ds.text.neutral.basic) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.ds.background.black) + } + .preferredColorScheme(.dark) + } + + @ViewBuilder + private func sectionDetail(_ section: GallerySection) -> some View { + switch section { + case .colors: + GalleryColorsSection() + case .typography: + GalleryTypographySection() + case .buttons: + GalleryButtonsSection() + case .social: + GallerySocialSection() + } + } +} + +// MARK: - Section Model + +private enum GallerySection: String, CaseIterable, Identifiable, Hashable { + case colors + case typography + case buttons + case social + + var id: String { rawValue } + + var title: String { + switch self { + case .colors: "Colors" + case .typography: "Typography" + case .buttons: "Buttons" + case .social: "Social" + } + } +} + +// MARK: - Section Screens + +private struct GalleryColorsSection: View { + var body: some View { + GallerySectionScaffold( + title: "Colors", + subtitle: "Semantic color tokens used by Feature" + ) { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.md) { + colorRow( + title: "Text", + swatches: [ + ("white", Color.ds.text.neutral.white), + ("basic", Color.ds.text.neutral.basic), + ("dark", Color.ds.text.neutral.dark), + ("primary", Color.ds.text.primary.basic), + ("secondary", Color.ds.text.secondary.basic), + ] + ) + + colorRow( + title: "Background", + swatches: [ + ("black", Color.ds.background.black), + ("grayDarker", Color.ds.background.grayDarker), + ("grayDark", Color.ds.background.grayDark), + ] + ) + + colorRow( + title: "Button Primary BG", + swatches: [ + ("default", Color.ds.button.primary.background.default), + ("pressed", Color.ds.button.primary.background.pressed), + ("disabled", Color.ds.button.primary.background.disabled), + ] + ) + + colorRow( + title: "Social Colors", + swatches: [ + ("kakao", Color.ds.social.kakao), + ("appleBG", Color.ds.social.appleBackground), + ("appleBorder", Color.ds.social.appleBorder), + ] + ) + } + } + } + + private func colorRow(title: String, swatches: [(String, Color)]) -> some View { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.xs) { + DesignText(title, style: TextStyle.ds.label.medium14Medium, color: Color.ds.text.neutral.basic) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: CGFloat.ds.spacing.sm) { + ForEach(Array(swatches.enumerated()), id: \.offset) { _, item in + VStack(spacing: CGFloat.ds.spacing.xs) { + RoundedRectangle(cornerRadius: CGFloat.ds.radius.xsm, style: .continuous) + .fill(item.1) + .frame(width: 56, height: 56) + .overlay { + RoundedRectangle(cornerRadius: CGFloat.ds.radius.xsm, style: .continuous) + .strokeBorder( + Color.ds.border.neutral.basic.opacity(0.4), + lineWidth: CGFloat.ds.border.thin + ) + } + + DesignText( + item.0, + style: TextStyle.ds.label.small12Medium, + color: Color.ds.text.neutral.light, + alignment: .center + ) + .frame(width: 64) + } + } + } + } + } + } +} + +private struct GalleryTypographySection: View { + var body: some View { + GallerySectionScaffold( + title: "Typography", + subtitle: "Pretendard text styles from TextStyle.ds" + ) { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.sm) { + DesignText("Heading / large32Bold", style: TextStyle.ds.heading.large32Bold) + DesignText("Title / large24Bold", style: TextStyle.ds.title.large24Bold) + DesignText("Title / medium22Semibold", style: TextStyle.ds.title.medium22Semibold) + DesignText( + "Body / medium16Regular", + style: TextStyle.ds.body.medium16Regular, + color: Color.ds.text.neutral.basic + ) + DesignText( + "Body / small14Regular", + style: TextStyle.ds.body.small14Regular, + color: Color.ds.text.neutral.basic + ) + DesignText( + "Label / medium14Medium", + style: TextStyle.ds.label.medium14Medium, + color: Color.ds.text.primary.basic + ) + DesignText("Button / lg18Semibold", style: TextStyle.ds.button.lg18Semibold) + } + } + } +} + +private struct GalleryButtonsSection: View { + var body: some View { + GallerySectionScaffold( + title: "DesignButton", + subtitle: "Variants, sizes, full-width, disabled, icons" + ) { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.md) { + buttonVariantRow(title: "Primary", variant: .primary) + buttonVariantRow(title: "Secondary", variant: .secondary) + buttonVariantRow(title: "Outlined", variant: .outlined) + buttonVariantRow(title: "Text", variant: .text) + + DesignText( + "Full width / disabled", + style: TextStyle.ds.label.small12Medium, + color: Color.ds.text.neutral.basic + ) + + DesignButton("Full Width Primary", variant: .primary, size: .lg, isFullWidth: true) {} + DesignButton( + "Disabled Primary", + variant: .primary, + size: .md, + isEnabled: false, + isFullWidth: true + ) {} + DesignButton( + "With Icons", + variant: .secondary, + size: .md, + leadingIcon: Image(systemName: "checkmark.circle.fill"), + trailingIcon: Image(systemName: "chevron.right") + ) {} + } + } + } + + private func buttonVariantRow(title: String, variant: DesignButtonVariant) -> some View { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.sm) { + DesignText(title, style: TextStyle.ds.label.medium14Medium, color: Color.ds.text.neutral.basic) + + HStack(spacing: CGFloat.ds.spacing.sm) { + DesignButton("sm", variant: variant, size: .sm) {} + DesignButton("md", variant: variant, size: .md) {} + DesignButton("lg", variant: variant, size: .lg) {} + } + } + } +} + +private struct GallerySocialSection: View { + var body: some View { + GallerySectionScaffold( + title: "SocialLoginButton", + subtitle: "Kakao / Apple fixed brand buttons" + ) { + VStack(spacing: CGFloat.ds.spacing.sm) { + SocialLoginButton(provider: .kakao) {} + SocialLoginButton(provider: .apple) {} + } + } + } +} + +// MARK: - Shared Scaffold + +private struct GallerySectionScaffold: View { + let title: String + let subtitle: String + @ViewBuilder let content: Content + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.lg) { + VStack(alignment: .leading, spacing: CGFloat.ds.spacing.xs) { + DesignText(title, style: TextStyle.ds.title.large24Bold) + DesignText( + subtitle, + style: TextStyle.ds.body.medium16Regular, + color: Color.ds.text.neutral.basic + ) + } + + content + .padding(CGFloat.ds.spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.ds.background.grayDarker) + .clipShape(RoundedRectangle(cornerRadius: CGFloat.ds.radius.md, style: .continuous)) + } + .padding(CGFloat.ds.spacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.ds.background.black) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview("Design System Gallery") { + DesignSystemGalleryPreviewHost() +} + +/// Preview-only host that registers fonts without making Gallery itself do bootstrap work. +private struct DesignSystemGalleryPreviewHost: View { + var body: some View { + DesignSystemGallery() + .onAppear { + _ = DesignSystemFontRegistration.registerIfNeeded() + } + } +} +#endif diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveColor.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveColor.swift new file mode 100644 index 0000000..485b67c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveColor.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct TokenColor: Equatable, Sendable { + let hexRGB: UInt32 + let alpha: Double + + init(hexRGB: UInt32, alpha: Double = 1.0) { + self.hexRGB = hexRGB + self.alpha = alpha + } + + var color: Color { Color(hex: hexRGB, alpha: alpha) } +} + +enum PrimitiveColor { + enum Primary { + static let _50 = TokenColor(hexRGB: 0xF0E89A) + static let _100 = TokenColor(hexRGB: 0xEDE58C) + static let _200 = TokenColor(hexRGB: 0xEBE17D) + static let _300 = TokenColor(hexRGB: 0xE9DE6F) + static let _400 = TokenColor(hexRGB: 0xD2C864) + static let _500 = TokenColor(hexRGB: 0xBAB259) + static let _600 = TokenColor(hexRGB: 0xA39B4E) + static let _700 = TokenColor(hexRGB: 0x8C8543) + static let _800 = TokenColor(hexRGB: 0x5D592C) + static let _900 = TokenColor(hexRGB: 0x464321) + static let _1000 = TokenColor(hexRGB: 0x2F2C16) + } + + enum Secondary { + static let _100 = TokenColor(hexRGB: 0xF8FBEF) + static let _200 = TokenColor(hexRGB: 0xEFF7D6) + static let _300 = TokenColor(hexRGB: 0xE5F2BD) + static let _400 = TokenColor(hexRGB: 0xD3E98B) + static let _500 = TokenColor(hexRGB: 0xD0E77C) + static let _600 = TokenColor(hexRGB: 0xB9CF6F) + static let _700 = TokenColor(hexRGB: 0xA2B661) + static let _800 = TokenColor(hexRGB: 0x8B9E54) + static let _900 = TokenColor(hexRGB: 0x748546) + } + + enum Neutral { + static let _0 = TokenColor(hexRGB: 0xFFFFFF) + static let _100 = TokenColor(hexRGB: 0xE5E5E5) + static let _200 = TokenColor(hexRGB: 0xCCCCCC) + static let _300 = TokenColor(hexRGB: 0xB2B2B2) + static let _400 = TokenColor(hexRGB: 0x999999) + static let _500 = TokenColor(hexRGB: 0x808080) + static let _600 = TokenColor(hexRGB: 0x666666) + static let _700 = TokenColor(hexRGB: 0x4D4D4D) + static let _800 = TokenColor(hexRGB: 0x333333) + static let _900 = TokenColor(hexRGB: 0x1A1A1A) + static let _1000 = TokenColor(hexRGB: 0x000000) + } + + enum System { + static let blue = TokenColor(hexRGB: 0x5297FF) + static let red = TokenColor(hexRGB: 0xF96767) + static let orange = TokenColor(hexRGB: 0xFB923C) + } + + enum Social { + static let kakaoYellow = TokenColor(hexRGB: 0xFEE500) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveNumber.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveNumber.swift new file mode 100644 index 0000000..498f822 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Primitive/PrimitiveNumber.swift @@ -0,0 +1,39 @@ +import CoreGraphics + +enum PrimitiveNumber { + enum Radius { + static let none: CGFloat = 0 + static let xsm: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xxlg: CGFloat = 24 + static let full: CGFloat = 9999 + } + + enum Spacing { + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + } + + enum Border { + static let thin: CGFloat = 1 + static let thick: CGFloat = 2 + } + + enum ControlHeight { + static let sm: CGFloat = 32 + static let md: CGFloat = 40 + static let lg: CGFloat = 48 + } + + enum Dim { + static let light: CGFloat = 0.2 + static let `default`: CGFloat = 0.4 + static let heavy: CGFloat = 0.6 + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift new file mode 100644 index 0000000..1ca3163 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift @@ -0,0 +1,141 @@ +import Foundation + +/// Clear backgrounds for outlined/text default states are represented as +/// `TokenColor(hexRGB: 0x000000, alpha: 0)` so they stay testable as TokenColor. +enum SemanticColor { + enum Button { + enum Primary { + static let background = Background() + static let content = Content() + + struct Background { + let `default` = PrimitiveColor.Primary._300 + let pressed = PrimitiveColor.Primary._600 + let disabled = PrimitiveColor.Primary._50 + } + + struct Content { + let `default` = PrimitiveColor.Neutral._1000 + let pressed = PrimitiveColor.Neutral._1000 + let disabled = PrimitiveColor.Neutral._500 + } + } + + enum Secondary { + static let background = Background() + static let content = Content() + + struct Background { + let `default` = PrimitiveColor.Secondary._500 + let pressed = PrimitiveColor.Secondary._800 + let disabled = PrimitiveColor.Secondary._200 + } + + struct Content { + let `default` = PrimitiveColor.Neutral._1000 + let pressed = PrimitiveColor.Neutral._1000 + let disabled = PrimitiveColor.Neutral._500 + } + } + + enum Outlined { + static let background = Background() + static let content = Content() + static let border = Border() + + struct Background { + let `default` = TokenColor(hexRGB: 0x000000, alpha: 0) + let pressed = PrimitiveColor.Primary._900 + let disabled = PrimitiveColor.Primary._800 + } + + struct Content { + let `default` = PrimitiveColor.Primary._300 + let pressed = PrimitiveColor.Primary._300 + let disabled = PrimitiveColor.Primary._700 + } + + struct Border { + let `default` = PrimitiveColor.Primary._300 + let pressed = PrimitiveColor.Primary._300 + let disabled = PrimitiveColor.Primary._700 + } + } + + enum Text { + static let background = Background() + static let content = Content() + + struct Background { + let `default` = TokenColor(hexRGB: 0x000000, alpha: 0) + let pressed = PrimitiveColor.Primary._900 + let disabled = PrimitiveColor.Primary._800 + } + + struct Content { + let `default` = PrimitiveColor.Primary._300 + let pressed = PrimitiveColor.Primary._300 + let disabled = PrimitiveColor.Primary._700 + } + } + } + + enum Text { + enum Neutral { + static let black = PrimitiveColor.Neutral._1000 + static let darker = PrimitiveColor.Neutral._800 + static let dark = PrimitiveColor.Neutral._700 + static let basic = PrimitiveColor.Neutral._500 + static let light = PrimitiveColor.Neutral._300 + static let lighter = PrimitiveColor.Neutral._100 + static let white = PrimitiveColor.Neutral._0 + } + + enum Primary { + static let basic = PrimitiveColor.Primary._300 + static let dark = PrimitiveColor.Primary._500 + static let darker = PrimitiveColor.Primary._700 + } + + enum Secondary { + static let basic = PrimitiveColor.Secondary._500 + static let darker = PrimitiveColor.Secondary._800 + } + } + + enum Background { + static let black = PrimitiveColor.Neutral._1000 + static let grayDarker = PrimitiveColor.Neutral._900 + static let grayDark = PrimitiveColor.Neutral._800 + } + + enum Border { + enum Primary { + static let basic = PrimitiveColor.Primary._300 + static let dark = PrimitiveColor.Primary._500 + static let darker = PrimitiveColor.Primary._700 + } + + enum Neutral { + static let lighter = PrimitiveColor.Neutral._100 + static let light = PrimitiveColor.Neutral._300 + static let basic = PrimitiveColor.Neutral._500 + static let dark = PrimitiveColor.Neutral._700 + static let darker = PrimitiveColor.Neutral._800 + } + } + + enum Social { + static let kakao = PrimitiveColor.Social.kakaoYellow + static let kakaoContent = PrimitiveColor.Neutral._1000 + static let appleBackground = PrimitiveColor.Neutral._0 + static let appleBorder = PrimitiveColor.Neutral._1000 + static let appleContent = PrimitiveColor.Neutral._1000 + } + + enum Dim { + static let light = TokenColor(hexRGB: 0x000000, alpha: Double(PrimitiveNumber.Dim.light)) + static let `default` = TokenColor(hexRGB: 0x000000, alpha: Double(PrimitiveNumber.Dim.default)) + static let heavy = TokenColor(hexRGB: 0x000000, alpha: Double(PrimitiveNumber.Dim.heavy)) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticNumber.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticNumber.swift new file mode 100644 index 0000000..0a8b413 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticNumber.swift @@ -0,0 +1,39 @@ +import CoreGraphics + +enum SemanticNumber { + enum Radius { + static let none = PrimitiveNumber.Radius.none + static let xsm = PrimitiveNumber.Radius.xsm + static let sm = PrimitiveNumber.Radius.sm + static let md = PrimitiveNumber.Radius.md + static let lg = PrimitiveNumber.Radius.lg + static let xxlg = PrimitiveNumber.Radius.xxlg + static let full = PrimitiveNumber.Radius.full + } + + enum Spacing { + static let xs = PrimitiveNumber.Spacing.xs + static let sm = PrimitiveNumber.Spacing.sm + static let md = PrimitiveNumber.Spacing.md + static let lg = PrimitiveNumber.Spacing.lg + static let xl = PrimitiveNumber.Spacing.xl + static let xxl = PrimitiveNumber.Spacing.xxl + } + + enum Border { + static let thin = PrimitiveNumber.Border.thin + static let thick = PrimitiveNumber.Border.thick + } + + enum ControlHeight { + static let sm = PrimitiveNumber.ControlHeight.sm + static let md = PrimitiveNumber.ControlHeight.md + static let lg = PrimitiveNumber.ControlHeight.lg + } + + enum Dim { + static let light = PrimitiveNumber.Dim.light + static let `default` = PrimitiveNumber.Dim.default + static let heavy = PrimitiveNumber.Dim.heavy + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Typography/TextStyle.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Typography/TextStyle.swift new file mode 100644 index 0000000..c23380f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Typography/TextStyle.swift @@ -0,0 +1,47 @@ +import CoreGraphics +import SwiftUI + +public struct TextStyle: Sendable, Equatable { + public let fontName: String + public let size: CGFloat + public let lineHeight: CGFloat + public let letterSpacingEm: CGFloat + + public init( + fontName: String, + size: CGFloat, + lineHeight: CGFloat, + letterSpacingEm: CGFloat + ) { + self.fontName = fontName + self.size = size + self.lineHeight = lineHeight + self.letterSpacingEm = letterSpacingEm + } + + public var font: Font { + Font.custom(fontName, size: size) + } + + public var letterSpacing: CGFloat { + size * letterSpacingEm + } + + public var additionalLineSpacing: CGFloat { + max(0, lineHeight - size) + } +} + +public extension TextStyle { + static let ds = DesignSystemTextStyles() +} + +public struct DesignSystemTextStyles: Sendable { + public let heading = HeadingTextStyles() + public let title = TitleTextStyles() + public let body = BodyTextStyles() + public let label = LabelTextStyles() + public let button = ButtonTextStyles() + + public init() {} +} diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Typography/Typography.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Typography/Typography.swift new file mode 100644 index 0000000..69504d7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Typography/Typography.swift @@ -0,0 +1,177 @@ +import CoreGraphics + +enum DesignSystemFontName { + static let regular = "Pretendard-Regular" + static let medium = "Pretendard-Medium" + static let semibold = "Pretendard-SemiBold" + static let bold = "Pretendard-Bold" +} + +public struct HeadingTextStyles: Sendable { + public let large32Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 32, + lineHeight: 40, + letterSpacingEm: -0.01 + ) + public let large32Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 32, + lineHeight: 40, + letterSpacingEm: -0.01 + ) + public let medium28Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 28, + lineHeight: 36, + letterSpacingEm: -0.01 + ) + public let medium28Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 28, + lineHeight: 36, + letterSpacingEm: -0.01 + ) + + public init() {} +} + +public struct TitleTextStyles: Sendable { + public let large24Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 24, + lineHeight: 32, + letterSpacingEm: -0.01 + ) + public let large24Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 24, + lineHeight: 32, + letterSpacingEm: -0.01 + ) + public let medium22Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 22, + lineHeight: 30, + letterSpacingEm: -0.01 + ) + public let medium22Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 22, + lineHeight: 30, + letterSpacingEm: -0.01 + ) + public let small20Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 20, + lineHeight: 28, + letterSpacingEm: -0.01 + ) + public let small20Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 20, + lineHeight: 28, + letterSpacingEm: -0.01 + ) + public let xsmall18Bold = TextStyle( + fontName: DesignSystemFontName.bold, + size: 18, + lineHeight: 26, + letterSpacingEm: -0.01 + ) + public let xsmall18Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 18, + lineHeight: 26, + letterSpacingEm: -0.01 + ) + + public init() {} +} + +public struct BodyTextStyles: Sendable { + public let large18Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 18, + lineHeight: 30, + letterSpacingEm: 0 + ) + public let large18Regular = TextStyle( + fontName: DesignSystemFontName.regular, + size: 18, + lineHeight: 30, + letterSpacingEm: 0 + ) + public let medium16Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 16, + lineHeight: 28, + letterSpacingEm: 0 + ) + public let medium16Regular = TextStyle( + fontName: DesignSystemFontName.regular, + size: 16, + lineHeight: 28, + letterSpacingEm: 0 + ) + public let small14Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 14, + lineHeight: 24, + letterSpacingEm: 0 + ) + public let small14Regular = TextStyle( + fontName: DesignSystemFontName.regular, + size: 14, + lineHeight: 24, + letterSpacingEm: 0 + ) + + public init() {} +} + +public struct LabelTextStyles: Sendable { + public let large16Medium = TextStyle( + fontName: DesignSystemFontName.medium, + size: 16, + lineHeight: 20, + letterSpacingEm: 0.02 + ) + public let medium14Medium = TextStyle( + fontName: DesignSystemFontName.medium, + size: 14, + lineHeight: 20, + letterSpacingEm: 0.02 + ) + public let small12Medium = TextStyle( + fontName: DesignSystemFontName.medium, + size: 12, + lineHeight: 16, + letterSpacingEm: 0.02 + ) + + public init() {} +} + +public struct ButtonTextStyles: Sendable { + public let sm14Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 14, + lineHeight: 14, + letterSpacingEm: 0 + ) + public let md16Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 16, + lineHeight: 16, + letterSpacingEm: 0 + ) + public let lg18Semibold = TextStyle( + fontName: DesignSystemFontName.semibold, + size: 18, + lineHeight: 18, + letterSpacingEm: 0 + ) + + public init() {} +} diff --git a/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift b/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift new file mode 100644 index 0000000..ebb8611 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import SharedDesignSystem + +final class DesignButtonMetricsTests: XCTestCase { + func test_size_metrics() { + XCTAssertEqual(DesignButtonSize.sm.height, 32) + XCTAssertEqual(DesignButtonSize.sm.horizontalPadding, 12) + XCTAssertEqual(DesignButtonSize.sm.cornerRadius, 4) + XCTAssertEqual(DesignButtonSize.sm.iconSize, 14) + + XCTAssertEqual(DesignButtonSize.md.height, 40) + XCTAssertEqual(DesignButtonSize.md.horizontalPadding, 16) + XCTAssertEqual(DesignButtonSize.md.cornerRadius, 4) + XCTAssertEqual(DesignButtonSize.md.iconSize, 16) + + XCTAssertEqual(DesignButtonSize.lg.height, 48) + XCTAssertEqual(DesignButtonSize.lg.horizontalPadding, 20) + XCTAssertEqual(DesignButtonSize.lg.cornerRadius, 8) + XCTAssertEqual(DesignButtonSize.lg.iconSize, 18) + } + + func test_primary_disabled_uses_semantic_tokens() { + let style = DesignButtonStyleResolver.resolve(variant: .primary, state: .disabled) + XCTAssertEqual(style.background.hexRGB, PrimitiveColor.Primary._50.hexRGB) + XCTAssertEqual(style.content.hexRGB, PrimitiveColor.Neutral._500.hexRGB) + } + + func test_outlined_pressed_background() { + let style = DesignButtonStyleResolver.resolve(variant: .outlined, state: .pressed) + XCTAssertEqual(style.background.hexRGB, PrimitiveColor.Primary._900.hexRGB) + XCTAssertEqual(style.border?.hexRGB, PrimitiveColor.Primary._300.hexRGB) + } + + func test_disabled_state_resolution() { + // Covers parent .disabled / DesignButton(isEnabled: false) → disabled tokens. + XCTAssertEqual( + DesignButtonInteractionStateResolver.resolve(isEnabled: false, isPressed: false), + .disabled + ) + XCTAssertEqual( + DesignButtonInteractionStateResolver.resolve(isEnabled: false, isPressed: true), + .disabled + ) + XCTAssertEqual( + DesignButtonInteractionStateResolver.resolve(isEnabled: true, isPressed: true), + .pressed + ) + XCTAssertEqual( + DesignButtonInteractionStateResolver.resolve(isEnabled: true, isPressed: false), + .default + ) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift b/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift new file mode 100644 index 0000000..31091ec --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import SharedDesignSystem + +final class SocialLoginProviderTests: XCTestCase { + func test_kakao_copy_and_colors() { + XCTAssertEqual(SocialLoginProvider.kakao.title, "카카오로 시작하기") + XCTAssertEqual(SocialLoginProvider.kakao.background.hexRGB, PrimitiveColor.Social.kakaoYellow.hexRGB) + XCTAssertEqual(SocialLoginProvider.kakao.content.hexRGB, PrimitiveColor.Neutral._1000.hexRGB) + XCTAssertNil(SocialLoginProvider.kakao.border) + XCTAssertEqual(SocialLoginProvider.kakao.contentGap, 12) + } + + func test_apple_copy_and_colors() { + XCTAssertEqual(SocialLoginProvider.apple.title, "Apple로 로그인") + XCTAssertEqual(SocialLoginProvider.apple.background.hexRGB, PrimitiveColor.Neutral._0.hexRGB) + XCTAssertEqual(SocialLoginProvider.apple.content.hexRGB, PrimitiveColor.Neutral._1000.hexRGB) + XCTAssertEqual(SocialLoginProvider.apple.border?.hexRGB, PrimitiveColor.Neutral._1000.hexRGB) + XCTAssertEqual(SocialLoginProvider.apple.contentGap, 9) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift b/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift new file mode 100644 index 0000000..9ef2ef7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import SharedDesignSystem + +final class FontRegistrationTests: XCTestCase { + func test_font_resources_exist() { + let names = [ + "Pretendard-Regular", + "Pretendard-Medium", + "Pretendard-SemiBold", + "Pretendard-Bold", + ] + let bundle = SharedDesignSystemResources.bundle + + for name in names { + XCTAssertNotNil( + bundle.url(forResource: name, withExtension: "otf") + ?? bundle.url(forResource: name, withExtension: "ttf"), + "Missing font resource: \(name)" + ) + } + } + + func test_registerIfNeeded_is_idempotent() { + let first = DesignSystemFontRegistration.registerIfNeeded() + let second = DesignSystemFontRegistration.registerIfNeeded() + XCTAssertTrue(first) + XCTAssertTrue(second) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift b/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift new file mode 100644 index 0000000..a5529a0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedDesignSystem +import SwiftUI +import XCTest + +final class SharedDesignSystemSmokeTests: XCTestCase { + func test_모듈_임포트() { + XCTAssertNotNil(Color.red) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift new file mode 100644 index 0000000..cd1e8a9 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import SharedDesignSystem + +final class PrimitiveColorTests: XCTestCase { + func test_primary300_hex() { + XCTAssertEqual(PrimitiveColor.Primary._300.hexRGB, 0xE9DE6F) + } + + func test_neutral1000_hex() { + XCTAssertEqual(PrimitiveColor.Neutral._1000.hexRGB, 0x000000) + } + + func test_brand_kakao_hex() { + XCTAssertEqual(PrimitiveColor.Social.kakaoYellow.hexRGB, 0xFEE500) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift new file mode 100644 index 0000000..7861b48 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift @@ -0,0 +1,90 @@ +import SwiftUI +import XCTest +@testable import SharedDesignSystem + +final class SemanticColorTests: XCTestCase { + func test_button_primary_default_maps_primary300() { + XCTAssertEqual( + SemanticColor.Button.Primary.background.default.hexRGB, + PrimitiveColor.Primary._300.hexRGB + ) + } + + func test_button_primary_disabled_content_maps_neutral500() { + XCTAssertEqual( + SemanticColor.Button.Primary.content.disabled.hexRGB, + PrimitiveColor.Neutral._500.hexRGB + ) + } + + func test_text_neutral_white_maps_neutral0() { + XCTAssertEqual( + SemanticColor.Text.Neutral.white.hexRGB, + PrimitiveColor.Neutral._0.hexRGB + ) + } + + func test_brand_kakao_maps_brand_token() { + XCTAssertEqual( + SemanticColor.Social.kakao.hexRGB, + PrimitiveColor.Social.kakaoYellow.hexRGB + ) + XCTAssertEqual( + SemanticColor.Social.kakaoContent.hexRGB, + PrimitiveColor.Neutral._1000.hexRGB + ) + XCTAssertEqual( + SemanticColor.Social.appleContent.hexRGB, + PrimitiveColor.Neutral._1000.hexRGB + ) + } + + func test_button_secondary_default_maps_secondary500() { + XCTAssertEqual( + SemanticColor.Button.Secondary.background.default.hexRGB, + PrimitiveColor.Secondary._500.hexRGB + ) + } + + func test_button_outlined_default_is_clear() { + let background = SemanticColor.Button.Outlined.background.default + XCTAssertEqual(background.hexRGB, 0x000000) + XCTAssertEqual(background.alpha, 0, accuracy: 0.0001) + } + + func test_button_text_pressed_background_maps_primary900() { + XCTAssertEqual( + SemanticColor.Button.Text.background.pressed.hexRGB, + PrimitiveColor.Primary._900.hexRGB + ) + } + + func test_background_grayDarker_maps_neutral900() { + XCTAssertEqual( + SemanticColor.Background.grayDarker.hexRGB, + PrimitiveColor.Neutral._900.hexRGB + ) + } + + func test_border_primary_basic_maps_primary300() { + XCTAssertEqual( + SemanticColor.Border.Primary.basic.hexRGB, + PrimitiveColor.Primary._300.hexRGB + ) + } + + func test_dim_default_is_black_40_percent() { + let dim = SemanticColor.Dim.default + XCTAssertEqual(dim.hexRGB, 0x000000) + XCTAssertEqual(dim.alpha, 0.4, accuracy: 0.0001) + } + + func test_public_color_ds_paths_resolve() { + XCTAssertNotNil(Color.ds.button.primary.background.default) + XCTAssertNotNil(Color.ds.text.neutral.basic) + XCTAssertNotNil(Color.ds.background.grayDarker) + XCTAssertNotNil(Color.ds.border.primary.basic) + XCTAssertNotNil(Color.ds.dim.default) + XCTAssertNotNil(Color.ds.social.kakao) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift new file mode 100644 index 0000000..f9bbdd7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import SharedDesignSystem + +final class SemanticNumberTests: XCTestCase { + func test_spacing_and_radius() { + XCTAssertEqual(SemanticNumber.Spacing.md, 12) + XCTAssertEqual(SemanticNumber.Radius.xsm, 4) + XCTAssertEqual(SemanticNumber.ControlHeight.lg, 48) + XCTAssertEqual(SemanticNumber.Dim.default, 0.4, accuracy: 0.0001) + } + + func test_full_semantic_number_table() { + XCTAssertEqual(SemanticNumber.Radius.none, 0) + XCTAssertEqual(SemanticNumber.Radius.sm, 8) + XCTAssertEqual(SemanticNumber.Radius.md, 12) + XCTAssertEqual(SemanticNumber.Radius.lg, 16) + XCTAssertEqual(SemanticNumber.Radius.xxlg, 24) + XCTAssertEqual(SemanticNumber.Radius.full, 9999) + + XCTAssertEqual(SemanticNumber.Spacing.xs, 4) + XCTAssertEqual(SemanticNumber.Spacing.sm, 8) + XCTAssertEqual(SemanticNumber.Spacing.lg, 16) + XCTAssertEqual(SemanticNumber.Spacing.xl, 24) + XCTAssertEqual(SemanticNumber.Spacing.xxl, 32) + + XCTAssertEqual(SemanticNumber.Border.thin, 1) + XCTAssertEqual(SemanticNumber.Border.thick, 2) + + XCTAssertEqual(SemanticNumber.ControlHeight.sm, 32) + XCTAssertEqual(SemanticNumber.ControlHeight.md, 40) + + XCTAssertEqual(SemanticNumber.Dim.light, 0.2, accuracy: 0.0001) + XCTAssertEqual(SemanticNumber.Dim.heavy, 0.6, accuracy: 0.0001) + } + + func test_public_cgfloat_ds_paths_resolve() { + XCTAssertEqual(CGFloat.ds.spacing.md, 12) + XCTAssertEqual(CGFloat.ds.radius.xsm, 4) + XCTAssertEqual(CGFloat.ds.controlHeight.lg, 48) + XCTAssertEqual(CGFloat.ds.dim.default, 0.4, accuracy: 0.0001) + XCTAssertEqual(CGFloat.ds.border.thin, 1) + } +} diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift new file mode 100644 index 0000000..3d65a2a --- /dev/null +++ b/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift @@ -0,0 +1,246 @@ +import SwiftUI +import XCTest +@testable import SharedDesignSystem + +final class TypographyTests: XCTestCase { + func test_body_small14_regular_metrics() { + let style = TextStyle.ds.body.small14Regular + XCTAssertEqual(style.size, 14) + XCTAssertEqual(style.lineHeight, 24) + XCTAssertEqual(style.letterSpacingEm, 0) + XCTAssertEqual(style.fontName, "Pretendard-Regular") + } + + func test_heading_large32_bold_metrics() { + let style = TextStyle.ds.heading.large32Bold + XCTAssertEqual(style.size, 32) + XCTAssertEqual(style.lineHeight, 40) + XCTAssertEqual(style.letterSpacingEm, -0.01, accuracy: 0.0001) + XCTAssertEqual(style.fontName, "Pretendard-Bold") + } + + func test_button_sm14_is_semibold() { + let style = TextStyle.ds.button.sm14Semibold + XCTAssertEqual(style.size, 14) + XCTAssertEqual(style.lineHeight, 14) + XCTAssertEqual(style.fontName, "Pretendard-SemiBold") + } + + func test_label_letter_spacing() { + XCTAssertEqual(TextStyle.ds.label.small12Medium.letterSpacingEm, 0.02, accuracy: 0.0001) + } + + func test_heading_and_title_full_table() { + assertStyle( + TextStyle.ds.heading.large32Bold, + fontName: "Pretendard-Bold", + size: 32, + lineHeight: 40, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.heading.large32Semibold, + fontName: "Pretendard-SemiBold", + size: 32, + lineHeight: 40, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.heading.medium28Bold, + fontName: "Pretendard-Bold", + size: 28, + lineHeight: 36, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.heading.medium28Semibold, + fontName: "Pretendard-SemiBold", + size: 28, + lineHeight: 36, + letterSpacingEm: -0.01 + ) + + assertStyle( + TextStyle.ds.title.large24Bold, + fontName: "Pretendard-Bold", + size: 24, + lineHeight: 32, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.large24Semibold, + fontName: "Pretendard-SemiBold", + size: 24, + lineHeight: 32, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.medium22Bold, + fontName: "Pretendard-Bold", + size: 22, + lineHeight: 30, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.medium22Semibold, + fontName: "Pretendard-SemiBold", + size: 22, + lineHeight: 30, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.small20Bold, + fontName: "Pretendard-Bold", + size: 20, + lineHeight: 28, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.small20Semibold, + fontName: "Pretendard-SemiBold", + size: 20, + lineHeight: 28, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.xsmall18Bold, + fontName: "Pretendard-Bold", + size: 18, + lineHeight: 26, + letterSpacingEm: -0.01 + ) + assertStyle( + TextStyle.ds.title.xsmall18Semibold, + fontName: "Pretendard-SemiBold", + size: 18, + lineHeight: 26, + letterSpacingEm: -0.01 + ) + } + + func test_body_label_button_full_table() { + assertStyle( + TextStyle.ds.body.large18Semibold, + fontName: "Pretendard-SemiBold", + size: 18, + lineHeight: 30, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.body.large18Regular, + fontName: "Pretendard-Regular", + size: 18, + lineHeight: 30, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.body.medium16Semibold, + fontName: "Pretendard-SemiBold", + size: 16, + lineHeight: 28, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.body.medium16Regular, + fontName: "Pretendard-Regular", + size: 16, + lineHeight: 28, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.body.small14Semibold, + fontName: "Pretendard-SemiBold", + size: 14, + lineHeight: 24, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.body.small14Regular, + fontName: "Pretendard-Regular", + size: 14, + lineHeight: 24, + letterSpacingEm: 0 + ) + + assertStyle( + TextStyle.ds.label.large16Medium, + fontName: "Pretendard-Medium", + size: 16, + lineHeight: 20, + letterSpacingEm: 0.02 + ) + assertStyle( + TextStyle.ds.label.medium14Medium, + fontName: "Pretendard-Medium", + size: 14, + lineHeight: 20, + letterSpacingEm: 0.02 + ) + assertStyle( + TextStyle.ds.label.small12Medium, + fontName: "Pretendard-Medium", + size: 12, + lineHeight: 16, + letterSpacingEm: 0.02 + ) + + assertStyle( + TextStyle.ds.button.sm14Semibold, + fontName: "Pretendard-SemiBold", + size: 14, + lineHeight: 14, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.button.md16Semibold, + fontName: "Pretendard-SemiBold", + size: 16, + lineHeight: 16, + letterSpacingEm: 0 + ) + assertStyle( + TextStyle.ds.button.lg18Semibold, + fontName: "Pretendard-SemiBold", + size: 18, + lineHeight: 18, + letterSpacingEm: 0 + ) + } + + func test_derived_spacing_helpers() { + let heading = TextStyle.ds.heading.large32Bold + XCTAssertEqual(heading.letterSpacing, 32 * -0.01, accuracy: 0.0001) + XCTAssertEqual(heading.additionalLineSpacing, 8) + + let button = TextStyle.ds.button.sm14Semibold + XCTAssertEqual(button.letterSpacing, 0) + XCTAssertEqual(button.additionalLineSpacing, 0) + + let label = TextStyle.ds.label.small12Medium + XCTAssertEqual(label.letterSpacing, 12 * 0.02, accuracy: 0.0001) + XCTAssertEqual(label.additionalLineSpacing, 4) + } + + func test_public_font_ds_paths_resolve() { + XCTAssertNotNil(Font.ds.heading.large32Bold) + XCTAssertNotNil(Font.ds.title.xsmall18Semibold) + XCTAssertNotNil(Font.ds.body.small14Regular) + XCTAssertNotNil(Font.ds.label.medium14Medium) + XCTAssertNotNil(Font.ds.button.lg18Semibold) + } + + private func assertStyle( + _ style: TextStyle, + fontName: String, + size: CGFloat, + lineHeight: CGFloat, + letterSpacingEm: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(style.fontName, fontName, file: file, line: line) + XCTAssertEqual(style.size, size, file: file, line: line) + XCTAssertEqual(style.lineHeight, lineHeight, file: file, line: line) + XCTAssertEqual(style.letterSpacingEm, letterSpacingEm, accuracy: 0.0001, file: file, line: line) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 079305b..665692d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,6 +52,15 @@ App → 조립 - 포함: `AppInfo`, pure Foundation 헬퍼 - 제외: UI, Network, Storage, Logger, Domain, TCA helper +### SharedDesignSystem + +- 포함: semantic UI tokens, typography, `DesignText` / `DesignButton` / `SocialLoginButton` +- Feature public surface: `Color.ds`, `Font.ds`, `CGFloat.ds`, `TextStyle.ds` + 공통 컴포넌트 +- Primitive / Semantic 토큰 타입은 모듈 내부 전용. Feature는 `*.ds`와 컴포넌트만 사용 +- 컴포넌트는 Semantic 토큰을 통해 값을 해석한다 +- Pretendard 폰트 등록은 App bootstrap에서 1회 수행 +- `DesignText` lineHeight는 SwiftUI `.lineSpacing` 기반 근사 적용(고정 line box 미보장) + ### SharedLogger - 전역 `Logger.shared` OSLog facade diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 340b97b..9077e4e 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -14,6 +14,7 @@ - Scene 통신은 delegate bubble-up - SharedUtils는 pure Foundation / App metadata 만. UI·I/O·Domain 금지 - CoreNetwork uses SharedLogger (`.network`) and never contains service-specific domain flows +- SharedDesignSystem은 Feature에서 `Color.ds` / `Font.ds` / `CGFloat.ds` / `TextStyle.ds` 와 공통 컴포넌트만 사용. Primitive 직접 참조 금지 ## Schemes