├── .github ├── data │ ├── iOS.png │ ├── tvOS.png │ ├── banner.png │ ├── iPadOS.png │ ├── visionOS.png │ ├── dark-mode.png │ └── light-mode.png ├── workflows │ ├── pr-title-checker.yml │ └── static-site.yml └── pr-title-checker-config.json ├── .gitignore ├── .spi.yml ├── Sources └── ContrastKit │ ├── Public │ ├── AgnosticColor.swift │ ├── Color+Convenience.swift │ ├── ContrastLevel.swift │ ├── Color+Ext.swift │ └── ColorLevel.swift │ ├── Internal │ ├── UIColor+Ext.swift │ └── Color+Helpers.swift │ └── UITesting │ ├── Example+SwiftUI.swift │ └── PreviewTesting.swift ├── Package.swift ├── LICENCE ├── Tests └── ContrastKitTests │ └── ContrastKitTests.swift └── README.md /.github/data/iOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/iOS.png -------------------------------------------------------------------------------- /.github/data/tvOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/tvOS.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | /.swiftpm 7 | .netrc 8 | -------------------------------------------------------------------------------- /.github/data/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/banner.png -------------------------------------------------------------------------------- /.github/data/iPadOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/iPadOS.png -------------------------------------------------------------------------------- /.github/data/visionOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/visionOS.png -------------------------------------------------------------------------------- /.github/data/dark-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/dark-mode.png -------------------------------------------------------------------------------- /.github/data/light-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markbattistella/ContrastKit/HEAD/.github/data/light-mode.png -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ContrastKit] 5 | platform: ios 6 | -------------------------------------------------------------------------------- /.github/workflows/pr-title-checker.yml: -------------------------------------------------------------------------------- 1 | name: PR title checker 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | jobs: 10 | 11 | pr-validator: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: thehanimo/pr-title-checker@v1.4.1 16 | with: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/pr-title-checker-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": { 3 | "name": "Bad PR title format", 4 | "color": "EEEEEE" 5 | }, 6 | "CHECKS": { 7 | "prefixes": [], 8 | "regexp": "^(19|20)\\d{2}(\/|-)(0[1-9]|1[1,2])(\/|-)(0[1-9]|[12][0-9]|3[01])", 9 | "regexpFlags": "", 10 | "ignoreLabels": [ 11 | "ignore-title" 12 | ] 13 | }, 14 | "MESSAGES": { 15 | "success": "All OK", 16 | "failure": "Failing CI test", 17 | "notice": "" 18 | } 19 | } -------------------------------------------------------------------------------- /Sources/ContrastKit/Public/AgnosticColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | // Provides a platform-independent typealias for color representation. 8 | // This file ensures that the same code can be used seamlessly across 9 | // UIKit (iOS) and AppKit (macOS). 10 | 11 | #if canImport(UIKit) 12 | 13 | import UIKit.UIColor 14 | 15 | @available(iOS 14.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 16 | public typealias AgnosticColor = UIColor 17 | 18 | #else 19 | 20 | import AppKit.NSColor 21 | 22 | @available(macOS 11.0, *) 23 | public typealias AgnosticColor = NSColor 24 | 25 | #endif 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ContrastKit", 7 | platforms: [ 8 | .iOS(.v14), 9 | .macOS(.v11), 10 | .macCatalyst(.v14), 11 | .tvOS(.v14), 12 | .watchOS(.v7), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "ContrastKit", 18 | targets: ["ContrastKit"] 19 | ) 20 | ], 21 | targets: [ 22 | .target( 23 | name: "ContrastKit", 24 | dependencies: [], 25 | path: "Sources" 26 | ), 27 | .testTarget( 28 | name: "ContrastKitTests", 29 | dependencies: ["ContrastKit"], 30 | path: "Tests" 31 | ) 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /.github/workflows/static-site.yml: -------------------------------------------------------------------------------- 1 | name: Deploy website 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: "pages" 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | jobs: 19 | 20 | deploy: 21 | name: Deploy the website 22 | runs-on: ubuntu-latest 23 | 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Pages 33 | uses: actions/configure-pages@v4 34 | 35 | - name: Upload artifact 36 | uses: actions/upload-pages-artifact@v3 37 | with: 38 | path: './Documentation' 39 | 40 | - name: Deploy to GitHub Pages 41 | id: deployment 42 | uses: actions/deploy-pages@v4 43 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mark Battistella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/ContrastKitTests/ContrastKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import XCTest 8 | @testable import ContrastKit 9 | 10 | final class ContrastKitTests: XCTestCase { 11 | 12 | /// Tests the luminance calculation for the black color. 13 | func testBlackLuminanceCalculation() { 14 | let black = AgnosticColor.black 15 | XCTAssertEqual(black.luminance(), 0.0, "Luminance of black should be 0.0") 16 | } 17 | 18 | /// Tests the luminance calculation for the white color. 19 | func testWhiteLuminanceCalculation() { 20 | let white = AgnosticColor.white 21 | XCTAssertEqual(white.luminance(), 1.0, "Luminance of white should be 1.0") 22 | } 23 | 24 | /// Tests the contrast ratio calculation between black and white colors. 25 | func testContrastRatio() { 26 | let black = AgnosticColor.black 27 | let white = AgnosticColor.white 28 | let result = black.contrastRatio(with: white) 29 | XCTAssertEqual(result.ratio, 21.0, "Contrast ratio between black and white should be 21.0") 30 | XCTAssertEqual(result.rating, "AAA", "Contrast rating should be AAA") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Public/Color+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import SwiftUI 8 | 9 | // MARK: - Concenience APIs 10 | 11 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 12 | extension Color { 13 | 14 | /// Returns a color at level 50 brightness. 15 | public var level50: Color { self.level(.level50) } 16 | 17 | /// Returns a color at level 100 brightness. 18 | public var level100: Color { self.level(.level100) } 19 | 20 | /// Returns a color at level 200 brightness. 21 | public var level200: Color { self.level(.level200) } 22 | 23 | /// Returns a color at level 300 brightness. 24 | public var level300: Color { self.level(.level300) } 25 | 26 | /// Returns a color at level 400 brightness. 27 | public var level400: Color { self.level(.level400) } 28 | 29 | /// Returns a color at level 500 brightness. 30 | public var level500: Color { self.level(.level500) } 31 | 32 | /// Returns a color at level 600 brightness. 33 | public var level600: Color { self.level(.level600) } 34 | 35 | /// Returns a color at level 700 brightness. 36 | public var level700: Color { self.level(.level700) } 37 | 38 | /// Returns a color at level 800 brightness. 39 | public var level800: Color { self.level(.level800) } 40 | 41 | /// Returns a color at level 900 brightness. 42 | public var level900: Color { self.level(.level900) } 43 | 44 | /// Returns a color at level 950 brightness. 45 | public var level950: Color { self.level(.level950) } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Public/ContrastLevel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Defines the contrast levels used to ensure accessibility in user interfaces. 10 | /// 11 | /// This enum provides named contrast levels corresponding to the WCAG accessibility guidelines: 12 | /// - AA Large: Minimum contrast for large text. 13 | /// - AA: Standard minimum contrast for normal text. 14 | /// - AAA: Enhanced contrast for improved readability. 15 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 16 | public enum ContrastLevel: String { 17 | 18 | /// Contrast level for large text, typically for visual accessibility. 19 | case aaLarge = "AA Large" 20 | 21 | /// Standard contrast level for normal text size, ensuring clear readability. 22 | case aa = "AA" 23 | 24 | /// Highest contrast level, recommended for the best readability in all contexts. 25 | case aaa = "AAA" 26 | 27 | /// Provides the minimum and maximum contrast ratios for each contrast level. 28 | /// 29 | /// These values define the acceptable range of contrast ratios to meet or exceed specific 30 | /// accessibility requirements: 31 | /// - For `aaLarge`, the range is suitable for large text. 32 | /// - For `aa`, the range is set for normal text. 33 | /// - For `aaa`, the range encompasses the highest contrast ratio, suitable for all types 34 | /// of text to ensure optimal readability. 35 | var ratio: (min: CGFloat, max: CGFloat) { 36 | switch self { 37 | case .aaLarge: 38 | return (3.0, 4.49) 39 | case .aa: 40 | return (4.5, 6.99) 41 | case .aaa: 42 | return (7.0, CGFloat.infinity) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Public/Color+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import SwiftUI 8 | 9 | // MARK: - Public APIs 10 | 11 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 12 | extension Color { 13 | 14 | /// Adjusts the color based on the specified level and optionally adjusts for the 15 | /// specified color scheme. 16 | /// 17 | /// - Parameters: 18 | /// - level: The specified `ColorLevel`. 19 | /// - scheme: Optional `ColorScheme` to adjust for light or dark mode. 20 | /// - Returns: A `Color` adjusted for the specified level and color scheme. 21 | public func level(_ level: ColorLevel, scheme: ColorScheme? = nil) -> Color { 22 | let adjustedLevel = scheme == .dark ? level.correspondingDarkModeLevel : level 23 | let components = self.uiColorComponents() 24 | return Color.hsl( 25 | hue: components.hue * 360, 26 | saturation: components.saturation, 27 | lightness: adjustedLevel.lightness 28 | ) 29 | } 30 | 31 | /// Returns the color that achieves the highest possible contrast. 32 | /// 33 | /// This property calculates the maximum contrast color available without any upper limit 34 | /// on the contrast ratio, aiming to enhance readability and accessibility. 35 | public var highestRatedContrastLevel: Color { 36 | calculateBestContrast(for: .aaa, maxRatio: CGFloat.infinity) 37 | } 38 | 39 | /// Direct access to a color that complies with the AAA contrast level requirements. 40 | public var aaaContrastLevel: Color { 41 | calculateBestContrast(for: .aaa) 42 | } 43 | 44 | /// Direct access to a color that complies with the AA contrast level requirements. 45 | public var aaContrastLevel: Color { 46 | calculateBestContrast(for: .aa) 47 | } 48 | 49 | /// Direct access to a color that complies with the AA Large contrast level requirements. 50 | public var aaLargeContrastLevel: Color { 51 | calculateBestContrast(for: .aaLarge) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Public/ColorLevel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import Foundation 8 | 9 | /// Enum defining specific lightness levels for colors, useful for generating color themes or 10 | /// ensuring accessible contrast. 11 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 12 | public enum ColorLevel: CaseIterable { 13 | 14 | case level50, level100, level200, level300, level400, level500, 15 | level600, level700, level800, level900, level950 16 | 17 | /// Provides the lightness value associated with each color level. 18 | var lightness: CGFloat { 19 | switch self { 20 | 21 | // Very light shade, almost white. 22 | case .level50: return 0.95 23 | 24 | // Very light shade. 25 | case .level100: return 0.90 26 | 27 | // Lighter shade, for subtle backgrounds. 28 | case .level200: return 0.80 29 | 30 | // Light shade, good for hover states or secondary buttons. 31 | case .level300: return 0.70 32 | 33 | // Medium light shade. 34 | case .level400: return 0.60 35 | 36 | // Neutral base shade, often used for the primary variant of a color. 37 | case .level500: return 0.50 38 | 39 | // Medium dark shade. 40 | case .level600: return 0.40 41 | 42 | // Darker shade, suitable for text. 43 | case .level700: return 0.30 44 | 45 | // Very dark shade, often used for text or active elements. 46 | case .level800: return 0.20 47 | 48 | // Very dark, closer to black. 49 | case .level900: return 0.10 50 | 51 | // Extremely dark, almost black. 52 | case .level950: return 0.05 53 | } 54 | } 55 | 56 | /// Returns the corresponding level for dark mode. 57 | var correspondingDarkModeLevel: ColorLevel { 58 | switch self { 59 | case .level50: return .level950 60 | case .level100: return .level900 61 | case .level200: return .level800 62 | case .level300: return .level700 63 | case .level400: return .level600 64 | case .level500: return .level500 65 | case .level600: return .level400 66 | case .level700: return .level300 67 | case .level800: return .level200 68 | case .level900: return .level100 69 | case .level950: return .level50 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Internal/UIColor+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | #if canImport(UIKit) 8 | import UIKit.UIColor 9 | #else 10 | import AppKit.NSColor 11 | #endif 12 | 13 | // MARK: - Internal API 14 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 15 | extension AgnosticColor { 16 | 17 | /// Computes the perceived luminance of the color using the sRGB formula. 18 | /// This method handles different color spaces including RGB and grayscale. 19 | /// 20 | /// - Returns: A CGFloat representing the luminance of the color. 21 | internal func luminance() -> CGFloat { 22 | guard let components = cgColor.components else { return 0 } 23 | let numberOfComponents = components.count 24 | let r: CGFloat, g: CGFloat, b: CGFloat 25 | 26 | switch numberOfComponents { 27 | case 4: // RGBA 28 | r = components[0] 29 | g = components[1] 30 | b = components[2] 31 | case 2, 1: // Grayscale 32 | r = components[0] 33 | g = components[0] 34 | b = components[0] 35 | default: // Unexpected color space 36 | r = 0 37 | g = 0 38 | b = 0 39 | } 40 | 41 | return calculateLuminance(red: r, green: g, blue: b) 42 | } 43 | 44 | /// Helper function to calculate luminance using the standard sRGB luminance formula. 45 | /// 46 | /// - Parameters: 47 | /// - red: The red component of the color. 48 | /// - green: The green component of the color. 49 | /// - blue: The blue component of the color. 50 | /// - Returns: The calculated luminance as a CGFloat. 51 | private func calculateLuminance(red: CGFloat, green: CGFloat, blue: CGFloat) -> CGFloat { 52 | let rL = adjustedLuminance(component: red) 53 | let gL = adjustedLuminance(component: green) 54 | let bL = adjustedLuminance(component: blue) 55 | 56 | let redCoefficient: CGFloat = 0.2126 57 | let greenCoefficient: CGFloat = 0.7152 58 | let blueCoefficient: CGFloat = 0.0722 59 | 60 | return (redCoefficient * rL) + (greenCoefficient * gL) + (blueCoefficient * bL) 61 | } 62 | 63 | /// Adjusts the component value based on the sRGB luminance formula. 64 | /// 65 | /// - Parameter component: The color component value. 66 | /// - Returns: The adjusted luminance component. 67 | private func adjustedLuminance(component: CGFloat) -> CGFloat { 68 | let kLowRGBLuminanceMultiplier: CGFloat = 12.92 69 | let kHighRGBLuminanceMultiplier: CGFloat = 1.055 70 | let kHighRGBLuminanceAddend: CGFloat = 0.055 71 | let kLuminancePower: CGFloat = 2.4 72 | 73 | return component < 0.03928 ? 74 | component / kLowRGBLuminanceMultiplier : 75 | pow((component + kHighRGBLuminanceAddend) / kHighRGBLuminanceMultiplier, kLuminancePower) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/ContrastKit/UITesting/Example+SwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | #if DEBUG && canImport(SwiftUI) 8 | 9 | import SwiftUI 10 | 11 | // MARK: - UI Preview 12 | 13 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 14 | internal struct StandardShadeView: View { 15 | 16 | private let baseColor = Color.pink 17 | 18 | var body: some View { 19 | HStack { 20 | VStack(spacing: 0) { 21 | ForEach(ColorLevel.allCases, id: \.self) { level in 22 | let shadeColor = baseColor.level(level) 23 | let contrastColor = shadeColor.highestRatedContrastLevel 24 | let contrastInfo = shadeColor.contrastInfo(with: contrastColor) 25 | ShadeCell(level, shade: shadeColor, contrast: contrastColor, info: contrastInfo) 26 | } 27 | } 28 | VStack(spacing: 0) { 29 | ForEach(ColorLevel.allCases, id: \.self) { level in 30 | let shadeColor = baseColor.level(level) 31 | let contrastColor = shadeColor.aaLargeContrastLevel 32 | let contrastInfo = shadeColor.contrastInfo(with: contrastColor, for: .aaLarge) 33 | ShadeCell(level, shade: shadeColor, contrast: contrastColor, info: contrastInfo) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 41 | internal struct EnvironmentShadeView: View { 42 | 43 | @Environment(\.colorScheme) 44 | private var colorScheme 45 | 46 | private let baseColor = Color.pink 47 | 48 | var body: some View { 49 | HStack { 50 | VStack(spacing: 0) { 51 | ForEach(ColorLevel.allCases, id: \.self) { level in 52 | let shadeColor = baseColor.level(level, scheme: colorScheme) 53 | let contrastColor = shadeColor.highestRatedContrastLevel 54 | let contrastInfo = shadeColor.contrastInfo(with: contrastColor) 55 | ShadeCell(level, shade: shadeColor, contrast: contrastColor, info: contrastInfo) 56 | } 57 | } 58 | VStack(spacing: 0) { 59 | ForEach(ColorLevel.allCases, id: \.self) { level in 60 | let shadeColor = baseColor.level(level, scheme: colorScheme) 61 | let contrastColor = shadeColor.aaLargeContrastLevel 62 | let contrastInfo = shadeColor.contrastInfo(with: contrastColor, for: .aaLarge) 63 | ShadeCell(level, shade: shadeColor, contrast: contrastColor, info: contrastInfo) 64 | } 65 | } 66 | VStack { 67 | baseColor.level(.level100, scheme: colorScheme) 68 | baseColor.level(.level900, scheme: colorScheme) 69 | 70 | baseColor.level(.level100, scheme: colorScheme).level900 71 | baseColor.level(.level900, scheme: colorScheme).level100 72 | } 73 | } 74 | } 75 | } 76 | 77 | #Preview("ColorScheme: Fixed") { StandardShadeView() } 78 | #Preview("ColorScheme: Environment") { EnvironmentShadeView() } 79 | 80 | #endif 81 | -------------------------------------------------------------------------------- /Sources/ContrastKit/UITesting/PreviewTesting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | #if DEBUG 8 | 9 | import SwiftUI 10 | 11 | /// An enumeration defining accessibility ratings based on contrast ratios. 12 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 13 | internal enum AccessibilityRating: String { 14 | case fail = "Fail" 15 | case aaLarge = "AA Large" 16 | case aa = "AA" 17 | case aaa = "AAA" 18 | } 19 | 20 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 21 | extension Color { 22 | 23 | /// Returns a string describing the contrast information between this color and another. 24 | /// Includes the enforced contrast level. 25 | /// 26 | /// - Parameters: 27 | /// - color: The other color to compare. 28 | /// - level: The contrast level being enforced. 29 | /// - Returns: A string containing the contrast ratio, its corresponding accessibility 30 | /// rating, and the enforced level. 31 | internal func contrastInfo(with color: Color, for level: ContrastLevel? = nil) -> String { 32 | let selfUIColor = AgnosticColor(self) 33 | let otherUIColor = AgnosticColor(color) 34 | let contrastDetails = selfUIColor.contrastRatio(with: otherUIColor) 35 | return String(format: "%.2f: %@\n(Enforced: %@)", contrastDetails.ratio, contrastDetails.rating, level?.rawValue ?? "None") 36 | } 37 | } 38 | 39 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 40 | extension AgnosticColor { 41 | 42 | /// Calculates the contrast ratio between this color and another specified color and determines 43 | /// the accessibility rating based on that ratio. 44 | /// 45 | /// - Parameter otherColor: The other color to compare with. 46 | /// - Returns: A tuple containing the contrast ratio and the accessibility rating as a string. 47 | public func contrastRatio(with otherColor: AgnosticColor) -> (ratio: CGFloat, rating: String) { 48 | let luminance1 = self.luminance() 49 | let luminance2 = otherColor.luminance() 50 | let ratio = (max(luminance1, luminance2) + 0.05) / (min(luminance1, luminance2) + 0.05) 51 | 52 | return (ratio, rating(for: ratio)) 53 | } 54 | 55 | /// Determines the accessibility rating based on the contrast ratio. 56 | /// 57 | /// - Parameter ratio: The contrast ratio. 58 | /// - Returns: The corresponding accessibility rating as a string. 59 | private func rating(for ratio: CGFloat) -> String { 60 | switch ratio { 61 | case ..<3: 62 | return AccessibilityRating.fail.rawValue 63 | case ..<4.5: 64 | return AccessibilityRating.aaLarge.rawValue 65 | case ..<7: 66 | return AccessibilityRating.aa.rawValue 67 | default: 68 | return AccessibilityRating.aaa.rawValue 69 | } 70 | } 71 | } 72 | 73 | /// A view component that displays a color shade along with its contrast information. 74 | /// 75 | /// This view constructs a cell that visually represents a color shade (`shade`) and its 76 | /// contrast color (`contrast`), along with informational text (`info`) that describes the 77 | /// contrast details. It is designed to facilitate easy visualization of color contrasts in 78 | /// different UI contexts, especially useful in settings where visual accessibility is tested. 79 | /// 80 | /// - Parameters: 81 | /// - level: The color level associated with the shade. Used to label the view. 82 | /// - shade: The primary color of the view's background. 83 | /// - contrast: The color used for text foreground to ensure it stands out against the `shade`. 84 | /// - info: A string containing detailed information about the contrast ratio and/or other 85 | /// relevant data. 86 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 87 | internal struct ShadeCell: View { 88 | private let level: ColorLevel 89 | private let shade: Color 90 | private let contrast: Color 91 | private let info: String 92 | 93 | /// Initializes a new instance of `ShadeCell`. 94 | /// 95 | /// - Parameters: 96 | /// - level: The color level for which this cell is being displayed. 97 | /// - shade: The background color representing the specific color shade of the level. 98 | /// - contrast: The color used for text that contrasts with the background shade. 99 | /// - info: Textual information about the contrast properties or other relevant details. 100 | init(_ level: ColorLevel, shade: Color, contrast: Color, info: String) { 101 | self.level = level 102 | self.shade = shade 103 | self.contrast = contrast 104 | self.info = info 105 | } 106 | 107 | /// The content and layout body of the view. 108 | var body: some View { 109 | ZStack { 110 | shade 111 | VStack { 112 | Text("\(level)") 113 | Text(info) 114 | } 115 | .foregroundColor(contrast) 116 | .font(.caption.bold()) 117 | .multilineTextAlignment(.center) 118 | } 119 | } 120 | } 121 | 122 | #endif 123 | -------------------------------------------------------------------------------- /Sources/ContrastKit/Internal/Color+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project: ContrastKit 3 | // Author: Mark Battistella 4 | // Website: https://markbattistella.com 5 | // 6 | 7 | import SwiftUI 8 | 9 | // MARK: - Internal APIs 10 | 11 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 12 | extension Color { 13 | 14 | /// Creates a color from specified HSL (Hue, Saturation, Lightness) components. 15 | /// 16 | /// The method calculates the corresponding RGB values using the HSL color model and 17 | /// returns a SwiftUI Color. 18 | /// 19 | /// - Parameters: 20 | /// - hue: The hue component of the color, specified in degrees. 21 | /// - saturation: The saturation of the color, specified as a fraction from 0.0 to 1.0. 22 | /// - lightness: The lightness of the color, specified as a fraction from 0.0 to 1.0. 23 | /// - Returns: A SwiftUI Color object with the specified HSL values. 24 | internal static func hsl( 25 | hue: CGFloat, 26 | saturation: CGFloat, 27 | lightness: CGFloat 28 | ) -> Color { 29 | let c = (1 - abs(2 * lightness - 1)) * saturation 30 | let x = c * (1 - abs(fmod(hue / 60.0, 2) - 1)) 31 | let m = lightness - c / 2 32 | 33 | let (r, g, b) = hueToRGB(hue: hue, c: c, x: x, m: m) 34 | return Color(red: Double(r), green: Double(g), blue: Double(b)) 35 | } 36 | 37 | /// Retrieves and returns the HSL components of the color. 38 | /// 39 | /// This method uses the UIKit method to extract hue, saturation, and brightness 40 | /// values, then returns them as a tuple. Note that `brightness` in UIKit corresponds 41 | /// to `lightness` in HSL. 42 | /// 43 | /// - Returns: A tuple containing the hue, saturation, and lightness (brightness in UIKit) 44 | /// of the color. 45 | internal func uiColorComponents() -> (hue: CGFloat, saturation: CGFloat, lightness: CGFloat) { 46 | var hue: CGFloat = 0 47 | var saturation: CGFloat = 0 48 | var brightness: CGFloat = 0 49 | 50 | #if os(macOS) 51 | let color = AgnosticColor(self).usingColorSpace(.sRGB) ?? AgnosticColor.white 52 | #else 53 | let color = AgnosticColor(self) 54 | #endif 55 | 56 | color.getHue(&hue, 57 | saturation: &saturation, 58 | brightness: &brightness, 59 | alpha: nil) 60 | return (hue, saturation, brightness) 61 | } 62 | 63 | /// Calculates the color that provides the best contrast according to the specified 64 | /// contrast level settings. 65 | /// 66 | /// This method calculates the best contrast by evaluating all potential candidate colors 67 | /// and selecting the one that offers the highest contrast without exceeding the specified 68 | /// maximum ratio. 69 | /// 70 | /// - Parameters: 71 | /// - level: The contrast level to calculate. 72 | /// - maxRatio: The optional maximum contrast ratio to consider. If nil, there's no 73 | /// upper limit. 74 | /// - Returns: The `Color` that provides the best contrast. 75 | internal func calculateBestContrast( 76 | for level: ContrastLevel, 77 | maxRatio: CGFloat? = nil 78 | ) -> Color { 79 | let backgroundLuminance = AgnosticColor(self).luminance() 80 | var bestContrastColor: Color = .white 81 | var bestContrastRatio: CGFloat = 0 82 | let candidateColors = [Color.white, Color.black] + ColorLevel.allCases.map { self.level($0) } 83 | let (minRatio, upperLimit) = (level.ratio.min, maxRatio ?? level.ratio.max) 84 | for candidate in candidateColors { 85 | let candidateLuminance = AgnosticColor(candidate).luminance() 86 | let contrastRatio = (max(backgroundLuminance, candidateLuminance) + 0.05) / 87 | (min(backgroundLuminance, candidateLuminance) + 0.05) 88 | if contrastRatio >= minRatio 89 | && contrastRatio <= upperLimit 90 | && contrastRatio > bestContrastRatio { 91 | bestContrastRatio = contrastRatio 92 | bestContrastColor = candidate 93 | } 94 | } 95 | return bestContrastColor 96 | } 97 | } 98 | 99 | // MARK: - Private APIs 100 | 101 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) 102 | extension Color { 103 | 104 | /// Converts hue value to RGB components. 105 | /// 106 | /// This function supports the conversion by determining which segment 107 | /// of the color wheel the hue lies within, and adjusts the RGB values accordingly. 108 | /// 109 | /// - Parameters: 110 | /// - hue: The hue component of the color. 111 | /// - c: The color value. 112 | /// - x: The secondary color value. 113 | /// - m: The base value for RGB components. 114 | /// - Returns: The RGB components as a tuple. 115 | private static func hueToRGB( 116 | hue: CGFloat, 117 | c: CGFloat, 118 | x: CGFloat, 119 | m: CGFloat 120 | ) -> (CGFloat, CGFloat, CGFloat) { 121 | switch Int(hue / 60.0) % 6 { 122 | case 0: (c + m, x + m, m) 123 | case 1: (x + m, c + m, m) 124 | case 2: (m, c + m, x + m) 125 | case 3: (m, x + m, c + m) 126 | case 4: (x + m, m, c + m) 127 | case 5: (c + m, m, x + m) 128 | default: (m, m, m) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |