Rebuild the event colour system on Oklch (#21, #36)

Colours were shaped in HSV, whose "value" is not perceived brightness:
at one pinned value the hues spread across relative luminance 0.10 to
0.45, straddling the black/white ink crossover at 0.179. So the ink was
decided by hue — red and purple took white, orange and blue took black —
and the fills nearest the crossover were the mid colours that contrast
badly with either ink.

Shape them in Oklch instead, where lightness matches what the eye calls
brightness, and split the two jobs one colour was doing: a container
sits behind text and must contrast with its ink, an accent is a mark on
a surface and must contrast with the surface. Nine call sites that were
painting marks now ask for the accent.

Colours go to whichever pole they already sit nearer rather than all
being forced deep — a dark orange is brown and a dark yellow is olive,
so the warm, naturally light hues keep their character and take dark ink
while the rest go deep and take white. Either way they land clear of the
middle, which was the fault. White ink is now 5.6-6.3:1 on every deep
hue and dark ink 11:1 on the light ones, against 2.7-8.2:1 before.

Picker curation now calls the same eventTone the picker paints with,
instead of a copy of the shaping kept in step by hand, and drops its CIE
Lab helper for Oklab distance. Renames the setting to "harmonise", since
it no longer softens anything.
This commit is contained in:
2026-07-31 19:39:11 +02:00
parent 37995d1fb3
commit 9287b7b0da
18 changed files with 573 additions and 160 deletions

View File

@@ -1,12 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs import de.jeanlucmakiola.calendula.domain.color.Oklch
import kotlin.math.atan2 import de.jeanlucmakiola.calendula.domain.color.eventTone
import kotlin.math.cbrt import de.jeanlucmakiola.calendula.domain.color.oklchOf
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
/** /**
* Curates an account's published event palette for the colour picker. * Curates an account's published event palette for the colour picker.
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
* (#22). * (#22).
* *
* Crucially, curation runs against the colour the picker actually *paints*, not * Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]: * the raw provider value — and it gets that colour from the same [eventTone]
* it pins lightness to a constant and caps saturation, so the raw palette's * the picker calls, rather than from a copy of its shaping kept in step by hand.
* lightness axis is invisible on screen. Two raw colours that look different — * Because a harmonised container pins lightness, the raw palette's lightness
* a navy and a mid blue — paint as one swatch, and every neutral (black, the * axis is invisible on screen: two raw colours that look different — a navy and
* grays, white) paints as the same pale tint. Judging distinctness in raw * a mid blue — paint as one swatch, and every neutral (black, the grays, white)
* space, as before, left near-identical painted swatches and stranded the * paints as the same grey. Judging distinctness in raw space, as before, left
* neutrals as a run of look-alike "pinks" at the end of the grid. * near-identical painted swatches and stranded the neutrals as a run of
* look-alike tints at the end of the grid.
* *
* Three steps, all in painted space: * Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key * 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a * wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together. * hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out * 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are * swatches — with lightness pinned, a grey source paints as plain grey, so
* then thinned to visually distinct colours: most vivid first, a colour is * "has no hue left" is simply zero chroma — and are then thinned to visually
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every * distinct colours: most vivid first, a colour is kept only when at least
* colour already kept. Small palettes are already curated by their adapter * [MIN_DISTANCE] away in Oklab from every colour already kept. Small
* and pass through whole. * palettes are already curated by their adapter and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue — * 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable * with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends. * seam lands in dead space and no hue family is torn across both ends.
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
*/ */
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> { fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key } val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) } .distinctBy { paintedArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) } .map { it to oklchOf(paintedArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) { val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted painted
} else { } else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR }) thin(painted.filter { (_, painted) -> painted.chroma > 0f })
} }
return orderAroundWheel(kept).map { (option, _) -> option } return orderAroundWheel(kept).map { (option, _) -> option }
} }
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
* instead of mid-family. Saturation breaks ties, vivid first. * instead of mid-family. Saturation breaks ties, vivid first.
*/ */
private fun orderAroundWheel( private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>, swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Lab>> { ): List<Pair<EventColorOption, Oklch>> {
if (swatches.size < 2) return swatches if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith( val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }), compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
) )
// Split the wheel after the largest empty arc between neighbouring hues; // Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the // the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void. // familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) { for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) { if (gap > widestGap) {
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
/** Greedy max-distance filter: vivid colours stake out clusters first. */ /** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin( private fun thin(
swatches: List<Pair<EventColorOption, Lab>>, swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Lab>> { ): List<Pair<EventColorOption, Oklch>> {
val byVividness = swatches val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key }) .sortedWith(
val kept = mutableListOf<Pair<EventColorOption, Lab>>() compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
.thenBy { it.first.key },
)
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
for (candidate in byVividness) { for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
} }
return kept return kept
} }
/** /** The colour the picker paints for [argb]; the light theme stands in as the
* The softening the colour picker paints over every swatch: keep the hue, scale * reference, since a harmonised container differs only in lightness by theme
* and clamp saturation into a gentle band, and pin value to a constant so * and curation compares hue and chroma. */
* nothing screams and everything reads on the surface. Value is fixed here so private fun paintedArgb(argb: Int): Int =
* curation is theme-independent — only hue and saturation distinguish painted eventTone(argb, dark = false, harmonise = true).container
* swatches.
*
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
* rather than theme-picked. Curation must reason about the colour the picker
* paints, so the two shapings have to agree: if floret's saturation band or
* curve changes, update this in step.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */ /** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36 private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/** /**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed- * Minimum Oklab distance between surviving painted swatches. Painted colours all
* out tint — the neutrals and near-whites the saturation clamp muddies — so it * share one lightness, so this is really a hue/chroma separation — far enough
* is dropped rather than shown as pale filler. * apart that two swatches never read as the same colour in the grid.
*/ */
private const val PASTEL_CHROMA_FLOOR = 22.0 private const val MIN_DISTANCE = 0.025f
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain.color
/**
* The colours one calendar's identity resolves to in one theme.
*
* The app paints a calendar's colour in two structurally different places, and
* they want opposite things:
*
* - a **[container]** sits behind text (week and day blocks, month bars, widget
* rows, picker swatches), so it has to contrast with its own ink;
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
* search stripes, calendar icon tints, the detail header), so it has to
* contrast with the *surface* instead.
*
* Painting both from one colour is what made this hard to get right: deep enough
* to carry white text is too dark to see as a dot on a dark surface, and light
* enough to show up there is too pale to carry white text. Splitting them lets
* each be pinned to the lightness its job needs, while hue and chroma — the parts
* that actually say *which calendar this is* — stay shared, so the two roles
* still read as the same colour.
*/
data class EventTone(
val container: Int,
val onContainer: Int,
val accent: Int,
)
/**
* Resolve [rawArgb] for the current theme.
*
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
* role, which is what makes the ink predictable: every container lands at the
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
* it off the provider's colour is painted verbatim — the sync source's own look,
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
* nothing constrains what the provider sends.
*/
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
val opaque = rawArgb or 0xFF000000.toInt()
val raw = oklchOf(opaque)
if (!harmonise) {
return EventTone(
container = opaque,
onContainer = inkFor(raw.lightness),
accent = opaque,
)
}
// A near-grey source has no hue worth keeping, so it stays grey rather than
// being pushed to an arbitrary one; everything else is held inside a band
// that keeps calendars apart without going neon.
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
// Two poles rather than one. Forcing every hue deep is what turned the warm,
// naturally light ones muddy — a dark orange is brown and a dark yellow is
// olive, which is a fact about those hues, not a tuning miss. So a colour is
// sent to whichever pole it already sits nearer: most land deep and carry
// white ink, while genuinely light sources (yellows, creams, pale tints)
// stay light and carry dark ink, keeping the character that made them
// recognisable. Either way the colour is pulled clear of the middle, where
// neither ink reads well — which was the original fault.
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
val containerLightness = when {
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
dark -> CONTAINER_LIGHTNESS_DARK
else -> CONTAINER_LIGHTNESS_LIGHT
}
// The accent takes no pole: it is a mark on the app's surface, so it has to
// contrast with that surface whichever way its container went.
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
return EventTone(
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
onContainer = inkFor(containerLightness),
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
)
}
/**
* White or black ink for a background of the given perceptual [lightness].
*
* Derived rather than hardcoded so it stays right for raw provider colours,
* where lightness is whatever the sync source sent. For harmonised containers it
* is constant by construction — that is the point of pinning the lightness.
*/
fun inkFor(lightness: Float): Int =
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
/**
* Lightness at which white ink overtakes black. Sits above the midpoint because
* lightness is perceptual: a colour has to be distinctly light before black wins.
*/
const val INK_FLIP_LIGHTNESS = 0.62f
/**
* Raw lightness at or above which a colour keeps its light character instead of
* being pushed deep. Set high enough that oranges and warm reds still go deep —
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
* so only the genuinely pale sources take the light pole.
*/
const val LIGHT_POLE_THRESHOLD = 0.72f
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
/** Same in dark mode, a shade lighter so a block separates from the surface. */
const val CONTAINER_LIGHTNESS_DARK = 0.48f
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
/** Same in dark mode, held down a little so a pale block doesn't glare. */
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
/** Accent lightness: dark enough to read as a mark on a pale surface. */
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
/** Same against a dark surface, where the mark has to be the light one. */
const val ACCENT_LIGHTNESS_DARK = 0.78f
/** Below this chroma a colour counts as grey and keeps no hue. */
const val GREY_CHROMA = 0.02f
/** Chroma band for harmonised colours: distinct, but never electric. */
const val MIN_CHROMA = 0.07f
const val MAX_CHROMA = 0.16f

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.domain.color
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
* (colourfulness) and [hue] in degrees.
*
* The point of using it over HSV is that its lightness axis matches what the eye
* calls brightness. HSV's "value" does not: at a single pinned value the hues
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
* pinning value produced fills that needed different ink per hue. Pin Oklch
* lightness instead and every hue lands at the same apparent brightness, so one
* ink serves all of them.
*
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
* the sRGB limit.
*/
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
/**
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
* saturated blues).
*/
fun distanceTo(other: Oklch): Float {
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
val dl = (lightness - other.lightness).toDouble()
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
}
}
/** Read [argb]'s opaque colour as Oklch. */
fun oklchOf(argb: Int): Oklch {
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
val b = toLinear((argb and 0xFF) / 255.0)
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
}
/**
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
* and gives up chroma until it does. Holding lightness is what matters here —
* it's the axis the contrast guarantees rest on.
*/
fun Oklch.toArgb(): Int {
val fitted = if (inSrgb(lightness, chroma, hue)) {
chroma
} else {
var low = 0f
var high = chroma
repeat(GAMUT_STEPS) {
val mid = (low + high) / 2f
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
}
low
}
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
return 0xFF shl 24 or
(channel(r) shl 16) or
(channel(g) shl 8) or
channel(b)
}
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
val a = chroma * cosDeg(hue)
val b = chroma * sinDeg(hue)
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
return Triple(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
)
}
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
}
private fun channel(linear: Double): Int =
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
private fun toLinear(c: Double): Double =
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
private fun toSrgb(c: Double): Double =
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
private const val GAMUT_STEPS = 24
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
private const val GAMUT_EPSILON = 1e-4

