Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

crates/nornir-warehouse-nav/src/palette.rs

//! **Categorical colour-by-repo** — the validated dataviz palette, not hand-picked.
//!
//! The 8 base hues are the reference categorical instance from the `dataviz` skill
//! (`references/palette.md`), stepped per mode: the light column for a light
//! surface, the dark column for a dark surface. That set was validated (worst
//! adjacent CVD ΔE 24.2 light / 10.3 dark — clear of / at the ≥12·8 floor) so
//! colour-by-repo stays colour-blind-separable; we did NOT eyeball it.
//!
//! **Recency is NOT a hue.** The design law is explicit: colour = identity (repo),
//! recency = **glow** (emissive halo / ring intensity). So this module owns hue
//! only; [`crate::glow_alpha`] owns the recency channel.
//!
//! **Bugs get a reserved alert hue** (`status::critical`), never a categorical
//! slot, so a "known glitch" node never impersonates a repo.
//!
//! ## Beyond 8 repos (the nordisk constellation has ~17)
//! The strict rule is "never cycle a 9th hue". We honour it via *secondary
//! encoding*: every chip is **directly labelled** by the shared renderer AND every
//! repo's crates sit inside a **grouping hull** — so identity never rests on colour
//! alone. Repos past slot 8 take the same 8 hues shifted one lightness *tier*
//! (`tier = i / 8`), which the label + hull disambiguate. This is the documented
//! "8–12 legal only WITH secondary encoding" relief, applied deliberately.

use egui::Color32;

/// The 8 base categorical hues, **light-surface** steps (dataviz reference).
const BASE_LIGHT: [(u8, u8, u8); 8] = [
    (0x2a, 0x78, 0xd6), // 1 blue
    (0x1b, 0xaf, 0x7a), // 2 aqua
    (0xed, 0xa1, 0x00), // 3 yellow
    (0x00, 0x83, 0x00), // 4 green
    (0x4a, 0x3a, 0xa7), // 5 violet
    (0xe3, 0x49, 0x48), // 6 red
    (0xe8, 0x7b, 0xa4), // 7 magenta
    (0xeb, 0x68, 0x34), // 8 orange
];

/// The 8 base categorical hues, **dark-surface** steps (dataviz reference) — the
/// SAME eight hues re-stepped for the dark band, not a different palette.
const BASE_DARK: [(u8, u8, u8); 8] = [
    (0x39, 0x87, 0xe5), // 1 blue
    (0x19, 0x9e, 0x70), // 2 aqua
    (0xc9, 0x85, 0x00), // 3 yellow
    (0x00, 0x83, 0x00), // 4 green
    (0x90, 0x85, 0xe9), // 5 violet
    (0xe6, 0x67, 0x67), // 6 red
    (0xd5, 0x51, 0x81), // 7 magenta
    (0xd9, 0x59, 0x26), // 8 orange
];

/// The reserved **bug / alert** hue (dataviz `status::critical`, fixed, never
/// themed) — a "known glitch" node (failing test / reported problem) wears this so
/// it can never be mistaken for a repo's categorical colour.
pub const BUG_ALERT: Color32 = Color32::from_rgb(0xd0, 0x3b, 0x3b);

/// The number of base categorical hues (a full tier).
pub const TIER: usize = 8;

fn shift_tier((r, g, b): (u8, u8, u8), tier: usize) -> (u8, u8, u8) {
    // Tier 0 = the validated step. Each further tier lightens toward the surface
    // by a fixed fraction (identity still carried by the always-present label +
    // hull); clamped so it never blows out to white.
    if tier == 0 {
        return (r, g, b);
    }
    let k = (tier as f32 * 0.22).min(0.62);
    let mix = |c: u8| -> u8 {
        let c = c as f32;
        (c + (235.0 - c) * k).round().clamp(0.0, 255.0) as u8
    };
    (mix(r), mix(g), mix(b))
}

