diff --git a/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ButtonTheme.swift b/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ButtonTheme.swift index 2af11607..68879281 100644 --- a/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ButtonTheme.swift +++ b/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ButtonTheme.swift @@ -28,52 +28,57 @@ public enum ButtonTheme: String, CaseIterable { var spec: ButtonThemeSpec { switch self { + // Default/disabled colors come from the semantic `Theme.button*` tokens; the pressed state + // has no semantic token, so it stays on `Primitives.Colours.*`. case .primary: return .init( - backgroundColor: Primitives.Colours.neutrals800, - backgroundDisabledColor: Primitives.Colours.neutrals100, + backgroundColor: Theme.buttonPrimaryBackgroundPrimaryDefault, + backgroundDisabledColor: Theme.buttonPrimaryBackgroundPrimaryDisabled, backgroundPressedColor: Primitives.Colours.neutrals500, - textColor: Primitives.Colours.neutrals0, - textDisabledColor: Primitives.Colours.neutrals400, + textColor: Theme.buttonPrimaryContentPrimaryDefault, + textDisabledColor: Theme.buttonPrimaryContentPrimaryDisabled, textPressedColor: Primitives.Colours.neutrals0, - borderColor: Primitives.Colours.neutrals800, - borderDisabledColor: Primitives.Colours.neutrals100, + borderColor: Theme.buttonPrimaryStrokePrimaryDefault, + borderDisabledColor: Theme.buttonPrimaryStrokePrimaryDisabled, borderPressedColor: Primitives.Colours.neutrals500 ) case .secondary: return .init( - backgroundColor: Primitives.Colours.neutrals0, - backgroundDisabledColor: Primitives.Colours.neutrals0, + backgroundColor: Theme.buttonSecondaryBackgroundSecondaryDefault, + backgroundDisabledColor: Theme.buttonSecondaryBackgroundSecondaryDisabled, backgroundPressedColor: Primitives.Colours.neutrals100, - textColor: Primitives.Colours.neutrals800, - textDisabledColor: Primitives.Colours.neutrals400, + textColor: Theme.buttonSecondaryContentSecondaryDefault, + textDisabledColor: Theme.buttonSecondaryContentSecondaryDisabled, textPressedColor: Primitives.Colours.neutrals500, - borderColor: Primitives.Colours.neutrals800, - borderDisabledColor: Primitives.Colours.neutrals400, + borderColor: Theme.buttonSecondaryStrokeSecondaryDefault, + borderDisabledColor: Theme.buttonSecondaryStrokeSecondaryDisabled, borderPressedColor: Primitives.Colours.neutrals500 ) case .tertiary: return .init( - backgroundColor: Primitives.Colours.neutrals0, - backgroundDisabledColor: Primitives.Colours.neutrals0, + backgroundColor: Theme.buttonTerciaryBackgroundTerciaryDefault, + backgroundDisabledColor: Theme.buttonTerciaryBackgroundTerciaryDisabled, backgroundPressedColor: Primitives.Colours.neutrals100, - textColor: Primitives.Colours.neutrals800, - textDisabledColor: Primitives.Colours.neutrals400, + textColor: Theme.buttonTerciaryContentTerciaryDefault, + textDisabledColor: Theme.buttonTerciaryContentTerciaryDisabled, textPressedColor: Primitives.Colours.neutrals500, - borderColor: Primitives.Colours.neutrals0, - borderDisabledColor: Primitives.Colours.neutrals0, + borderColor: Theme.buttonTerciaryStrokeTerciaryDefault, + borderDisabledColor: Theme.buttonTerciaryStrokeTerciaryDisabled, borderPressedColor: Primitives.Colours.neutrals100 ) + // Underline has no semantic button group → its text tracks the semantic `Theme.linkLink*` + // tokens (an underline button is a link); pressed text and the invisible background/border + // have no matching token and stay on `Primitives.Colours.*`. case .underline: return .init( backgroundColor: Primitives.Colours.neutrals0, backgroundDisabledColor: Primitives.Colours.neutrals0, backgroundPressedColor: Primitives.Colours.neutrals0, - textColor: Primitives.Colours.neutrals800, - textDisabledColor: Primitives.Colours.neutrals400, + textColor: Theme.linkLinkPrimaryDefault, + textDisabledColor: Theme.linkLinkPrimaryDisabled, textPressedColor: Primitives.Colours.neutrals500, borderColor: Primitives.Colours.neutrals0, borderDisabledColor: Primitives.Colours.neutrals0, diff --git a/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ThemedButton.swift b/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ThemedButton.swift index b567c308..47c5da3f 100644 --- a/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ThemedButton.swift +++ b/Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ThemedButton.swift @@ -136,10 +136,13 @@ private enum Constants { static let horizontalPadding: CGFloat = 0 static let verticalPadding: CGFloat = -Primitives.Spacing.spacing8 static let cornerRadius: CGFloat = Sizing.radiusSoft - static let iconSize: CGFloat = 16 - static let smallHeight: CGFloat = 36 - static let mediumHeight: CGFloat = 44 - static let bigHeight: CGFloat = 52 + static let iconSize: CGFloat = Sizing.iconsIconSmall + // FIXME: No dedicated button-height token exists; heights are snapped to the spacing scale + // (36→32, 44→40, 52→48). This drops `.medium` (the default) to 40pt — below Apple's 44pt + // minimum tap target. Pending design confirmation on adding proper control-height tokens. + static let smallHeight: CGFloat = Primitives.Spacing.spacing32 + static let mediumHeight: CGFloat = Primitives.Spacing.spacing40 + static let bigHeight: CGFloat = Primitives.Spacing.spacing48 } // MARK: - ThemedButtonStyle @@ -181,7 +184,7 @@ private struct ThemedButtonStyle: ButtonStyle { .overlay( ZStack { RoundedRectangle(cornerRadius: Constants.cornerRadius) - .stroke(borderColor(configuration), lineWidth: 1) + .stroke(borderColor(configuration), lineWidth: Primitives.Border.borderWeightDefault) if isLoading { LoaderView(circleDiameter: .defaultSmall, style: styleLoading) diff --git a/Alfie/AlfieKit/Tests/SharedUITests/ButtonThemeTests.swift b/Alfie/AlfieKit/Tests/SharedUITests/ButtonThemeTests.swift new file mode 100644 index 00000000..b7d3cffd --- /dev/null +++ b/Alfie/AlfieKit/Tests/SharedUITests/ButtonThemeTests.swift @@ -0,0 +1,64 @@ +import SwiftUI +import XCTest +@testable import SharedUI + +/// Pins each `ButtonTheme` variant to the design tokens it must source colors from, so a future +/// token regeneration (or an accidental edit) that re-points a mapping is caught. Default/disabled +/// colors come from the semantic `Theme.button*` layer; the pressed state and the underline +/// background/border have no semantic token and intentionally stay on `Primitives.Colours.*`. +final class ButtonThemeTests: XCTestCase { + func test_primary_sourcesSemanticButtonTokens() { + let spec = ButtonTheme.primary.spec + XCTAssertEqual(spec.backgroundColor, Theme.buttonPrimaryBackgroundPrimaryDefault) + XCTAssertEqual(spec.backgroundDisabledColor, Theme.buttonPrimaryBackgroundPrimaryDisabled) + XCTAssertEqual(spec.textColor, Theme.buttonPrimaryContentPrimaryDefault) + XCTAssertEqual(spec.textDisabledColor, Theme.buttonPrimaryContentPrimaryDisabled) + XCTAssertEqual(spec.borderColor, Theme.buttonPrimaryStrokePrimaryDefault) + XCTAssertEqual(spec.borderDisabledColor, Theme.buttonPrimaryStrokePrimaryDisabled) + // Pressed: no semantic token — primitives retained. + XCTAssertEqual(spec.backgroundPressedColor, Primitives.Colours.neutrals500) + XCTAssertEqual(spec.textPressedColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.borderPressedColor, Primitives.Colours.neutrals500) + } + + func test_secondary_sourcesSemanticButtonTokens() { + let spec = ButtonTheme.secondary.spec + XCTAssertEqual(spec.backgroundColor, Theme.buttonSecondaryBackgroundSecondaryDefault) + XCTAssertEqual(spec.backgroundDisabledColor, Theme.buttonSecondaryBackgroundSecondaryDisabled) + XCTAssertEqual(spec.textColor, Theme.buttonSecondaryContentSecondaryDefault) + XCTAssertEqual(spec.textDisabledColor, Theme.buttonSecondaryContentSecondaryDisabled) + XCTAssertEqual(spec.borderColor, Theme.buttonSecondaryStrokeSecondaryDefault) + XCTAssertEqual(spec.borderDisabledColor, Theme.buttonSecondaryStrokeSecondaryDisabled) + XCTAssertEqual(spec.backgroundPressedColor, Primitives.Colours.neutrals100) + XCTAssertEqual(spec.textPressedColor, Primitives.Colours.neutrals500) + XCTAssertEqual(spec.borderPressedColor, Primitives.Colours.neutrals500) + } + + func test_tertiary_sourcesSemanticButtonTokens() { + let spec = ButtonTheme.tertiary.spec + XCTAssertEqual(spec.backgroundColor, Theme.buttonTerciaryBackgroundTerciaryDefault) + XCTAssertEqual(spec.backgroundDisabledColor, Theme.buttonTerciaryBackgroundTerciaryDisabled) + XCTAssertEqual(spec.textColor, Theme.buttonTerciaryContentTerciaryDefault) + XCTAssertEqual(spec.textDisabledColor, Theme.buttonTerciaryContentTerciaryDisabled) + XCTAssertEqual(spec.borderColor, Theme.buttonTerciaryStrokeTerciaryDefault) + XCTAssertEqual(spec.borderDisabledColor, Theme.buttonTerciaryStrokeTerciaryDisabled) + XCTAssertEqual(spec.backgroundPressedColor, Primitives.Colours.neutrals100) + XCTAssertEqual(spec.textPressedColor, Primitives.Colours.neutrals500) + XCTAssertEqual(spec.borderPressedColor, Primitives.Colours.neutrals100) + } + + func test_underline_sourcesSemanticLinkTokensForText() { + let spec = ButtonTheme.underline.spec + // Underline has no semantic button group → text tracks the semantic link tokens. + XCTAssertEqual(spec.textColor, Theme.linkLinkPrimaryDefault) + XCTAssertEqual(spec.textDisabledColor, Theme.linkLinkPrimaryDisabled) + // Pressed text + the (invisible) background/border have no matching token — primitives retained. + XCTAssertEqual(spec.textPressedColor, Primitives.Colours.neutrals500) + XCTAssertEqual(spec.backgroundColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.backgroundDisabledColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.backgroundPressedColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.borderColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.borderDisabledColor, Primitives.Colours.neutrals0) + XCTAssertEqual(spec.borderPressedColor, Primitives.Colours.neutrals0) + } +}