Skip to content

How I Fixed SwiftUI Color Contrast with OKLCH and APCA

A TestFlight screenshot exposed unreadable calendar tiles. Here is how I rebuilt hora's SwiftUI color system with OKLCH, APCA-style checks, and tests.

10 min read
A banana event tile in hora Calendar keeping its hue while its text changes from low contrast yellow to readable dark brown

During the first days of hora's public beta, a tester sent me a screenshot of a banana-yellow event in light mode. The title was rendered in nearly the same yellow as the tile behind it.

"I literally can't read this."

He was right. I had been looking at the same interface for weeks and had trained myself not to see the problem.

It was the simplest possible SwiftUI color contrast bug, and I had missed it.

The screenshot stayed on my list for almost two weeks. On May 6, I stopped adjusting opacity values inside individual views and replaced the whole path with one shared SwiftUI color resolver.

That resolver now does three jobs:

  • It turns a user-selected Google Calendar color into a predictable light or dark event surface.
  • It moves title and secondary text lightness until the pair reaches hora's chosen Lc target.
  • It describes invitation and selection state with borders, opacity, and strikethrough, not color alone.

This is the story of that rebuild, including the parts I described too confidently the first time around.

The screenshot I could not unsee

The first version of hora treated an event color as a hex value with opacity.

That approach looked fine with cool colors such as peacock and blueberry. It broke down with warmer colors such as banana, flamingo, and tangerine. A light tint made a pleasant tile background, but reusing the same brand color for text left too little separation between foreground and surface.

The bad banana tile was only the most obvious symptom.

  • Pending invitations used faded text over a transparent surface.
  • Tile edges disappeared into the white calendar grid.
  • Declined events relied mostly on lower opacity.
  • Selected warm colors could become too bright for white text.
  • Similar rules had started to drift between Week, Day, and Month views.

I had already made an earlier appearance fix by separating some light and dark mode values. It improved the surface, but it was still a local patch. The foreground color could remain unreadable for a particular hue, and every view was responsible for remembering the same details.

The real bug was not yellow. The bug was the absence of a color system.

The old combination failed every metric I used

My original version of this post claimed that WCAG contrast ratios do not work for tinted text on tinted backgrounds. That was wrong.

WCAG 2.2 calculates contrast from the final foreground and background colors. It does not stop applying because both colors are tinted. Normal text still needs a 4.5:1 contrast ratio under the current success criterion, and the old warm-hue combinations did not meet it.

For a simple light-mode composite, the banana pairing measured roughly 1.60:1 under the WCAG 2 ratio. hora's local Lc implementation measured about 24.6. After the resolver changed the foreground lightness, the same banana surface reached roughly Lc 84.6.

Those measurements are engineering signals, not a claim of formal accessibility conformance.

I kept an Lc check based on the APCA W3 formula because it is polarity-aware and useful for catching regressions in a dynamic palette. APCA is still work in progress, and WCAG 3 remains a draft. I use the Lc values alongside the current WCAG model and visual review, not as a replacement for them.

The distinction matters:

  • WCAG 2 remains the current conformance standard.
  • OKLCH is a color model, not a contrast test.
  • hora's Lc thresholds are internal engineering targets.
  • A passing number does not replace checking the rendered interface.

That is less dramatic than saying I found the one correct contrast algorithm. It is also much more honest.

Why OKLCH made the palette predictable

I first considered HSL because it is familiar and easy to manipulate. The problem is that equal HSL lightness does not look equally light across hues. A numerical yellow and blue can share the same lightness while appearing very different on screen.

OKLCH gives me separate controls for perceived lightness, chroma, and hue:

  • L controls lightness.
  • C controls chroma.
  • H keeps the calendar color's hue identity.

That makes the operation I actually need straightforward: preserve the user's hue, cap chroma for the surface, then move foreground lightness in small steps.

An OKLCH lightness path keeping the banana hue fixed while the event title darkens toward hora's Lc target.

In light mode, an accepted tile currently starts with these values:

  • Background: L 0.94, with chroma capped at 0.06.
  • Title: L 0.34, with chroma capped at 0.14.
  • Secondary text: L 0.44, with chroma capped at 0.12.

If a foreground pair misses its target, the resolver lowers foreground L by 0.04 and checks again, up to six times.