View File

@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
@@ -137,7 +138,7 @@ internal fun AgendaEventRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(eventFill(event.color, dark, soften)), .background(eventAccent(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,

View File

@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
@@ -442,7 +443,7 @@ private fun CalendarEditor(
) )
} }
} }
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) { EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField( InlineTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },

View File

@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon( Icon(
Icons.Filled.CalendarMonth, Icons.Filled.CalendarMonth,
contentDescription = null, contentDescription = null,
tint = eventFill(color, dark, soften), tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
} }

View File

@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.domain.color.inkFor
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/** /**
* Whether calendar/event colours are softened toward theme-fitting pastels * Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
* before display (issue #36). Provided app-wide from the "soften colours" * per role — before display (issue #36). Provided app-wide from the setting; the
* setting; the default `true` keeps the historical look. When off, the raw * default `true` is the app's own look. When off, the raw provider colour is
* provider colour is painted verbatim matching the sync source (DAVx5/CalDAV) * painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
* and other calendar apps. Widgets live outside this composition and read the * apps. Widgets live outside this composition and read the preference directly,
* preference directly, then pass the flag to [eventFill] / [eventInk]. * then pass the flag through.
*/ */
val LocalSoftenColors = staticCompositionLocalOf { true } val LocalSoftenColors = staticCompositionLocalOf { true }
/** /**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour * Fill for a surface that carries text on it — a week/day block, a month bar, a
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since * widget row, a picker swatch. Pair it with [eventInk].
* pastelize also returns an opaque colour).
*/ */
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color = fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt()) Color(eventTone(rawArgb, dark, soften).container)
/** /**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]: * Colour for a mark on an ordinary app surface — a day dot, an agenda or search
* white on a dark fill, near-black on a light one (issue #21). Applies to both * stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
* softened and raw fills — a saturated hue (deep blue, purple, red) is * pinned to contrast with the surface instead of with ink.
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
*/ */
fun eventInk(fill: Color, alpha: Float = 0.8f): Color { fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER Color(eventTone(rawArgb, dark, soften).accent)
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
/** /**
* Relative-luminance crossover where white text starts to out-contrast black. * Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives *
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`. * Takes the fill rather than the raw colour so it stays correct for the raw
* (un-harmonised) path too, where the provider decides the lightness.
*/ */
private const val INK_LUMINANCE_CROSSOVER = 0.179f fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
/**
* Ink alpha for a block's secondary line (the time under the title). Held above
* a fainter value because harmonised containers are deep: the small time text
* needs to stay clear of the 4.5:1 WCAG asks of body text.
*/
const val SECONDARY_INK_ALPHA = 0.8f
private fun Color.toArgbInt(): Int =
(0xFF shl 24) or
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
(blue * 255f).toInt().coerceIn(0, 255)

View File

@@ -89,6 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -670,7 +671,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.6f), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
) )
} }
} }

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val locale = currentDetailLocale() val locale = currentDetailLocale()
val accent = eventFill(instance.color, dark, LocalSoftenColors.current) val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column( Column(
modifier = modifier modifier = modifier

View File

@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -563,7 +564,7 @@ private fun EventEditContent(
// The accent ties the form to the detail screen's design language: the // The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour. // bar under the title takes the target calendar's colour.
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) } val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp

View File

@@ -110,6 +110,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -1473,7 +1474,7 @@ private fun SplitDots(
.morphBounds(MonthMorphKey.Event(date, event.instanceId)) .morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE) .size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f) .alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(event.color, dark, soften), CircleShape), .background(eventAccent(event.color, dark, soften), CircleShape),
) )
} }
if (hidden.isNotEmpty()) { if (hidden.isNotEmpty()) {
@@ -2098,7 +2099,7 @@ private fun OverflowDots(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(6.dp)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(argb, dark, soften), CircleShape), .background(eventAccent(argb, dark, soften), CircleShape),
) )
} }
val extra = total - dots.size val extra = total - dots.size

