Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,8 @@ jobs:
fi
done
done
# A POM that exists says nothing of whether an app can use it. tools/consumer is a build of its own that
# depends on colorpicker-material3 alone, as an app does, and links a dynamic iOS framework against what
# was just published, which resolves every native symbol the libraries need.
- name: Link an app against the published artifacts
run: ./gradlew -p tools/consumer compileKotlinJvm linkDebugFrameworkIosSimulatorArm64 -PpickerVersion="$(sed -n 's/^VERSION_NAME=//p' gradle.properties)"
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### Breaking changes

- **`codes.side:colorpicker` stops at 1.2.1.** 2.0 is `codes.side:colorpicker-material3`, which brings `codes.side:colorpicker-foundation`: the state, the `Basic*` components and the strings, with no Material dependency, for an app on another design system.
- **`codes.side:colorpicker` stops at 1.2.0.** 2.0 is `codes.side:colorpicker-material3`, which brings `codes.side:colorpicker-foundation`: the state, the `Basic*` components and the strings, with no Material dependency, for an app on another design system.
- **The Material components, `ColorPickerTheme`, `ColorPickerDefaults` and the theme classes are in `codes.side.colorpicker.material3`,** one package as `androidx.compose.material3` is, where 1.x had `codes.side.colorpicker.ui` and `codes.side.colorpicker.theme`.
- **A color is a `ColorValue`.** The `model`, `conversion` and `util` packages are gone: `HslColor`, `RgbColor`, `CmykColor`, `LabColor`, `OklabColor`, `OklchColor`, `OkhslColor`, `OkhsvColor`, `PickerColor`, their conversions and hex functions, and `randomHslColor`. A `ColorValue` is a color in one of fifteen spaces, in CSS's units, so HSL's saturation and lightness run 0–100 rather than 0–1.
- **`ColorPickerState` holds a `ColorValue`.** It is built from a `ColorValue` or a Compose `Color`, with no default. `state.value`, `state[channel]` and fifteen typed views (`state.hsl`, `state.oklch`, …) replace the eight typed getters, `pickerColor` and `argbInt`, and `state[channel] = x` and `state.value = x` replace the thirty `update…` functions. Writing NaN or a value outside a channel's limit throws where 1.x ignored or clamped it.
Expand Down Expand Up @@ -65,7 +65,18 @@
- **`ColorPickerDialogDefaults` and `ColorPickerDialogScope`,** the dialog's default spaces, title, header, switcher and buttons, and the scope its slots read the dialog's state from and confirm or dismiss it through.
- **`ColorPickerDialogState`, `BasicColorPickerDialogContent` and `BasicColorComparison`,** the dialog without Material: its state, saved with `ColorPickerDialogState.Saver`; its body, which puts the plane beside the sliders in a window too short to stack them; and the split swatch.

## 1.2.1
### Dependencies

Built with Kotlin 2.4.20 and Compose Multiplatform 1.12.1, up from 1.2.0's 2.4.10 and 1.12.0. Each artifact brings the one above it in this list, and adds:

- **`codes.side:color`:** Compose's `runtime-annotation` alone, for its stability annotations.
- **`codes.side:color-compose`:** Compose UI graphics.
- **`codes.side:colorpicker-foundation`:** Compose runtime, foundation and UI, and kotlinx-coroutines 1.11.0.
- **`codes.side:colorpicker-material3`:** Material 3 1.9.0, at runtime only, so it stays off an app's compile classpath.

## 1.2.1 (never published)

Merged but never released, so `codes.side:colorpicker` stops at 1.2.0. Its fixes are in 2.0.0.

### Fixed

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ need either: their saturation is measured against sRGB, so they are inside it by
except in a sliver just past pure blue (264.05–264.21°), where they stray by under 0.001 of a
linear channel.

Two limits of the arithmetic. Below an OkLCh lightness of 0.001, `ChromaReduction`'s default solver
finds the edge less precisely than the color itself, so lightness and hue come back changed, though
the color stays far darker than one 8-bit step; `ChromaReduction(EdgeSolver.Iterative)` keeps them.
And a component beyond about 1e102 overflows the conversion: it maps to a color of the gamut, but not
one related to it, and from about 1e150 to black.

### CSS and hex

```kotlin
Expand Down Expand Up @@ -840,7 +846,7 @@ take a channel. The color types live in `codes.side.color`, which `colorpicker-m