Dark mode uses the same idea in the other direction. The background starts at L 0.26, while title and secondary text begin at L 0.88 and L 0.78. A failed pair moves brighter in 0.03 steps.

These constants are not universal design tokens. They are the values that work for hora's tile sizes, type styles, and current surfaces. Keeping them in one place is what makes them maintainable.

One SwiftUI resolver instead of five local fixes

The public entry point is deliberately small:

public enum EventTileColors {
    public static func resolve(
        brand: Color,
        scheme: ColorScheme,
        state: TileState
    ) -> EventTilePalette {
        let key = CacheKey(
            brand: brand,
            scheme: scheme,
            state: state
        )

        if let cached = cache.value(for: key) {
            return cached
        }

        let palette = compute(
            brand: brand,
            scheme: scheme,
            state: state
        )
        cache.set(palette, for: key)
        return palette
    }
}

The returned EventTilePalette contains the complete visual contract for a tile:

public struct EventTilePalette: Sendable, Equatable {
    public let bg: Color
    public let fg: Color
    public let fgSecondary: Color
    public let stroke: Color
    public let accent: Color
    public let dimOpacity: Double
    public let strikethrough: Bool
    public let strokeIsDashed: Bool
}

The clamp itself is intentionally boring:

private static func clampedFG(
    targetLc: Double,
    start: OKLCH,
    bg: Color,
    step: Double,
    iterations: Int
) -> Color {
    var current = start

    for _ in 0..<iterations {
        let candidate = current.toColor()
        if APCA.lc(text: candidate, background: bg) >= targetLc {
            return candidate
        }

        current = OKLCH(
            L: max(0, min(1, current.L + step)),
            C: current.C,
            H: current.H
        )
    }

    return current.toColor()
}

There is no machine learning, no hidden system API, and no giant lookup table. It is a deterministic transformation that can be cached and tested.

State should not depend on color alone

Fixing the accepted tile was only half the job. A calendar event also communicates whether it needs a response, has been declined, or is currently selected.

The resolver now expresses five states users can actually see:

StateVisual treatment
AcceptedFilled surface with resolved title and secondary colors
TentativeAccepted surface plus a dashed brand border
PendingTransparent surface, solid brand outline, text resolved against the calendar canvas
DeclinedAccepted palette at 40% opacity plus strikethrough
SelectedSaturated brand surface, clamped darker when white text misses hora's Lc 60 target

The code also defines a cancelled palette, but the normal sync path removes or ignores cancelled events instead of presenting it as a common visible state.

Shared-event stripes were simplified at the same time. A 3 pt leading bar preserves the source color, while an SF Symbol can communicate additional meaning:

  • calendar.and.person for an event shared across calendars.
  • calendar.badge.lock for an event blocked by another calendar.

That is more scalable than adding another colored stripe for every calendar relationship.

A hora Calendar week view showing readable tiles across Google's eleven event hues and five visible states.

Week and Day timed events use the full palette resolver. Their all-day pills use it too. Month view shares the same state model, but its compact timed rows deliberately keep system text colors for density. The +N more popover reuses the Month row rather than inventing another color path.

That detail is worth stating because "one resolver everywhere" sounds cleaner than the real implementation. The real implementation has one source of color and state truth, plus a few deliberate presentation differences.

Testing SwiftUI color contrast across 11 hues

The test suite runs 143 contrast checks across Google's 11 event colors, light and dark appearance, and the states covered by the resolver.

The exact breakdown is:

  • 44 title checks for four filled states in light mode.
  • 44 title checks for the same filled states in dark mode.
  • 22 secondary-text checks across both appearances.
  • 11 selected-state checks with white text.
  • 22 pending-text checks against the light or dark canvas.

The title target is Lc 75. Secondary, pending, and selected text use Lc 60. Those are hora's regression thresholds, not a blanket promise that every text style at those values is accessible in every context.

The tests also have limits:

  • They verify the resolver's base foreground and background pairs.
  • They do not measure declined or cancelled tiles after final opacity compositing.
  • Pending tiles are checked against a fixed white or near-black canvas.
  • They do not test color management, screenshots, or every final rendered pixel.

I want to add screenshot regression coverage later. A number can catch a palette drifting below a threshold, but it cannot tell me that a border feels too weak, a title wraps badly, or an event disappears when the calendar is dense.

