From 71bb474181750e25ebf2d45568905f2665e2ee85 Mon Sep 17 00:00:00 2001 From: Illia Achour Date: Tue, 29 Sep 2026 15:40:39 -0400 Subject: [PATCH 1/4] Map an overflowing component into the gamut rather than throw Beyond about 1e102 the cube from Oklab into LMS overflows, infinities of both signs meet as NaN, and clamping passed the NaN through, so toGamut with Clip threw IllegalArgumentException for a color ColorValue had accepted. A NaN channel now clamps to 0, as the other methods already reach by searching. The KDoc states what remains: past about 1e102 the result is unrelated to the color and from about 1e150 black, and near black ClosedForm finds the edge less precisely than the color holds. --- .../kotlin/codes/side/color/GamutMapping.kt | 11 ++++++++++- .../kotlin/codes/side/color/GamutMappingTest.kt | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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( From 034fbf5d31e90c9f6a6ba27446569c7adb41dd01 Mon Sep 17 00:00:00 2001 From: Illia Achour Date: Tue, 29 Sep 2026 15:40:42 -0400 Subject: [PATCH 2/4] Say 1.x ends at 1.2.0, and list 2.0's dependencies 1.2.1 was merged but never published, yet CHANGELOG and the migration table named it as where codes.side:colorpicker stops. The 2.0 section also had no Dependencies entry, where 1.2.1's had one; it lists what each artifact adds. README gains the two limits of gamut mapping's arithmetic. --- CHANGELOG.md | 15 +++++++++++++-- README.md | 8 +++++++- docs/index.md | 8 +++++++- 3 files changed, 27 insertions(+), 4 deletions(-) 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/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` | From 3601fd9248a0ccfb192fe383da1bb2413939dcf0 Mon Sep 17 00:00:00 2001 From: Illia Achour Date: Tue, 29 Sep 2026 15:40:44 -0400 Subject: [PATCH 3/4] Add a security policy, and drop a site exclude that matches nothing A vulnerability had no reporting path but a public issue; SECURITY.md gives the address CODE_OF_CONDUCT already publishes. The Pages config excluded a folder that is not in the repository. --- SECURITY.md | 10 ++++++++++ docs/_config.yml | 4 ---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 SECURITY.md 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/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 From 11b7eb923193cb2da3eb98033ab73a360f76c6f9 Mon Sep 17 00:00:00 2001 From: Illia Achour Date: Tue, 29 Sep 2026 15:40:47 -0400 Subject: [PATCH 4/4] Link an app against the published artifacts in CI CI checked that each module's POMs exist after publishToMavenLocal, which says nothing of whether an app can use them, and its iOS framework is static, which defers symbol resolution to the app. tools/consumer is a build of its own that depends on colorpicker-material3 alone and links a dynamic iOS framework against the published artifacts. With -PpickerRepo=snapshots it reads the Central snapshot repository instead of Maven Local. --- .github/workflows/build.yml | 5 ++ tools/consumer/build.gradle.kts | 24 +++++++++ tools/consumer/gradle.properties | 2 + tools/consumer/settings.gradle.kts | 33 ++++++++++++ .../commonMain/kotlin/consumer/Consumer.kt | 53 +++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 tools/consumer/build.gradle.kts create mode 100644 tools/consumer/gradle.properties create mode 100644 tools/consumer/settings.gradle.kts create mode 100644 tools/consumer/src/commonMain/kotlin/consumer/Consumer.kt 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/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()