| 1.x | 2.0 | Note |
|---------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------------------------------------------|
| `implementation("codes.side:colorpicker:1.2.1")` | `implementation("codes.side:colorpicker-material3:2.0.0")` | `codes.side:colorpicker` stops at 1.2.1 |
| `implementation("codes.side:colorpicker:1.2.0")` | `implementation("codes.side:colorpicker-material3:2.0.0")` | `codes.side:colorpicker` stops at 1.2.0 |
| `codes.side.colorpicker.ui.*`, `codes.side.colorpicker.theme.*` | `codes.side.colorpicker.material3.*` | |
| `HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f)` | `Hsl(200.0, 80.0, 50.0)` | CSS's units: HSL's S and L are 0–100 |
| `RgbColor`, `CmykColor`, `LabColor`, `OkhslColor`, `OkhsvColor`, `OklabColor`, `OklchColor` | `Srgb(…)`, `Cmyk(…)`, `Lab(…)`, `Okhsl(…)`, `Okhsv(…)`, `Oklab(…)`, `Oklch(…)` | each a `ColorValue` |
Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Security

## Reporting a vulnerability

Email side.codes@gmail.com rather than opening a public issue, with the affected version and what an
attacker could do, and keep it private until a fix is released.

## Supported versions

Fixes go into the latest release.
11 changes: 10 additions & 1 deletion color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ public abstract class GamutMapping internal constructor() {
* Exact chroma reduction at constant OkLCh lightness and hue: the most chroma the gamut holds
* there up to the color's own, found by [solver], then a clip of the last rounding.
* The method for planes, gradients and boundaries.
*
* [EdgeSolver.ClosedForm] finds the edge to within 1e-9 of linear light, which below an OkLCh
* lightness of 0.001 is more than the color itself holds, so there lightness and hue come back
* changed; the color is still far darker than one 8-bit step. [EdgeSolver.Iterative] keeps them.
*/
public class ChromaReduction(public val solver: EdgeSolver = EdgeSolver.ClosedForm) : GamutMapping() {
private val iterative = solver === EdgeSolver.Iterative
Expand Down Expand Up @@ -155,6 +159,9 @@ public fun ColorValue.isInGamut(gamut: RgbGamut, tolerance: Double = ColorRules.
* Missing components resolve as CSS resolves them for gamut mapping, through the color in OkLCh: a
* missing lightness carries into OkLCh's and counts as 0, which is black, and any other counts as 0
* in the conversion. The result has none, except a missing alpha.
*
* A component beyond about 1e102 overflows the conversion: the result is still a color of the gamut,
* but not one related to this color, and from about 1e150 it is black.
*/
public fun ColorValue.toGamut(gamut: RgbGamut, method: GamutMapping = GamutMapping.Css()): ColorValue {
val color = resolved()
Expand Down Expand Up @@ -189,8 +196,10 @@ internal fun toLinear(t: DoubleArray, l: Double, a: Double, b: Double, out: Doub

internal fun inCube(v: DoubleArray): Boolean = v[0] in 0.0..1.0 && v[1] in 0.0..1.0 && v[2] in 0.0..1.0

// NaN, from a conversion that overflowed, clamps to 0: coerceIn passes it through, and a NaN channel
// would make the mapped color invalid.
private fun clamp(v: DoubleArray) {
for (i in 0..2) v[i] = v[i].coerceIn(0.0, 1.0)
for (i in 0..2) v[i] = if (v[i].isNaN()) 0.0 else v[i].coerceIn(0.0, 1.0)
}

// SDR's ends: lightness 1 or more is white, 0 or less black. True when [l] was one of them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ class GamutMappingTest {
assertComponents(doubleArrayOf(1.0, 0.0, 0.0), DisplayP3(1.0, 0.0, 0.0).toGamut(Srgb.gamut, GamutMapping.Clip), 1e-15)
}

@Test
fun aComponentThatOverflowsTheConversionStillMapsIntoTheGamut() {
// Past about 1e102 the cube in Oklab → LMS overflows, and infinities of both signs meet as NaN.
for (method in methods) {
val mapped = Oklab(0.5, 1e103, 1e103).toGamut(Srgb.gamut, method)
assertTrue(mapped.components().all { it in 0.0..1.0 }, "$method: ${mapped.components().toList()}")
}
}

@Test
fun cssMatchesColorJs() {
val cases = listOf(
Expand Down
4 changes: 0 additions & 4 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,3 @@ plugins:
- jemoji

theme: jekyll-theme-minimal

# Internal design docs live in the repo, not on the site.
exclude:
- superpowers
8 changes: 7 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ need either: their saturation is measured against sRGB, so they are inside it by
except in a sliver just past pure blue (264.05–264.21°), where they stray by under 0.001 of a
linear channel.

Two limits of the arithmetic. Below an OkLCh lightness of 0.001, `ChromaReduction`'s default solver
finds the edge less precisely than the color itself, so lightness and hue come back changed, though
the color stays far darker than one 8-bit step; `ChromaReduction(EdgeSolver.Iterative)` keeps them.
And a component beyond about 1e102 overflows the conversion: it maps to a color of the gamut, but not
one related to it, and from about 1e150 to black.

### CSS and hex

```kotlin
Expand Down Expand Up @@ -840,7 +846,7 @@ take a channel. The color types live in `codes.side.color`, which `colorpicker-m

| 1.x | 2.0 | Note |
|---------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------------------------------------------|
| `implementation("codes.side:colorpicker:1.2.1")` | `implementation("codes.side:colorpicker-material3:2.0.0")` | `codes.side:colorpicker` stops at 1.2.1 |
| `implementation("codes.side:colorpicker:1.2.0")` | `implementation("codes.side:colorpicker-material3:2.0.0")` | `codes.side:colorpicker` stops at 1.2.0 |
| `codes.side.colorpicker.ui.*`, `codes.side.colorpicker.theme.*` | `codes.side.colorpicker.material3.*` | |
| `HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f)` | `Hsl(200.0, 80.0, 50.0)` | CSS's units: HSL's S and L are 0–100 |
| `RgbColor`, `CmykColor`, `LabColor`, `OkhslColor`, `OkhsvColor`, `OklabColor`, `OklchColor` | `Srgb(…)`, `Cmyk(…)`, `Lab(…)`, `Okhsl(…)`, `Okhsv(…)`, `Oklab(…)`, `Oklch(…)` | each a `ColorValue` |
Expand Down
24 changes: 24 additions & 0 deletions tools/consumer/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
}

val pickerVersion = providers.gradleProperty("pickerVersion").get()

kotlin {
jvm()
iosSimulatorArm64 {
// Dynamic, so linking resolves every native symbol the libraries need, where a static framework
// leaves that to the app it goes into.
binaries.framework {
baseName = "Consumer"
isStatic = false
}
}
sourceSets {
// Material 3 alone: the other three modules and every type in its signatures come through it.
commonMain.dependencies {
implementation("codes.side:colorpicker-material3:$pickerVersion")
}
}
}
2 changes: 2 additions & 0 deletions tools/consumer/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
kotlin.code.style=official
33 changes: 33 additions & 0 deletions tools/consumer/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// A build of its own that uses the libraries as an app does: from published artifacts, never from the
// project's sources. CI links it for iOS against what publishToMavenLocal has just published; with
// -PpickerRepo=snapshots it reads the Central snapshot repository instead.
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}

dependencyResolutionManagement {
repositories {
if (providers.gradleProperty("pickerRepo").orNull == "snapshots") {
maven("https://central.sonatype.com/repository/maven-snapshots/") {
content { includeGroup("codes.side") }
}
} else {
mavenLocal {
content { includeGroup("codes.side") }
}
}
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../../gradle/libs.versions.toml"))
}
}
}

rootProject.name = "consumer"
53 changes: 53 additions & 0 deletions tools/consumer/src/commonMain/kotlin/consumer/Consumer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package consumer

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import codes.side.color.ColorValue
import codes.side.color.Okhsv
import codes.side.color.Oklch
import codes.side.color.Srgb
import codes.side.color.compose.toComposeColor
import codes.side.color.parseCss
import codes.side.color.toGamut
import codes.side.colorpicker.foundation.BasicAlphaSlider
import codes.side.colorpicker.foundation.BasicChannelPlane
import codes.side.colorpicker.foundation.BasicChannelSlider
import codes.side.colorpicker.foundation.BasicColorPicker
import codes.side.colorpicker.material3.ColorPicker
import codes.side.colorpicker.material3.ColorPickerDialog
import codes.side.colorpicker.material3.ColorSwatch
import codes.side.colorpicker.state.rememberSaveableColorPickerState

/** Each published module in use, so compiling and linking this resolves every one of their artifacts. */
@Composable
fun Consumer() {
val state = rememberSaveableColorPickerState(ColorValue.parseCss("oklch(70% 0.15 140)"))
var dialogOpen by remember { mutableStateOf(false) }
ColorPicker(state)
ColorSwatch(color = state.color, modifier = Modifier)
BasicColorPicker(
state = state,
space = Okhsv,
plane = { part -> BasicChannelPlane(part.state, part.x, part.y, thumb = {}) },
channelSlider = { part -> BasicChannelSlider(part.state, part.channel, track = {}, thumb = {}) },
alphaSlider = { part -> BasicAlphaSlider(part.state, track = {}, thumb = {}) },
)
if (dialogOpen) {
ColorPickerDialog(
initialValue = state.value,
onValueSelected = { value ->
state.value = value
dialogOpen = false
},
onDismissRequest = { dialogOpen = false },
)
}
}

/** The color model and the Compose bridge, outside composition. */
fun mappedColor(): Color = Oklch(0.7, 0.3, 150.0).toGamut(Srgb.gamut).toComposeColor()
Loading