What actually shipped

The shared resolver landed on May 6 under the SZA-321 color and state refactor. A follow-up wired Month view deduplication and the new shared-event icons. The cleaner event colors were later called out in the TestFlight 0.7.5 notes.

That history is less tidy than the original claim that a two-week rewrite shipped as one perfectly scoped release. The screenshot waited for almost two weeks. The core implementation happened mostly in one focused morning, followed by integration fixes.

I also removed an old promise from this post that the system was a clean drop-in for Liquid Glass. It is not. A material that changes with the content behind it changes the contrast problem too. If hora moves event tiles to glass, the final composited result needs to be measured and reviewed again.

What I would add today:

  • Tests for the final composited declined state.
  • Canvas colors supplied by the active theme instead of fixed constants.
  • Screenshot fixtures for the warmest and lightest hues.
  • Explicit variants for Increase Contrast and Differentiate Without Color.
  • A small audit that checks WCAG 2 ratios alongside hora's Lc regression targets.

This is the part of building a native Swift app that I enjoy. The fix is not a CSS override around a web view. It becomes a small product primitive shared by calendar surfaces. I wrote more about that tradeoff in Native App vs Electron and PWA.

The lesson I kept

The useful lesson was not "use APCA" or "use OKLCH."

It was this: if users can choose the hue, opacity is not a color system.

OKLCH made the adjustments predictable. The Lc checks gave me another regression signal. The state model stopped color from carrying every meaning by itself. The 143 checks made it harder for the same yellow-on-yellow bug to return unnoticed.

One tester screenshot forced me to replace a pile of local fixes with a primitive I can still reason about months later. That is exactly the kind of beta feedback I want.

The resolver described here now powers calendar views in hora Calendar 1.0. If your schedule lives in Google Calendar and you want to see the result in a native Mac app, try hora Calendar on the Mac App Store.

SwiftUI color contrast FAQ

05 questions / quick answers

What is OKLCH, and why use it for UI colors?

OKLCH represents a color as lightness, chroma, and hue. It makes perceived lightness adjustments more predictable across warm and cool colors. It does not guarantee readable contrast by itself, so every final foreground and background pair still needs to be tested.

Does OKLCH guarantee accessible color contrast?

No. OKLCH is a color model, not an accessibility test. Text size, font weight, opacity, appearance mode, and the final foreground and background values all affect readability.

Should APCA replace WCAG contrast testing?

No. APCA can be a useful additional perceptual signal, but WCAG 2.2 remains the current W3C recommendation and WCAG 3 is still a draft. hora uses local Lc checks as engineering regression targets alongside current WCAG ratios and visual review.

How do I support Increase Contrast in SwiftUI?

Read colorSchemeContrast from the SwiftUI environment and provide stronger variants when it is set to increased. For interfaces where color carries meaning, also consider accessibilityDifferentiateWithoutColor and add another visible cue such as a border, icon, label, or strikethrough.

How do you test a dynamic SwiftUI color system?

Build a matrix across every supported hue, appearance mode, and UI state. Test primary text, secondary text, selection, invitation states, borders, and icons. Then review screenshots because a passing numeric threshold does not catch every visual problem.

Related stories

Google Calendar API vs CalDAV: Which Should You Use?
Engineering

Google Calendar API vs CalDAV: Which Should You Use?

Google Calendar API or CalDAV? Compare sync, auth, event support, and portability, plus the tradeoffs I learned while building hora for Google Calendar.

Maciej Szamowski10 min read
Google Calendar API in Swift: The Package I Missed
Engineering

Google Calendar API in Swift: The Package I Missed

I hand-built a Google Calendar API client in Swift, then migrated hora to Google's GTLR library. See the code, tradeoffs, and rate-limit lessons.

Maciej Szamowski10 min read
Real-Time Google Calendar Sync on macOS
Engineering

Real-Time Google Calendar Sync on macOS

How hora uses Google Calendar watch channels, a Cloudflare Worker, APNs, and fallback polling to keep its native Mac calendar fresh and reliable.

Maciej Szamowski10 min read
Mobile Beta Sign-up

Want to stay updated?

Subscribe for information about iOS/iPadOS beta availability.