Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 25 additions & 20 deletions Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ButtonTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions Alfie/AlfieKit/Sources/SharedUI/Theme/Buttons/ThemedButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High severity — button heights snapped below the 44pt accessibility minimum.

smallHeight/mediumHeight/bigHeight were changed from 36/44/52 to 32/40/48 to fit the spacing-token scale. mediumHeight (the app's default button size) now renders at 40pt, under Apple's Human Interface Guidelines minimum recommended tap target of 44×44pt.

This also reverses a decision made earlier in this same PR's plan/grill docs, which explicitly left heights as documented literal gaps because no height token exists — this commit silently overrides that with no updated AC, no test coverage, and no visual verification (snapshot tests are disabled repo-wide).

Could you check with Thais (design) on whether shrinking every button below the HIG minimum tap target is intentional before this merges? If it is, .medium should probably map to a 44pt-equivalent spacing token rather than 40, to stay HIG-compliant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — good catch on the tap-target. Team decision: keep the token-snapped heights but document the risk with a FIXME (6accca7) noting .medium at 40pt is below the 44pt HIG minimum, pending a proper control-height token. Khoi is confirming the smaller heights with Thais (design); if not signed off we'll add a 44pt-equivalent height token or revert. Proceeding to merge on that basis.

static let bigHeight: CGFloat = Primitives.Spacing.spacing48
Comment on lines +143 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unlike the Badge (#93) / Chip (#94) PRs, this one isn't a visual no-op: mapping the button heights onto Primitives.Spacing.* shrinks every button by 4pt (36→32 / 44→40 / 52→48). Spacing tokens aren't sizing tokens, and this also contradicts the (good) decision right here to keep borderSelected/close-icon as documented literals.

Suggestion: keep the heights as literal constants annotated "no height token yet" rather than change the rendered size to fit a token — or get design sign-off on the smaller heights.

(Separately: Secondary/Tertiary background goes opaque-white → transparentTransparent — fine on today's white surfaces, will show through on any non-white surface. Fine to leave, just noting.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed spacing tokens aren't sizing tokens — there is genuinely no height/size token in the design system (checked all generated files + source token JSONs). Documented with a FIXME (6accca7) and Khoi will confirm the heights with design; we'll swap to a real control-height token once it exists. Noted on the transparent Secondary/Tertiary background too.

}

// MARK: - ThemedButtonStyle
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions Alfie/AlfieKit/Tests/SharedUITests/ButtonThemeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}