diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41ac6647..f61bf57c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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)" diff --git a/CHANGELOG.md b/CHANGELOG.md index a2471f70..3b522402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/README.md b/README.md index aaa734a7..0c86f52d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..650d158c --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt b/color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt index 477281b7..03b5f641 100644 --- a/color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt +++ b/color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt @@ -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 @@ -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() @@ -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. diff --git a/color/src/commonTest/kotlin/codes/side/color/GamutMappingTest.kt b/color/src/commonTest/kotlin/codes/side/color/GamutMappingTest.kt index 6fe3cedc..55da0845 100644 --- a/color/src/commonTest/kotlin/codes/side/color/GamutMappingTest.kt +++ b/color/src/commonTest/kotlin/codes/side/color/GamutMappingTest.kt @@ -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( diff --git a/docs/_config.yml b/docs/_config.yml index 2bd6e778..604dc74c 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,7 +2,3 @@ plugins: - jemoji theme: jekyll-theme-minimal - -# Internal design docs live in the repo, not on the site. -exclude: - - superpowers diff --git a/docs/index.md b/docs/index.md index 8824a5a0..3f619c3d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 @@ -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` | diff --git a/tools/consumer/build.gradle.kts b/tools/consumer/build.gradle.kts new file mode 100644 index 00000000..31a472ba --- /dev/null +++ b/tools/consumer/build.gradle.kts @@ -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") + } + } +} diff --git a/tools/consumer/gradle.properties b/tools/consumer/gradle.properties new file mode 100644 index 00000000..b7e914fb --- /dev/null +++ b/tools/consumer/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/tools/consumer/settings.gradle.kts b/tools/consumer/settings.gradle.kts new file mode 100644 index 00000000..89b590a7 --- /dev/null +++ b/tools/consumer/settings.gradle.kts @@ -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" diff --git a/tools/consumer/src/commonMain/kotlin/consumer/Consumer.kt b/tools/consumer/src/commonMain/kotlin/consumer/Consumer.kt new file mode 100644 index 00000000..a5b76491 --- /dev/null +++ b/tools/consumer/src/commonMain/kotlin/consumer/Consumer.kt @@ -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()