Blog

August 15, 2026 · 10 min read

App Theme Palette Audit: Cutting 20 Sets to 12

Palettes lifted from a web reference had correct hex values, yet cards vanished into the background in 20 of them. The criteria that survived the audit, and how a parameter silently dropped 17 times.

  • design
  • theming
  • palette
  • iOS

A user said the card and background colors did not seem to have come through correctly.

I took 17 of 20 paid themes from a web palette site, Happy Hues palettes 1 through 17. The site gave ten colors—background, title, body, button, card, and others—so copying them directly seemed sufficient.

The audit found every copied hex was correct. Yet the card on screen differed from the site.

Base colors matched 100%; only cards differed

First I checked the transferred values. I downloaded the site's original HTML and mechanically compared it with code arguments.

Surface, title, body, stroke, button, button text, highlight, secondary, tertiary, and card—all ten matched the site.

There was one exception: palette 13 had no Stroke role on the site, so code filled black arbitrarily.

But screen pixels differed

I sampled pixels from palette-gallery screenshots saved in the album.

Role Screen value Compared with site
Surface #fffffd, #f2f6f5, #fdc7d7, #f9f5f2 Match (JPEG error ±1)
Accent #3ea9fb, #00ecc8, #fbae2c, #6246e8 Match
Card Only 3 of 17 palettes use palette color 14 are derived values

Surface and accent were correct. Cards were the problem.

Fourteen card colors were paper.steppedSurface(0.10), the result of darkening the surface by 10%. They were computed by our code, not taken from the site.

The same value repeated across palettes; #e5e5e5 appeared in three. Similar near-white surfaces produce the same derived value.

The function makes this repetition expected.

// ColorMixing.swift:41,50-54
/// 이 색이 종이라면 그 위의 잉크는 검은 쪽인가 흰 쪽인가.
public var prefersDarkInk: Bool { relativeLuminance > 0.45 }

/// 표면을 **한 단 옮긴다.** 밝은 종이는 어두운 쪽으로, 어두운 종이는 밝은 쪽으로 —
/// 방향을 팔레트가 정하지 않아도 위계가 늘 같은 방향으로 쌓인다.
public func steppedSurface(_ amount: Double) -> Color {
  mixed(with: prefersDarkInk ? .black : .white, by: amount)
}

The inputs are one surface color and one ratio. Similar surfaces produce similar results. Even with 17 palettes, near-white surfaces gather around the same gray card.

The criterion is WCAG relative luminance with a threshold of 0.45. Above it I treat the paper as light and mix black; otherwise I mix white. Thus cards in dark palettes become brighter than the surface.

The function is not itself wrong. It is needed to create hierarchy when a palette supplies no card color. The problem was that it won even when the site supplied one.

Seventeen palettes produced only three kinds of card.

The cause was different sections on one page

A Happy Hues palette page shows one palette in several sections, changing the surface to demonstrate how it looks.

That contained the trap.

Our code took the surface from the first section and the card from another. In 16 of 17 palettes, the source sections differed.

Within one section, the (surface, card) pair is valid because the site shows them together. Across sections it is not.

That made cards the same color as the surface

In 11 palettes, the card argument became the same color as the first section's background. The card from another section happened to sit on a background equal to the first section's.

Our code has this check.

separates = 대비비 >= 1.05

It checks whether card and surface are distinct. Equal colors have contrast 1.0 and fail. On failure, code falls back to a mechanical derived value.

The check worked and rejected the wrong values. The problem was what it wrote afterward. It silently filled a derived color, so the user saw a screen where the palette had not taken effect.

Two palettes failed in the opposite direction: the card was brighter than the surface.

The missed value was the background of the card's section

The answer was on the site: the background color of the section containing the card.

That was the palette's second surface color, a pair the site itself had shown as distinct side by side.

Palette Missed second surface color
2 #f2f4f6
3 #d8eefe (pale sky)
4 #242629
14 #e3f6f5 (mint)
17 #f3d2c1

All five also fit our hierarchy, with contrast between 1.10 and 1.32.

Even when values come from one page, I must record which section they came from.

Unused parameter sets were sitting quietly

The audit found something else.

The happyHues(...) argument list had highlight, secondary, and tertiary, none used in the function body.

Three of the ten colors supplied by the site never reached the app.

Swift warns about unused local variables but not unused parameters, so this stayed hidden.

I passed values 17 times and discarded them 17 times.

The site's colors and app roles differ

This is the fundamental problem with transferring a web palette directly into an app.

A landing page has roles such as background, title, body, and button. The app has surface, plate, sunken state, stroke, marker, and switch fill.