View File

@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -208,7 +209,7 @@ private fun SearchResultRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(eventFill(event.color, dark, soften)), .background(eventAccent(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -835,7 +836,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.6f), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
) )
} }
} }

View File

@@ -58,6 +58,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -382,7 +383,7 @@ private fun EventRow(
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and // Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone. // dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = eventFill(event.color, dark, soften).let { val stripeColor = eventAccent(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it if (dimmed) it.copy(alpha = EventDimAlpha) else it
} }
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface

View File

@@ -330,8 +330,8 @@
<string name="settings_default_view">Default view</string> <string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string> <string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_soften_colors">Soften calendar colours</string> <string name="settings_soften_colors">Harmonise calendar colours</string>
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string> <string name="settings_soften_colors_summary">Keep each calendar\'s hue but even out its brightness, so every event stays legible and the colours sit together. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_font_headings">Headings font</string> <string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string> <string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string> <string name="settings_font_system">System default</string>

View File

@@ -74,10 +74,10 @@ class EventColorPaletteTest {
} }
@Test @Test
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() { fun `neutrals collapse instead of forming a run of look-alikes`() {
// Black and every gray paint as the same pale swatch (the picker pins // Neutrals lose their hue entirely when painted, so they collapse onto
// lightness and floors saturation), so only one survives — no stranded // the one or two greys the poles offer — a dark and a pale — instead of
// run of look-alike "pinks" at the end of the grid (#22). // the old stranded run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf( val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()), EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()), EventColorOption("gray", 0xFF808080.toInt()),
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
EventColorOption("red", 0xFFFF0000.toInt()), EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key } ).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black assertThat(curated).doesNotContain("gray") // paints as black's grey
assertThat(curated).containsAtLeast("black", "red", "blue") assertThat(curated).containsAtLeast("black", "red", "blue")
// darkgray is light enough to keep a pale character, so the three
// neutrals still fold down to at most two swatches, never a run.
assertThat(curated.count { it in listOf("black", "gray", "darkgray") }).isAtMost(2)
} }
@Test @Test

