Three stops — purple into blue into black — matches ios!144

#3A1E9E purple, #0B3080 blue, #070A1A black, top down. Less purple than the
two-stop version, because the purple is a third of the sweep rather than half.

🔴 One card colour against three stops has to lift off all three. #0E1745
looked right against the purple and the blue and measured 1.15:1 against the
black end — a card that disappears at the bottom of a long screen. #141C52
lifts 1.41 / 1.33 / 1.23.

The palette test checks the middle stop too. A three-stop gradient checked only
at its ends is two thirds measured, and the middle is where most of a screen
actually sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 13:37:35 -04:00
co-authored by Claude Opus 5
parent 15e678d593
commit b824e626e7
2 changed files with 31 additions and 15 deletions
@@ -79,11 +79,12 @@ object Palette {
// the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the
// bottom stop binds, and it is where most of the explanatory copy sits.
// White cards lift off the bottom at 2.4:1, which keeps a card a card.
val GroundTop = Color(0xFF3A0CA3)
val GroundBottom = Color(0xFF062C8C)
val GroundTop = Color(0xFF3A1E9E) // purple
val GroundMid = Color(0xFF0B3080) // blue
val GroundBottom = Color(0xFF070A1A) // black
// Kept as the same two colours: there is one theme now.
val NightGroundTop = Color(0xFF3A0CA3)
val NightGroundBottom = Color(0xFF062C8C)
val NightGroundTop = Color(0xFF3A1E9E)
val NightGroundBottom = Color(0xFF070A1A)
val Teal = Color(0xFF4BE3C0) // success and presence
val Pink = Color(0xFFFF819F) // the kit's alert accent
@@ -93,13 +94,13 @@ object Palette {
// ⚠️ Still needed where a gradient cannot go (a single Color parameter, a
// system bar tint). This is the gradient's mid-tone, so the two never read
// as different surfaces.
val Mist = Color(0xFF201C98) // the page ground, flat
val SurfaceAlt = Color(0xFF2A1875) // received bubbles, pills, bands
val IncomingBubble = Color(0xFF2A1875)
val Mist = Color(0xFF191D68) // the page ground, flat
val SurfaceAlt = Color(0xFF1E2A70) // received bubbles, pills, bands
val IncomingBubble = Color(0xFF1E2A70)
// ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
// needs to read as a circle against the surface behind it, and two greys a
// shade apart do not.
val AvatarFill = Color(0xFF31207F)
val AvatarFill = Color(0xFF243480)
// 🔴 **Darkened with the ground, not left behind it.** #505C69 read 5.0:1
// on the old bottom stop and only 3.5:1 on the darker one — below AA, and
// most of the explanatory copy on these screens is set in it. Measured now:
@@ -126,8 +127,12 @@ object Palette {
// card lighter than a saturated violet has to be lighter still than that,
// which puts secondary text below AA on it. Recessed panels invert the
// problem: the gradient is the bright thing, content sits in dark wells.
val InkSurface = Color(0xFF190B4E) // --panel
val NightGround = Color(0xFF201C98) // --bg, the gradient's mid-tone
// 🔴 One card colour against a THREE-stop ground, so it must lift off all
// three. #0E1745 looked right against the purple and the blue and measured
// 1.15:1 against the black end — a card that disappears at the bottom of a
// long screen. This lifts 1.41 / 1.33 / 1.23.
val InkSurface = Color(0xFF141C52) // --panel
val NightGround = Color(0xFF191D68) // --bg, the gradient's mid-tone
}
/**
@@ -142,7 +147,8 @@ fun groundBrush(): Brush =
// One theme, so no branch. The Night* pair is kept pointing at the same two
// colours rather than deleted, because several screens name it directly and
// a rename across both apps is a separate change.
Brush.verticalGradient(listOf(Palette.GroundTop, Palette.GroundBottom))
Brush.verticalGradient(
listOf(Palette.GroundTop, Palette.GroundMid, Palette.GroundBottom))
/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */
fun accentBrush(): Brush =
@@ -88,8 +88,11 @@ class PaletteMatchesIOSTest {
@Test
fun `text clears AA on every surface it is read on`() {
val surfaces = listOf(
"ground top" to Palette.GroundTop,
"ground bottom" to Palette.GroundBottom,
"ground top (purple)" to Palette.GroundTop,
// ⚠️ The middle stop is a surface too. A three-stop gradient
// checked only at its ends is two thirds measured.
"ground middle (blue)" to Palette.GroundMid,
"ground bottom (black)" to Palette.GroundBottom,
"card" to Palette.InkSurface,
"field and incoming bubble" to Palette.SurfaceAlt,
"avatar" to Palette.AvatarFill,
@@ -111,8 +114,15 @@ class PaletteMatchesIOSTest {
*/
@Test
fun `cards and fields lift off what is behind them`() {
val cardLift = contrast(Palette.InkSurface, Palette.GroundBottom)
assertTrue(cardLift >= 1.15, "a card lifts off the ground by only %.2f:1".format(cardLift))
// 🔴 Against ALL THREE stops. One card colour has to survive the whole
// sweep, and the black end is the one it fails on.
for ((name, stop) in listOf("purple" to Palette.GroundTop,
"blue" to Palette.GroundMid,
"black" to Palette.GroundBottom)) {
val lift = contrast(Palette.InkSurface, stop)
assertTrue(lift >= 1.15,
"a card lifts off the $name end of the ground by only %.2f:1".format(lift))
}
val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface)
assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift))
}