Some overlap, but more do not. The app has states absent from the web: pressed, selected, and disabled.

Filling unmatched roles with a derived function means those roles do not have a palette character.

Surface hierarchy was opposite to the site's

A deeper mismatch remained.

Eight palettes use “quiet surface + brighter card” on the site, making the card rise from the background.

Our rule is opposite. The code comment says:

A card is not an object floating above the surface, but a place laid on the surface.

So our card is darker than the surface, a printed-on-paper metaphor.

Taking the site value unchanged reverses that decision and touches one test.

DesignTokensTests.testDarkPaperIsVisiblyOffBlack

It is a contract that in dark mode the plate must be brighter than paper.

This is not a technical judgment. It is a product-impression decision, so I raised it to the user.

I used house and collected palettes differently

House palettes follow the rule: the card is darker than the surface.

Measured palettes follow the direction of their source app. blushPaper is one: blush #FAE4E6 surface with a white card. That app's impression is a white card on pink paper, and our screens are all cards in lists, detail, and Settings, so the structure still works.

I invented the sunken surface because the source has no third surface. Using measured #F2E2E4 gives contrast 1.03, collapsing three surfaces into one, so I pressed it one more step to #F1D6DA.

For palettes with invented values I record which value was invented and why above the declaration. Without separating measured and invented values, the next fix cannot know which has evidence.

I reduced 20 themes to 12 and changed their sources

I took palettes from real apps and real palette projects instead of landing pages.

I measured color area in app screenshots

I chose five indie apps and excluded big-company UIs.

I downloaded six US App Store screenshots for each, cropped the central 62%, and measured the area occupied by each color.

App Main color Area
Structured Blush #FAE4E6 15.9%
Gentler Streak Amber #F9A81F 3.9%
Tangerine Apricot #FFF2EA 14.7%
Kino Black #131313 16.5%
Kino Periwinkle #8885F2 4.2%

I measured area for a reason. Palette sites show colors side by side, making each seem equally weighted. In a real app, a surface can occupy 15% and an accent 4%.

An accent is an accent because it is used sparingly. I need the ratio to decide which color becomes surface and which marker when transferring a palette.

I took palette projects as they were

Some palettes are already built from color theory and widely used as editor themes.

I used Rosé Pine Dawn, Catppuccin Latte, Nord, Gruvbox, Kanagawa, and Everforest. All are MIT licensed.

Unlike landing palettes, they define multiple surface levels, such as background, surface, surface2, and surface3, because code editors need them.

We need the same: surface, plate, and sunken place.

I deleted the derived function. Since the source defines a third surface, there is no reason to calculate it.

I reduced 20 themes to 12

Before After
Built-in 2 2
Paid 18 10 (5 light, 5 dark)

I retired eight. Ten coherent palettes are better than 17 with only three kinds of card.

I recorded retired names in two places

Users already have these themes. If a name disappears, the app cannot find the value.

I put a table from 18 retired names to successor palettes in two places.

Where For
DB migration v94 Rows stored locally
StreamPalette.ID.succession Values migration cannot reach

The second table is necessary because iCloud-synced rows and strings stored by widgets in App Group are not touched by a DB migration. They may come from another device or process.

I also wired the widget's theme-reading path through this interpretation function.

I fixed the paywall wording too: “19 themes” became “10 paid themes.”

Contracts rejected four palette drafts

I created ten new palettes and ran the contract tests: 78 cases.

Four failed.

Palette What failed Fix
blushPaper Sunken/surface 1.03 (minimum 1.05) #F2E2E4#F1D6DA
amberLinen Sunken/plate 1.04 Use measured #E5E3E9
everforest bg0/bg1 channel delta 0.039 (minimum 0.05) Change plate to bg2, 0.075
obsidian Surface #0A0A0A/plate #161616 is 0.047 Pure black + #131313

A number can fail in the second decimal place. The difference between 1.03 and 1.05 is hard to see, so tests are needed.

Strokes must be brighter on black paper

Obsidian exposed another issue: the stroke was #060606, effectively invisible.

In a light theme, stroke is darker than paper. Applying that rule unchanged made a darker line on black paper.

On black paper the stroke material must be brighter than paper. I changed it to #8E8E93.

If a derived rule assumes a brightness direction, it reverses in a dark theme.

I changed the research method once

I searched the web for indie-app colors and got SEO color-introduction sites: lists such as “popular colors of 2026,” not actual app colors.

I changed methods. I used the iTunes Lookup API for app information, downloaded screenshots, and measured color area.

Happy Hues had a similar issue. Reader mode removed all hex values. I had to fetch original HTML and parse color-name/hex pairs from the DOM.

Text-extraction tools discard data about colors.