Skip to main content

max / makeover

Add color quantization for low-color terminals A theme authored in 24-bit color has to survive terminals that do not have it. The Linux console has sixteen colors and reports eight, and approximates anything else it is sent, so a surface and the border drawn on it can arrive as one flat area. Alloy's console frame disappeared exactly this way: the block was drawn every frame, in a color the console could not tell from the page behind it. quantize answers for one color, in OKLab, for the same reason mix interpolates there. quantize_against answers for a pair, because the failure is a pairwise one: it quantizes the background first, since what a border must differ from is the entry the terminal will really paint, then takes the nearest entry to the foreground that clears 3:1 against it.
Author: Max Johnson <me@maxj.phd> · 2026-07-21 21:52 UTC
Commit: 2dee801b60da1b3bd492364fe821cce2fe95ae68
Parent: 53a0e53
2 files changed, +198 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "1.0.0"
3 + version = "1.1.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M src/lib.rs +197
@@ -198,6 +198,126 @@ pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
198 198 }
199 199
200 200 // ============================================================================
201 + // Low-color terminals
202 + // ============================================================================
203 +
204 + /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
205 + /// arrangement the Linux console and most emulators start from.
206 + ///
207 + /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
208 + /// rather than white, which is the entry a themed surface usually lands on, and
209 + /// index 15 is the true white.
210 + ///
211 + /// Emulators let the user repaint all sixteen, so this is the standard
212 + /// arrangement rather than a promise about any one terminal. The Linux console
213 + /// keeps it, which is the case that matters: a console app cannot fall back to
214 + /// 24-bit color there.
215 + pub const ANSI_16: [Rgb; 16] = [
216 + Rgb { r: 0x00, g: 0x00, b: 0x00 },
217 + Rgb { r: 0xaa, g: 0x00, b: 0x00 },
218 + Rgb { r: 0x00, g: 0xaa, b: 0x00 },
219 + Rgb { r: 0xaa, g: 0x55, b: 0x00 },
220 + Rgb { r: 0x00, g: 0x00, b: 0xaa },
221 + Rgb { r: 0xaa, g: 0x00, b: 0xaa },
222 + Rgb { r: 0x00, g: 0xaa, b: 0xaa },
223 + Rgb { r: 0xaa, g: 0xaa, b: 0xaa },
224 + Rgb { r: 0x55, g: 0x55, b: 0x55 },
225 + Rgb { r: 0xff, g: 0x55, b: 0x55 },
226 + Rgb { r: 0x55, g: 0xff, b: 0x55 },
227 + Rgb { r: 0xff, g: 0xff, b: 0x55 },
228 + Rgb { r: 0x55, g: 0x55, b: 0xff },
229 + Rgb { r: 0xff, g: 0x55, b: 0xff },
230 + Rgb { r: 0x55, g: 0xff, b: 0xff },
231 + Rgb { r: 0xff, g: 0xff, b: 0xff },
232 + ];
233 +
234 + /// The contrast ratio two colors must clear to read as separate areas.
235 + ///
236 + /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
237 + /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
238 + /// text can ask for more by checking [`wcag_contrast`] itself.
239 + pub const DISTINCT: f32 = 3.0;
240 +
241 + /// Perceptual distance between two colors, for choosing the closest of a set.
242 + fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
243 + let (x, y) = (a.to_oklab(), b.to_oklab());
244 + ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
245 + }
246 +
247 + /// Index of the entry in `palette` that looks most like `c`.
248 + ///
249 + /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
250 + /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
251 + /// nearest match computed in it picks visibly wrong entries in the mid tones.
252 + ///
253 + /// # Panics
254 + ///
255 + /// If `palette` is empty.
256 + pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
257 + assert!(!palette.is_empty(), "a palette needs at least one color");
258 + let mut best = 0;
259 + let mut best_distance = f32::INFINITY;
260 + for (index, entry) in palette.iter().enumerate() {
261 + let distance = oklab_distance(c, *entry);
262 + if distance < best_distance {
263 + best = index;
264 + best_distance = distance;
265 + }
266 + }
267 + best
268 + }
269 +
270 + /// Index of the entry in `palette` closest to `fg` that still reads against
271 + /// `bg`.
272 + ///
273 + /// [`quantize`] answers about one color at a time, and two colors that differ
274 + /// can quantize to the same entry: a themed page and a border drawn on it are
275 + /// often a few steps apart in a 24-bit theme and land together on a 16-color
276 + /// terminal, leaving one flat area where there was a frame. Alloy's console
277 + /// showed exactly this, and it is not a contrived pairing: a light page and the
278 + /// mid-tone border derived from it both land on index 7.
279 + ///
280 + /// So the background is quantized first, because what the border must be
281 + /// distinguished from is the entry the terminal will actually paint, not the
282 + /// color the theme asked for. Then the nearest entry to `fg` clearing
283 + /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
284 + /// furthest does: at that point the palette cannot honor the design, and the
285 + /// most legible approximation beats the closest invisible one.
286 + ///
287 + /// Only for colors whose whole job is to be told apart from their background.
288 + /// Applied to every token it would push a deliberately quiet one until it
289 + /// shouted.
290 + ///
291 + /// # Panics
292 + ///
293 + /// If `palette` is empty.
294 + pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
295 + assert!(!palette.is_empty(), "a palette needs at least one color");
296 + let shown = palette[quantize(bg, palette)];
297 +
298 + let mut order: Vec<usize> = (0..palette.len()).collect();
299 + order.sort_by(|a, b| {
300 + oklab_distance(fg, palette[*a])
301 + .total_cmp(&oklab_distance(fg, palette[*b]))
302 + });
303 +
304 + order
305 + .iter()
306 + .copied()
307 + .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
308 + .unwrap_or_else(|| {
309 + order
310 + .iter()
311 + .copied()
312 + .max_by(|a, b| {
313 + wcag_contrast(palette[*a], shown)
314 + .total_cmp(&wcag_contrast(palette[*b], shown))
315 + })
316 + .expect("the palette is not empty")
317 + })
318 + }
319 +
320 + // ============================================================================
201 321 // Intent resolution
202 322 // ============================================================================
203 323
@@ -664,6 +784,83 @@ mod tests {
664 784 assert!(validate_theme_id("theme.toml").is_err());
665 785 }
666 786
787 + // ---- low-color terminals ----
788 +
789 + #[test]
790 + fn the_ansi_palette_is_sixteen_distinct_colors() {
791 + let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
792 + seen.sort_unstable();
793 + seen.dedup();
794 + assert_eq!(seen.len(), 16);
795 + }
796 +
797 + #[test]
798 + fn quantize_picks_the_obvious_entry() {
799 + let black = Rgb { r: 0, g: 0, b: 0 };
800 + let white = Rgb { r: 255, g: 255, b: 255 };
801 + assert_eq!(quantize(black, &ANSI_16), 0);
802 + assert_eq!(quantize(white, &ANSI_16), 15);
803 + }
804 +
805 + // Nearest-entry quantization is per-color, so two colors a theme keeps
806 + // apart can arrive as one. These two are both closest to the palette's
807 + // light gray, and a border drawn in one on a page painted the other is not
808 + // drawn at all.
809 + #[test]
810 + fn two_colors_can_quantize_to_one_entry() {
811 + let page = Rgb::from_hex("#a8a8a8").unwrap();
812 + let border = Rgb::from_hex("#b4b4b4").unwrap();
813 +
814 + assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
815 + assert!(quantize_against(border, page, &ANSI_16) != quantize(page, &ANSI_16));
816 + }
817 +
818 + #[test]
819 + fn quantize_against_keeps_the_border_off_the_page() {
820 + let page = Rgb::from_hex("#e4ded6").unwrap();
821 + let border = Rgb::from_hex("#7f786d").unwrap();
822 +
823 + let shown_page = ANSI_16[quantize(page, &ANSI_16)];
824 + let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
825 +
826 + assert!(
827 + wcag_contrast(shown_border, shown_page) >= DISTINCT,
828 + "border {} on page {} is {:.2}:1",
829 + shown_border.to_hex(),
830 + shown_page.to_hex(),
831 + wcag_contrast(shown_border, shown_page)
832 + );
833 + }
834 +
835 + // A color that already reads against its background is left where it is,
836 + // so this can be applied without redesigning what already worked.
837 + #[test]
838 + fn quantize_against_leaves_a_readable_color_alone() {
839 + let page = Rgb::from_hex("#e4ded6").unwrap();
840 + let text = Rgb::from_hex("#1a1816").unwrap();
841 +
842 + assert_eq!(
843 + quantize_against(text, page, &ANSI_16),
844 + quantize(text, &ANSI_16)
845 + );
846 + }
847 +
848 + // With nothing in the palette to satisfy the request, the most legible
849 + // entry is the answer. Returning the nearest one would return the
850 + // background itself, which is the failure this function exists to avoid.
851 + #[test]
852 + fn an_impossible_palette_gets_the_most_legible_entry() {
853 + let page = Rgb::from_hex("#ffffff").unwrap();
854 + let border = Rgb::from_hex("#fefefe").unwrap();
855 + let palette = [
856 + Rgb::from_hex("#ffffff").unwrap(),
857 + Rgb::from_hex("#fdfdfd").unwrap(),
858 + ];
859 +
860 + let chosen = palette[quantize_against(border, page, &palette)];
861 + assert_eq!(chosen.to_hex(), "#fdfdfd");
862 + }
863 +
667 864 // ---- meta ----
668 865
669 866 #[test]