/// **The categorical colour for repo slot `i`** on the given surface. Slots wrap
/// through the 8 validated hues; every wrap lightens one tier (see the module
/// note — legal only because the chip label + repo hull are the secondary
/// encoding). Deterministic ⇒ the same repo keeps its colour across frames and
/// drill levels (colour follows the entity, never its rank).
pub fn repo_color(i: usize, dark: bool) -> Color32 {
    let base = if dark { &BASE_DARK } else { &BASE_LIGHT };
    let (r, g, b) = shift_tier(base[i % TIER], i / TIER);
    Color32::from_rgb(r, g, b)
}

/// The colour as a `#rrggbb` string (for `state_json` — so a robot test asserts
/// the exact per-repo hue without pixels).
pub fn hex(c: Color32) -> String {
    format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
}

/// A crate chip's fill inside repo slot `i`: the repo hue, nudged toward the
/// surface a touch so a crate reads as "of this repo, one level down" while
/// keeping the repo's identity hue. Deterministic per (repo, ordinal).
pub fn crate_color(repo_slot: usize, ordinal: usize, dark: bool) -> Color32 {
    let base = repo_color(repo_slot, dark);
    // Alternate a subtle light/dark tick per crate ordinal so sibling crates in a
    // dense hull are separable without leaving the repo's hue family.
    let k = if ordinal.is_multiple_of(2) { 0.0 } else { 0.12 };
    let toward = if dark { 235.0 } else { 20.0 };
    let mix = |c: u8| -> u8 {
        let c = c as f32;
        (c + (toward - c) * k).round().clamp(0.0, 255.0) as u8
    };
    Color32::from_rgb(mix(base.r()), mix(base.g()), mix(base.b()))
}

/// A **function** chip's fill inside crate `ordinal` of repo slot `i`: a clearly lighter
/// shade of the crate hue — so a function reads as "of this crate, two levels down" and
/// the function tier is a COLOURED continuation of its repo/crate, never the
/// undifferentiated grey that made the 100k world look like "gråa bajskorvar". `real`
/// distinguishes a warehouse-indexed function (brighter, full identity) from a synthetic
/// fallback (dimmer — an honest visual cue that it is a placeholder, matching the
/// `synthetic_functions` state flag). Deterministic per (repo, crate, real).
pub fn function_color(repo_slot: usize, ordinal: usize, dark: bool, real: bool) -> Color32 {
    let base = crate_color(repo_slot, ordinal, dark);
    let toward = if dark { 235.0 } else { 20.0 };
    // Real functions: a light shade of the crate hue. Synthetic: pushed further toward the
    // surface so it reads dimmer/placeholder without leaving the hue family.
    let k = if real { 0.32 } else { 0.52 };
    let mix = |c: u8| -> u8 {
        let c = c as f32;
        (c + (toward - c) * k).round().clamp(0.0, 255.0) as u8
    };
    Color32::from_rgb(mix(base.r()), mix(base.g()), mix(base.b()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slots_are_stable_and_distinct_within_a_tier() {
        // The first 8 repos each get a DISTINCT validated hue (no two equal).
        let cols: Vec<_> = (0..TIER).map(|i| repo_color(i, true)).collect();
        for a in 0..TIER {
            for b in (a + 1)..TIER {
                assert_ne!(cols[a], cols[b], "slot {a} and {b} must differ");
            }
        }
        // Deterministic: same slot → same colour (identity, not rank).
        assert_eq!(repo_color(3, true), repo_color(3, true));
        // Light and dark are different steps of the SAME palette.
        assert_ne!(repo_color(0, true), repo_color(0, false));
    }

    #[test]
    fn tier_two_wraps_hue_but_lightens() {
        // Slot 8 reuses hue 0's family (i % 8 == 0) but at a lighter tier ⇒ not equal.
        assert_ne!(
            repo_color(0, true),
            repo_color(8, true),
            "tier shift makes slot 8 distinguishable"
        );
    }

    #[test]
    fn bug_hue_is_outside_the_categorical_set() {
        for dark in [true, false] {
            for i in 0..16 {
                assert_ne!(
                    repo_color(i, dark),
                    BUG_ALERT,
                    "bug hue never collides with a repo slot"
                );
            }
        }
    }

    #[test]
    fn hex_is_lowercase_six_digits() {
        assert_eq!(hex(Color32::from_rgb(0x2a, 0x78, 0xd6)), "#2a78d6");
    }
}