View File

@@ -0,0 +1,159 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventToneTest {
/** Hues at full chroma, standing in for the provider colours in the wild. */
private val hues = (0 until 360 step 15).map { Oklch(0.65f, 0.2f, it.toFloat()).toArgb() }
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
private fun contrast(a: Int, b: Int): Double {
val hi = maxOf(relativeLuminance(a), relativeLuminance(b))
val lo = minOf(relativeLuminance(a), relativeLuminance(b))
return (hi + 0.05) / (lo + 0.05)
}
/** The other ink, for asserting the chosen one is the better of the two. */
private fun flip(ink: Int) =
if (ink == 0xFFFFFFFF.toInt()) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
/** [ink] over [fill] at [alpha], the way a title actually renders. */
private fun composite(ink: Int, fill: Int, alpha: Double): Int {
fun mix(shift: Int): Int {
val i = (ink shr shift) and 0xFF
val f = (fill shr shift) and 0xFF
return (alpha * i + (1 - alpha) * f).toInt().coerceIn(0, 255)
}
return (0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
}
@Test
fun `hues of one lightness all get the same ink`() {
// The bug this replaced: at a pinned HSV value the hues straddled the ink
// crossover, so red took white while orange beside it took black. Ink may
// still differ by *pole*, but never by hue within a pole.
for (dark in listOf(false, true)) {
val inks = hues.map { eventTone(it, dark, harmonise = true).onContainer }.toSet()
assertThat(inks).containsExactly(0xFFFFFFFF.toInt())
}
}
@Test
fun `naturally light colours keep their character instead of turning muddy`() {
// Forcing every hue deep made a yellow into olive and a cream into brown.
// Pale sources stay pale — and then take dark ink.
for (pale in listOf(0xFFF6BF26, 0xFFFFF8DC, 0xFFF0E68C, 0xFFFFFFFF)) {
val tone = eventTone(pale.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isGreaterThan(0.8f)
assertThat(tone.onContainer).isEqualTo(0xFF000000.toInt())
}
}
@Test
fun `warm mid colours still go deep, so orange reads as orange and not as cream`() {
// The threshold has to sit above orange: a burnt orange still says
// "orange", which is why it belongs on the deep pole with white ink.
// Note the ceiling this implies: a *bright* orange (#FF8C00, #FFA500) is
// genuinely a light colour and takes the light pole instead — forcing it
// deep is exactly what produced brown.
for (warm in listOf(0xFFF4511E, 0xFFE67C73, 0xFFD50000, 0xFFE65100)) {
val tone = eventTone(warm.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isLessThan(0.6f)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
}
@Test
fun `title ink clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.85)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
assertThat(contrast(tone.container, ink))
.isGreaterThan(contrast(tone.container, composite(flip(tone.onContainer), tone.container, 0.85)))
}
}
}
@Test
fun `the secondary line also clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.8)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
}
}
}
@Test
fun `an accent stays visible against its own theme's surface`() {
// Accents carry no text, so the bar is WCAG's 3:1 for non-text contrast.
val lightSurface = 0xFFFEF7FF.toInt()
val darkSurface = 0xFF141218.toInt()
for ((dark, surface) in listOf(false to lightSurface, true to darkSurface)) {
for (hue in hues) {
val accent = eventTone(hue, dark, harmonise = true).accent
assertThat(contrast(accent, surface)).isGreaterThan(3.0)
}
}
}
@Test
fun `container and accent keep the same hue, so one calendar reads as one colour`() {
for (hue in hues) {
val tone = eventTone(hue, dark = false, harmonise = true)
assertThat(oklchOf(tone.accent).hue).isWithin(2f).of(oklchOf(tone.container).hue)
}
}
@Test
fun `orange no longer gets dark ink`() {
// The reported case: a plain orange calendar used to fall just above the
// crossover and take black text while its neighbours took white.
val tone = eventTone(0xFFF4511E.toInt(), dark = false, harmonise = true)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
@Test
fun `different hues stay distinguishable`() {
val containers = hues.map { eventTone(it, dark = false, harmonise = true).container }
assertThat(containers.toSet()).hasSize(hues.size)
}
@Test
fun `a grey source stays grey rather than gaining an invented hue`() {
for (grey in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF, 0xFF9E9E9E)) {
val tone = eventTone(grey.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).chroma).isLessThan(0.01f)
}
}
@Test
fun `raw colours are painted verbatim when harmonising is off`() {
val raw = 0xFFF4511E.toInt()
val tone = eventTone(raw, dark = false, harmonise = false)
assertThat(tone.container).isEqualTo(raw)
assertThat(tone.accent).isEqualTo(raw)
}
@Test
fun `raw mode still picks a readable ink per colour`() {
// Nothing constrains what a provider sends, so the ink cannot be constant
// on this path the way it is for harmonised containers.
assertThat(eventTone(0xFF101010.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFFFFFFFF.toInt())
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFF000000.toInt())
}
}

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class OklchTest {
@Test
fun `matches Oklab's published anchors`() {
// Guards the conversion matrices against a transcription slip: these are
// Ottosson's own reference values for the space.
assertThat(oklchOf(0xFFFFFFFF.toInt()).lightness).isWithin(0.001f).of(1f)
assertThat(oklchOf(0xFF000000.toInt()).lightness).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFFFF0000.toInt()).lightness).isWithin(0.001f).of(0.6280f)
}
@Test
fun `white and black have no chroma`() {
assertThat(oklchOf(0xFFFFFFFF.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF000000.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF808080.toInt()).chroma).isWithin(0.001f).of(0f)
}
@Test
fun `converting to sRGB and back round-trips`() {
for (argb in listOf(0xFFF4511E, 0xFF039BE5, 0xFF0B8043, 0xFF8E24AA, 0xFFFBD75B)) {
val original = oklchOf(argb.toInt())
val roundTripped = oklchOf(original.toArgb())
assertThat(roundTripped.lightness).isWithin(0.01f).of(original.lightness)
assertThat(roundTripped.chroma).isWithin(0.01f).of(original.chroma)
assertThat(roundTripped.hue).isWithin(1f).of(original.hue)
}
}
@Test
fun `an out-of-gamut request keeps its lightness and gives up chroma`() {
// Most of the Oklch cylinder is outside sRGB. Lightness is the axis the
// contrast guarantees rest on, so it is the one that must survive.
val requested = Oklch(0.45f, 0.35f, 150f)
val actual = oklchOf(requested.toArgb())
assertThat(actual.lightness).isWithin(0.02f).of(requested.lightness)
assertThat(actual.chroma).isLessThan(requested.chroma)
}
@Test
fun `pinning lightness holds luminance far tighter than pinning HSV value did`() {
// The whole reason for the space swap. At a pinned HSV value the hues
// spread across relative luminance 0.10..0.45; pinned Oklch lightness
// must keep them in a narrow band, or one ink cannot serve them all.
val luminances = (0 until 360 step 15).map { hue ->
val argb = Oklch(0.45f, 0.16f, hue.toFloat()).toArgb()
relativeLuminance(argb)
}
assertThat(luminances.max() / luminances.min()).isLessThan(2.5)
}
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
}