Skip to main content

max / makeover

Take the intent-to-ANSI-slot table from skelgen, 2.4.0
Author: Max Johnson <me@maxj.phd> · 2026-07-31 14:55 UTC
Signed with PGP, not checked
Commit: 8350b9d0dc40fc9fbf20878fcc6688abd4113fad
Parent: dc2ad6c
2 files changed, +152 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.3.0"
3 + version = "2.4.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 +151
@@ -349,6 +349,82 @@
349 349 /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
350 350 pub const ANSI_240_OFFSET: usize = 16;
351 351
352 + /// The twelve chromatic ANSI slots, as the intents that paint them.
353 + ///
354 + /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
355 + /// or dark, since red is the theme's danger tone either way, which is exactly
356 + /// why the four achromatic slots are not in this table.
357 + ///
358 + /// Lifted from Alloy's `skelgen` on 2026-07-31, which had folded three
359 + /// disagreeing hand-maintained copies into one and is the reason the
360 + /// arrangement is trusted. It moved here so a program that paints its own
361 + /// palette at runtime, rather than reading a generated config, resolves the
362 + /// same slots. Slot 14 was the one the copies disagreed on and is
363 + /// `category.six`, which both the Linux console table and the retired
364 + /// `vtrgb.py` had.
365 + const CHROMATIC: [(usize, &str); 12] = [
366 + (1, "status.danger"),
367 + (2, "status.success"),
368 + (3, "status.warning"),
369 + (4, "status.info"),
370 + (5, "category.five"),
371 + (6, "category.six"),
372 + (9, "action.primary"), // bright red, the theme's warm accent
373 + (10, "status.success"),
374 + (11, "status.warning"),
375 + (12, "status.info"),
376 + (13, "category.five"),
377 + (14, "category.six"),
378 + ];
379 +
380 + /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
381 + ///
382 + /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
383 + /// "white", but what a terminal wants there is *the darkest tone* and *the
384 + /// lightest tone*, and which intent that is flips with the theme's polarity. A
385 + /// light theme's darkest tone is its ink; a dark theme's is its deepest
386 + /// surface. Pinning slot 0 to `content.primary` reads correctly on a light
387 + /// theme and hands a dark one a pale cream as "black".
388 + ///
389 + /// Slot 7 is a surface and not a text tone, because it is what a program with
390 + /// no way to name anything else draws its container on: a greeter's login card
391 + /// is a light card on the darker field slot 0 paints.
392 + ///
393 + /// Anything that is not `dark`, including `high-contrast`, follows the light
394 + /// anchors.
395 + fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
396 + let dark = variant == "dark";
397 + Some(match (index, dark) {
398 + (0, false) => "content.primary", // darkest text tone
399 + (0, true) => "surface.sunken", // darkest surface
400 + (7, false) => "surface.raised", // the login card
401 + (7, true) => "content.secondary", // a readable light tone
402 + (8, _) => "content.muted", // muted chrome, either way
403 + (15, false) => "surface.overlay", // lightest surface
404 + (15, true) => "content.primary", // lightest text tone
405 + _ => return None,
406 + })
407 + }
408 +
409 + /// The authored intent painting ANSI slot `index` under a theme of `variant`,
410 + /// as a dotted key into [`ThemeColors::colors`].
411 + ///
412 + /// `None` for an index outside 0-15. Every slot in range resolves, so a caller
413 + /// that has the intent can fill all sixteen.
414 + ///
415 + /// This is what makes a bare console, a terminal emulator and a generated
416 + /// config agree on what red means. They disagreed for as long as each kept its
417 + /// own table.
418 + #[must_use]
419 + pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
420 + achromatic_slot(index, variant).or_else(|| {
421 + CHROMATIC
422 + .iter()
423 + .find(|(slot, _)| *slot == index)
424 + .map(|(_, intent)| *intent)
425 + })
426 + }
427 +
352 428 const fn build_ansi_256() -> [Rgb; 256] {
353 429 let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
354 430
@@ -1296,6 +1372,81 @@
1296 1372 assert_eq!(seen.len(), 16);
1297 1373 }
1298 1374
1375 + // ---- the intent-to-slot table ----
1376 +
1377 + // Sixteen slots, every one of them answered. A caller filling a terminal
1378 + // palette has no fallback for a hole: the slot would keep whatever the
1379 + // emulator started with, and one raw ANSI colour in a themed table is more
1380 + // obviously wrong than all sixteen would be.
1381 + #[test]
1382 + fn every_ansi_slot_names_an_intent_on_either_polarity() {
1383 + for variant in ["light", "dark", "high-contrast"] {
1384 + for index in 0..16 {
1385 + assert!(
1386 + ansi_intent(index, variant).is_some(),
1387 + "slot {index} unanswered on {variant}"
1388 + );
1389 + }
1390 + assert_eq!(ansi_intent(16, variant), None);
1391 + }
1392 + }
1393 +
1394 + // The property the four achromatic slots exist to hold: 0 is the darkest
1395 + // tone the theme offers and 15 the lightest, in either polarity. A table
1396 + // that pins slot 0 to `content.primary` passes this on a light theme and
1397 + // inverts on a dark one, which is the bug the polarity split fixes.
1398 + #[test]
1399 + fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
1400 + for id in ["akari-dawn", "akari-night"] {
1401 + let theme = bundled(id);
1402 + let slot = |i: usize| -> Rgb {
1403 + let key = ansi_intent(i, &theme.meta.variant).expect("in range");
1404 + Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
1405 + };
1406 + assert!(
1407 + rel_luminance(slot(0)) < rel_luminance(slot(15)),
1408 + "{id}: ANSI 0 {} should be darker than ANSI 15 {}",
1409 + slot(0).to_hex(),
1410 + slot(15).to_hex(),
1411 + );
1412 + }
1413 + }
1414 +
1415 + // The pair a greeter draws with: its container on 7, its text on 0. If
1416 + // those collapse the login screen is one flat block, and slot 7 being a
1417 + // surface rather than a text tone is what keeps them apart.
1418 + #[test]
1419 + fn the_container_slot_and_the_text_slot_stay_legible() {
1420 + for id in ["akari-dawn", "akari-night"] {
1421 + let theme = bundled(id);
1422 + let slot = |i: usize| -> Rgb {
1423 + let key = ansi_intent(i, &theme.meta.variant).expect("in range");
1424 + Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
1425 + };
1426 + let contrast = wcag_contrast(slot(0), slot(7));
1427 + assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
1428 + }
1429 + }
1430 +
1431 + // The hues do not move with polarity. Red is the theme's danger tone on a
1432 + // light theme and on a dark one, which is why only four slots are in the
1433 + // polarity table at all.
1434 + #[test]
1435 + fn the_chromatic_slots_do_not_vary_with_polarity() {
1436 + for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
1437 + assert_eq!(
1438 + ansi_intent(index, "light"),
1439 + ansi_intent(index, "dark"),
1440 + "slot {index} moved with polarity"
1441 + );
1442 + }
1443 + }
1444 +
1445 + fn bundled(id: &str) -> ThemeColors {
1446 + let dir = bundled_themes_dir().expect("makeover ships its themes");
1447 + load_theme(&[(dir, false)], id).expect("the akari pair ships")
1448 + }
1449 +
1299 1450 #[test]
1300 1451 fn quantize_picks_the_obvious_entry() {
1301 1452 let black = Rgb { r: 0, g: 0, b: 0 };