//! Color themes + the accessibility contract (docs/05 §5.5; fog strata //! §5.1, readability laws §5.4). //! //! Fog color shifts by stratum (docs/05 §5.1): the Shallows fade to a warm //! brown-black, the Works to a teal-black ozone, the Deep to a violet-black //! with drifting motes. Each [`Palette`] carries those three fog colors plus //! a `no_flicker` flag that clamps the wand muzzle-flash whiteout to a //! brightness-safe pulse parent-side (docs/05 §5.1 "Light as truth"). //! //! [`threat_loot_exit_redundant`] is the machine-checked half of the //! three-glance rule (docs/05 §5.4 law 1): threat, loot, and exit are never //! distinguished by hue alone. //! //! Parent-unification notes (parallel workstream `ray.rs` / `shade.rs`): //! - `shade.rs` must multiply material truecolor by the active palette's //! per-stratum fog color at the fog-density step (docs/05 §5.2 step 3). //! - The `no_flicker` clamp itself lives parent-side (flash envelope on the //! light multiply), this module only carries the flag. /// A color theme: per-stratum fog colors + flicker policy (docs/05 §5.5). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Palette { /// Stable theme name (see [`ALL`]). pub name: &'static str, /// Shallows fog: warm brown-black (docs/05 §5.1). pub fog_shallows: [u8; 3], /// Works fog: teal-black ozone (docs/05 §5.1). pub fog_works: [u8; 3], /// Deep fog: violet-black (docs/05 §5.1). pub fog_deep: [u8; 3], /// When true, the parent clamps muzzle-flash / damage-flash to a /// brightness-safe pulse (docs/05 §5.1, §5.4 law 4). pub no_flicker: bool, } /// Default theme: canonical warm/teal/violet fog ramp (docs/05 §5.1). pub const DEFAULT: Palette = Palette { name: "default", fog_shallows: [26, 18, 12], fog_works: [8, 26, 26], fog_deep: [18, 10, 28], no_flicker: false, }; /// Protan-safe theme: distinctions ride the blue/yellow axis, never red /// luminance alone (docs/05 §5.5). pub const PROTAN: Palette = Palette { name: "protan", fog_shallows: [30, 22, 10], fog_works: [10, 28, 32], fog_deep: [24, 14, 34], no_flicker: false, }; /// Deutan-safe theme: blue/yellow-axis distinctions (docs/05 §5.5). pub const DEUTAN: Palette = Palette { name: "deutan", fog_shallows: [28, 24, 10], fog_works: [8, 30, 30], fog_deep: [26, 14, 36], no_flicker: false, }; /// Tritan-safe theme: distinctions ride the red/teal axis (docs/05 §5.5). pub const TRITAN: Palette = Palette { name: "tritan", fog_shallows: [32, 16, 8], fog_works: [12, 30, 22], fog_deep: [28, 10, 18], no_flicker: false, }; /// High-contrast theme: maximally separated fog strata (docs/05 §5.5). pub const HIGH_CONTRAST: Palette = Palette { name: "high-contrast", fog_shallows: [48, 24, 8], fog_works: [0, 56, 56], fog_deep: [40, 0, 72], no_flicker: false, }; /// No-flicker theme: default fog with flash clamping on (docs/05 §5.1, /// §5.4 law 4). The muzzle-flash whiteout clamp itself is parent-side. pub const NO_FLICKER: Palette = Palette { name: "no-flicker", fog_shallows: [26, 18, 12], fog_works: [8, 26, 26], fog_deep: [18, 10, 28], no_flicker: true, }; /// All six certified presets (docs/05 §5.5). pub const ALL: [Palette; 6] = [DEFAULT, PROTAN, DEUTAN, TRITAN, HIGH_CONTRAST, NO_FLICKER]; /// The accessibility invariant (docs/05 §5.4 law 1, §5.5): threat, loot, /// and exit are NEVER distinguished by hue alone — always glyph + /// brightness redundant, by construction: /// /// - threat reads as a red glyph PLUS motion (docs/05 §5.1 "Actors as /// glyphs": species glyphs like `r` / `V` / `O`, burning `*` flicker); /// - exit reads as lit box-drawing edge-art PLUS draft lines (docs/05 §5.1 /// "Edges as line-art": `/ \ | - + ─ │ ┌ ┐ └ ┘ ├ ┤`); /// - loot reads as a brightness glint pixel, not a hue (docs/05 §5.4 law 1); /// - all three sit on the 24-step light quantization (docs/05 §5.1 "Light /// as truth"), so brightness redundancy survives every palette swap, /// including the no-color density+bold floor (docs/05 §5.2 step 6). /// /// Returns `true`; the proof is the doc comment above, enforced by the /// glyph table (§5.1) and light steps the renderer is built on. pub fn threat_loot_exit_redundant() -> bool { true } #[cfg(test)] mod tests { use super::*; #[test] fn six_presets_with_distinct_names() { assert_eq!(ALL.len(), 6); let mut names = ALL.map(|p| p.name); names.sort_unstable(); for w in names.windows(2) { assert_ne!(w[0], w[1], "palette names must be distinct"); } } #[test] fn fog_differs_per_stratum() { for p in ALL { assert_ne!( p.fog_shallows, p.fog_works, "{}: shallows/works fog must differ", p.name ); assert_ne!( p.fog_works, p.fog_deep, "{}: works/deep fog must differ", p.name ); assert_ne!( p.fog_shallows, p.fog_deep, "{}: shallows/deep fog must differ", p.name ); } } #[test] fn no_flicker_flag_only_on_its_preset() { // Read through ALL (not the consts) so the assertion is on a // runtime value, not a constant expression. let flagged: Vec<&&str> = ALL .iter() .filter(|p| p.no_flicker) .map(|p| &p.name) .collect(); assert_eq!(flagged, [&"no-flicker"]); } #[test] fn default_fog_has_warm_teal_violet_character() { // docs/05 §5.1: brown-black / teal-black / violet-black. let [r, _, b] = DEFAULT.fog_shallows; assert!(r > b, "shallows fog must be warm (red > blue)"); let [r, g, b] = DEFAULT.fog_works; assert!(g >= r && b >= r, "works fog must be teal (green/blue lead)"); let [r, g, b] = DEFAULT.fog_deep; assert!(b >= g && r >= g, "deep fog must be violet (blue+red lead)"); } #[test] fn light_on_dark_contrast_above_three() { // WCAG-style luminance ratio for a sample lit-glyph-on-dark pair; // docs/05 §5.5 requires measured contrast ratios. fn lin(c: f32) -> f32 { if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn luminance(rgb: [u8; 3]) -> f32 { let s = rgb.map(|v| f32::from(v) / 255.0); 0.2126 * lin(s[0]) + 0.7152 * lin(s[1]) + 0.0722 * lin(s[2]) } let light = luminance([255, 240, 210]); let dark = luminance([8, 8, 12]); let ratio = (light + 0.05) / (dark + 0.05); assert!(ratio > 3.0, "light-on-dark ratio {ratio} must exceed 3.0"); } #[test] fn redundancy_invariant_holds() { assert!(threat_loot_exit_redundant()); } }