Skip to main content

max / makeover

Derive the bevel pair, and give it a palette deep enough to show Two derived intents, bevel-light and bevel-dark, lightened and darkened from surface.raised in OKLab. Same shape as the existing border-strong and action-hover derivations, and emitted only when the surface they read from is present. Geometry stays with the consumer; only the two tones are shared, which is the one form that reaches a consumer with no CSS to compose them in. The bevel does not survive 16 colors. Every shipped theme loses an edge there: a raised face lands on one of the palette's three grays with nothing between it and its neighbour, so whichever edge points toward the end of the ramp the face already occupies rounds back onto the face. So ANSI_256 is here too, as the xterm table indexed the way the escape sequence indexes it, and 26 of 31 themes keep both edges against it. The five that do not have raised surfaces at the top of their ramp, and three of those cannot bevel in truecolor either. ANSI_240 is the same table without the low sixteen. Every emulator lets the user repaint those, so a match landing there is a match against a color that may have moved, and 18 themes have an edge that lands there. It costs nothing: the same 26 keep both edges either way. quantize_against is the wrong tool for this pair despite being right for a border. It optimizes each color against the background alone, with no notion of direction, so both edges are pushed onto the same contrasting entry and the bevel inverts on one side. Plain quantize keeps them apart and in order. Also, in passing: the README advertised derived tokens that the usage audit pruned, and never documented the low-color API at all; and the dump_intents example read ../themes, which is not where they ship, so it panicked on every run.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 00:56 UTC
Signed with PGP, not checked
Commit: f32a073b2d27c92bb997b434e35498db97385423
Parent: 66e79d5
3 files changed, +278 insertions, -7 deletions
M README.md +29 -4
@@ -74,14 +74,39 @@
74 74
75 75 ### Derived tokens
76 76
77 - Interactive states are not authored. `resolve()` derives hover, active,
78 - selection, row striping, and contrast pairings perceptually in OKLab, so theme
79 - files stay small and every consuming app derives them identically rather than
80 - each recomputing its own.
77 + Interactive states are not authored. `resolve()` derives them perceptually in
78 + OKLab, so theme files stay small and every consuming app derives them
79 + identically rather than each recomputing its own: `action-hover`,
80 + `content-on-action`, `focus-ring`, `hover-surface`, `border-strong`, the
81 + translucent `overlay` scrim, and the `bevel-light` / `bevel-dark` pair that a
82 + raised surface is lit and shadowed with.
83 +
84 + Each is emitted only when the intents it reads from are present, so a partial
85 + theme resolves to a partial token set rather than failing.
86 +
87 + Bevel geometry is not derived here. Thickness, radius and which side takes which
88 + edge are the consuming app's, and only the two tones are shared.
81 89
82 90 `intent_css_vars()` renders a resolved theme as a `:root { … }` block for web
83 91 consumers; native consumers read RGB tuples off the same resolved tokens.
84 92
93 + ### Terminals without truecolor
94 +
95 + `ANSI_16`, `ANSI_256` and `ANSI_240` are the palettes a terminal addresses by
96 + index, and `quantize` maps a theme color onto the nearest entry of any of them in
97 + OKLab. `quantize_against` does the same for a color that has to stay legible
98 + against a known background, such as a border on a page, and it is the wrong
99 + choice for a pair of colors that must also stay apart from each other, because it
100 + optimizes each one against the background alone.
101 +
102 + Prefer `ANSI_240`, the 6x6x6 cube and the gray ramp. Every emulator lets the user
103 + repaint the low sixteen, so a match landing there is a match against a color that
104 + may have moved. Add `ANSI_240_OFFSET` to the returned index to get the one the
105 + terminal wants.
106 +
107 + Color depth decides how much of a theme survives. Two tones a hair apart in
108 + 24-bit round onto one entry at 256 and onto the same gray at 16.
109 +
85 110 ### Theme ID
86 111
87 112 The theme ID is the filename without `.toml` (e.g., `catppuccin-mocha.toml` has ID `catppuccin-mocha`). IDs must contain only alphanumeric characters, hyphens, and underscores. Path traversal characters are rejected.
@@ -2,9 +2,7 @@
2 2 let id = std::env::args()
3 3 .nth(1)
4 4 .unwrap_or_else(|| "makenotwork".into());
5 - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6 - .join("..")
7 - .join("themes");
5 + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes");
8 6 let t = makeover::load_semantic(&[(dir, false)], &id).unwrap();
9 7 for (k, v) in &t.intents {
10 8 println!(" --{k}: {v};");
M src/lib.rs +248
@@ -327,6 +327,73 @@
327 327 },
328 328 ];
329 329
330 + /// The 256 colors an xterm-compatible terminal addresses by index, so that
331 + /// entry `i` is what the terminal paints for `38;5;i`.
332 + ///
333 + /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
334 + /// system colors, which every emulator lets the user repaint. 16-231 are a
335 + /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
336 + ///
337 + /// So a color whose whole job is to be told apart from another should quantize
338 + /// against [`ANSI_240`] rather than against this table: a match landing in the
339 + /// low sixteen is a match against a color the user may have moved.
340 + pub const ANSI_256: [Rgb; 256] = build_ansi_256();
341 +
342 + /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
343 + /// the sixteen repaintable system colors.
344 + ///
345 + /// Quantizing against this returns an index into *this* slice; add
346 + /// [`ANSI_240_OFFSET`] to get the index the terminal wants.
347 + pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
348 +
349 + /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
350 + pub const ANSI_240_OFFSET: usize = 16;
351 +
352 + const fn build_ansi_256() -> [Rgb; 256] {
353 + let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
354 +
355 + let mut i = 0;
356 + while i < 16 {
357 + table[i] = ANSI_16[i];
358 + i += 1;
359 + }
360 +
361 + // The cube's six levels are not evenly spaced. The step from black to the
362 + // first is more than twice any later one, which is xterm's arrangement
363 + // rather than a choice available here, and it is why the darkest tones a
364 + // theme can reach on 256 colors come from the gray ramp instead.
365 + const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
366 + let mut r = 0;
367 + while r < 6 {
368 + let mut g = 0;
369 + while g < 6 {
370 + let mut b = 0;
371 + while b < 6 {
372 + table[16 + 36 * r + 6 * g + b] = Rgb {
373 + r: LEVELS[r],
374 + g: LEVELS[g],
375 + b: LEVELS[b],
376 + };
377 + b += 1;
378 + }
379 + g += 1;
380 + }
381 + r += 1;
382 + }
383 +
384 + // 8 to 238 in steps of 10. Neither end is black or white; both of those are
385 + // in the cube, so the ramp is 24 steps of gray between them rather than 24
386 + // steps of the whole range.
387 + let mut k = 0;
388 + while k < 24 {
389 + let v = 8 + 10 * k as u8;
390 + table[232 + k as usize] = Rgb { r: v, g: v, b: v };
391 + k += 1;
392 + }
393 +
394 + table
395 + }
396 +
330 397 /// The contrast ratio two colors must clear to read as separate areas.
331 398 ///
332 399 /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
@@ -511,6 +578,28 @@
511 578 format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
512 579 );
513 580 }
581 + if let Some(raised) = get(&intents, "surface-raised") {
582 + // The two edges of a bevel: a raised control is lit from the top left,
583 + // so its top and left edges take `bevel-light` and its bottom and right
584 + // edges `bevel-dark`. Inverting the pair gives a pressed state and an
585 + // inset well, which is what makes the idiom cheap for a consumer.
586 + //
587 + // Derived here rather than composed per-app because the two webviews
588 + // could do it in `color-mix()` and audiofiles, which is egui, could not.
589 + // Geometry (thickness, radius, which side gets which) stays app-side.
590 + //
591 + // The deltas are asymmetric because the eye is: an equal step down reads
592 + // as a smaller change than the same step up, so the shadow is cut deeper
593 + // than the highlight is raised.
594 + //
595 + // A face already at the top of the ramp cannot hold a highlight — the
596 + // lightening clamps and the control bevels on two sides without ever
597 + // resolving as lit. That is a property of the theme, not of this
598 + // derivation; `bevel_edges_are_distinct_from_their_face` names the
599 + // shipped themes it currently bites.
600 + derived.push(("bevel-light".into(), lighten(raised, 0.14)));
601 + derived.push(("bevel-dark".into(), darken(raised, 0.18)));
602 + }
514 603 if let Some(sunken) = get(&intents, "surface-sunken") {
515 604 derived.push(("hover-surface".into(), sunken));
516 605 }
@@ -1459,6 +1548,165 @@
1459 1548 assert!(t.hex("row-stripe").is_none());
1460 1549 }
1461 1550
1551 + #[test]
1552 + fn resolve_bevel_intents() {
1553 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1554 + let t = resolve(&theme);
1555 + let raised = Rgb::from_hex("#3b4252").unwrap();
1556 + assert_eq!(
1557 + t.hex("bevel-light").unwrap(),
1558 + lighten(raised, 0.14).to_hex()
1559 + );
1560 + assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
1561 + }
1562 +
1563 + // A bevel is two edges around one face, so both edges have to be visibly off
1564 + // that face or the control never resolves as lit. The lightening clamps at
1565 + // the top of the ramp, which means a theme authoring a white raised surface
1566 + // gets a highlight identical to the surface it is meant to sit on.
1567 + //
1568 + // The list is asserted rather than merely reported so that changing a theme
1569 + // has to come here and say so. Shrinking it is the fix; growing it is a
1570 + // regression in the theme, not in this derivation.
1571 + #[test]
1572 + fn bevel_edges_are_distinct_from_their_face() {
1573 + const CANNOT_BEVEL: &[&str] = &["audiofiles", "neobrute", "oxocarbon-light"];
1574 +
1575 + let mut degenerate: Vec<String> = Vec::new();
1576 + for (id, source) in embedded_themes() {
1577 + let theme = parse_theme_str(id, source, false).unwrap();
1578 + let t = resolve(&theme);
1579 + let Some(raised) = t.hex("surface-raised") else {
1580 + continue;
1581 + };
1582 + let light = t.hex("bevel-light").expect("raised implies bevel-light");
1583 + let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
1584 + if light == raised || dark == raised {
1585 + degenerate.push(id.to_string());
1586 + }
1587 + }
1588 + degenerate.sort();
1589 +
1590 + assert_eq!(
1591 + degenerate, CANNOT_BEVEL,
1592 + "themes whose raised surface cannot hold both bevel edges"
1593 + );
1594 + }
1595 +
1596 + // What the bevel pair does on a sixteen-color terminal, measured across the
1597 + // shipped set rather than assumed. Two results, both load-bearing for a
1598 + // consumer that has to render one there.
1599 + //
1600 + // Exactly one edge survives, never both. A raised face quantizes onto one of
1601 + // the palette's three grays, and the palette is too coarse to hold anything
1602 + // between that entry and its neighbour, so whichever edge is pushed toward
1603 + // the end of the ramp the face already sits on lands back on the face. Light
1604 + // themes and most dark ones keep the shadow and lose the highlight; a face
1605 + // that quantizes to black keeps the highlight and loses the shadow.
1606 + //
1607 + // So a low-color consumer draws the single edge it can render, on the side
1608 + // the palette left it, rather than a bevel that resolves on two sides.
1609 + //
1610 + // And `quantize_against` is the wrong function for this pair, though it is
1611 + // the right one for a border. It answers "nearest entry that clears DISTINCT
1612 + // against the background", which has no notion of direction, so both edges
1613 + // are pushed onto the same contrasting entry and the bevel inverts on one
1614 + // side. Plain `quantize` keeps them apart and in the right order.
1615 + #[test]
1616 + fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
1617 + for (id, source) in embedded_themes() {
1618 + let theme = parse_theme_str(id, source, false).unwrap();
1619 + let t = resolve(&theme);
1620 + let (Some(face), Some(light), Some(dark)) = (
1621 + t.hex("surface-raised").and_then(Rgb::from_hex),
1622 + t.hex("bevel-light").and_then(Rgb::from_hex),
1623 + t.hex("bevel-dark").and_then(Rgb::from_hex),
1624 + ) else {
1625 + continue;
1626 + };
1627 +
1628 + let face_index = quantize(face, &ANSI_16);
1629 + let light_survives = quantize(light, &ANSI_16) != face_index;
1630 + let dark_survives = quantize(dark, &ANSI_16) != face_index;
1631 + assert!(
1632 + light_survives != dark_survives,
1633 + "{id}: expected exactly one bevel edge to survive 16 colors, \
1634 + highlight {light_survives} shadow {dark_survives}"
1635 + );
1636 +
1637 + // Direction-blind, so it collapses the pair it is asked to separate.
1638 + assert_eq!(
1639 + quantize_against(light, face, &ANSI_16),
1640 + quantize_against(dark, face, &ANSI_16),
1641 + "{id}: quantize_against is expected to be unusable for a bevel pair"
1642 + );
1643 + }
1644 + }
1645 +
1646 + // 256 colors is where the bevel starts working. At 16 every shipped theme
1647 + // loses an edge; here all but the five whose raised surface sits at the very
1648 + // top of the ramp keep both, and those five fail for the reason they fail in
1649 + // truecolor rather than for a palette reason.
1650 + //
1651 + // Three of them cannot bevel at any depth, so they are the
1652 + // `bevel_edges_are_distinct_from_their_face` set. The other two are new here:
1653 + // they hold a highlight in 24-bit, but not one wide enough to survive
1654 + // rounding onto the cube.
1655 + #[test]
1656 + fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
1657 + const LOSES_AN_EDGE: &[&str] = &[
1658 + "audiofiles",
1659 + "gruvbox-light",
1660 + "neobrute",
1661 + "oxocarbon-light",
1662 + "rosepine-dawn",
1663 + ];
1664 +
1665 + let mut lost: Vec<String> = Vec::new();
1666 + for (id, source) in embedded_themes() {
1667 + let theme = parse_theme_str(id, source, false).unwrap();
1668 + let t = resolve(&theme);
1669 + let (Some(face), Some(light), Some(dark)) = (
1670 + t.hex("surface-raised").and_then(Rgb::from_hex),
1671 + t.hex("bevel-light").and_then(Rgb::from_hex),
1672 + t.hex("bevel-dark").and_then(Rgb::from_hex),
1673 + ) else {
1674 + continue;
1675 + };
1676 +
1677 + // Against the fixed region, which is what a consumer should use: a
1678 + // match in the low sixteen is a match against a repaintable color.
1679 + let f = quantize(face, ANSI_240);
1680 + let l = quantize(light, ANSI_240);
1681 + let d = quantize(dark, ANSI_240);
1682 + if l == f || d == f || l == d {
1683 + lost.push(id.to_string());
1684 + }
1685 + }
1686 + lost.sort();
1687 +
1688 + assert_eq!(
1689 + lost, LOSES_AN_EDGE,
1690 + "themes that cannot hold a two-tone bevel on a 256-color terminal"
1691 + );
1692 + }
1693 +
1694 + #[test]
1695 + fn the_256_table_has_its_three_regions() {
1696 + // Index is the escape-sequence index, so the low sixteen must match.
1697 + assert_eq!(ANSI_256[..16], ANSI_16);
1698 + // The cube's corners, at both ends and one interior level.
1699 + assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
1700 + assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
1701 + assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
1702 + // The gray ramp runs 8 to 238 and contains neither black nor white.
1703 + assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
1704 + assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
1705 + // The fixed region is the table minus the repaintable colors.
1706 + assert_eq!(ANSI_240.len(), 240);
1707 + assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
1708 + }
1709 +
1462 1710 #[test]
1463 1711 fn resolve_overlay_is_dark_translucent_scrim() {
1464 1712 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();