snora 0.39.0

iced engine for the Snora GUI framework. Consumes snora-core vocabulary.
Documentation
//! Per-preset visibility tests for token-derived engine surface styling
//! (RFC-039): the modal dim and the dialog card.
//!
//! - **Dim visibility**: the derived dim, composited over `background`,
//!   must be distinguishable from `background` itself, for all four
//!   presets — including the two clamping-adjacent cases that broke
//!   RFC-038's first attempt (`light`'s pure-white background,
//!   `high_contrast_dark`'s pure-black one).
//! - **Card border distinguishable**: `surface_raised` (the card's fill)
//!   is bitwise identical to `background` in two of the four built-in
//!   presets (`light`, `high_contrast_light`) — a card in those presets
//!   is visible *only* because of its border, exactly the "border-defined,
//!   not shadow-defined" design RFC-039 specifies. So this suite tests
//!   the border's contrast against `background`, not the fill's — testing
//!   fill-vs-background would assert something false by construction in
//!   half the presets.
//! - **Card fidelity**: the card's fill/border/radius match the token
//!   roles RFC-039 specifies exactly (`surface_raised`, `border`,
//!   `radius.lg`) — proving [`dialog_card_style`] is a direct token
//!   mapping, not an invented derivation.
//! - **Card text contrast**: `text_primary` on the card fill meets WCAG
//!   AA, independently re-verified here even though the underlying
//!   values come from [`snora_style::container::card_raised`]
//!   (RFC-055 — formerly `snora_widgets::design::style::container::card_raised`,
//!   removed in RFC-056), which has its own tests — this is the
//!   guarantee for *this* code path specifically.

use super::*;
use iced::Size;
use iced::widget::Id;
use iced_test::Simulator;
use snora_core::{AppLayout, Dialog};
use snora_design::{Color as SnColor, Tokens, contrast::contrast_ratio, surfaces::DIM_ALPHA};

const AA: f32 = 4.5;

#[derive(Debug, Clone, PartialEq, Eq)]
enum Msg {
    Noop,
}

/// WCAG 2.1 SC 1.4.11 non-text-contrast floor, shared by two assertions
/// below — the card border against `background`, and the modal dim
/// (composited over `background`) against `background` itself — because
/// both measure the same kind of thing: "is this element visually
/// distinct from the page behind it." Not `1.3` (RFC-071): that value
/// was chosen when the border's real worst case was believed to be
/// 1.39:1, "with a small margin under" it. **That belief was wrong** —
/// the border was repaired to clear `3.0` in 0.34.0 (RFC-058) and the
/// test was never updated to match, so a regression from 3.38 back
/// toward 1.3 would have passed silently for four minors.
///
/// Measured worst case, re-derived at 0.38.1 (RFC-071), by preset and
/// release the underlying value was last chosen at:
///
/// - **Card border vs `background`**: worst is `light` at **3.38:1**
///   (`dark` 3.81, both high-contrast presets 21.0) — the `border` role
///   used directly and unmodified, chosen to clear `3.0` in **0.34.0**
///   (RFC-058).
/// - **Modal dim (composited over `background`) vs `background`**:
///   worst is `light`/`high_contrast_light` at **3.2424:1** (`dark`
///   4.3798, `high_contrast_dark` 4.2529) — `DIM_ALPHA = 0.44`, chosen
///   to clear `3.0` in **0.37.0** (RFC-065).
///
/// Both real, unmodified values now clear `NON_TEXT_MIN` with margin —
/// **8%** on the thinnest case (`light`'s dim, 3.24 against 3.0) — so
/// this floor is not a discount from the WCAG threshold, it *is* the
/// WCAG threshold. **This effectively freezes `border` and `DIM_ALPHA`
/// at ≥3.0** under RFC-036: either value regressing below it fails this
/// suite, and lowering either on purpose requires the accessibility
/// carve-out (as 0.34.0's and 0.37.0's repairs both used) — an intent
/// already acted on twice, not a new constraint invented here. If a
/// future preset value fails this floor, that is a finding to report,
/// not a threshold to relax.
///
/// **A sibling constant of the same name and value exists in
/// `crates/snora-design/src/tests.rs`** (`NON_TEXT_MIN: f32 = 3.0`,
/// RFC-058). It cannot be shared directly — that one lives in a
/// `#[cfg(test)]` module, unreachable from this crate, and exporting it
/// would add a public item to a covenant-frozen crate just to serve a
/// test. If you change one, check the other: nothing links them beyond
/// this comment, which is exactly the gap that let `1.3` drift from
/// `3.0` unnoticed here in the first place (RFC-071 review, round 1).
const NON_TEXT_MIN: f32 = 3.0;

fn named_presets() -> [(&'static str, Tokens); 4] {
    [
        ("light", Tokens::light()),
        ("dark", Tokens::dark()),
        ("high_contrast_light", Tokens::high_contrast_light()),
        ("high_contrast_dark", Tokens::high_contrast_dark()),
    ]
}

fn to_sn(c: iced::Color) -> SnColor {
    SnColor::rgba(c.r, c.g, c.b, c.a)
}

/// Alpha-composites `fg` (with its own alpha) over opaque `bg`, matching
/// what the renderer actually draws when the dim layer is painted over
/// the page background.
fn composite(fg: iced::Color, bg: iced::Color) -> iced::Color {
    let a = fg.a;
    iced::Color {
        r: fg.r * a + bg.r * (1.0 - a),
        g: fg.g * a + bg.g * (1.0 - a),
        b: fg.b * a + bg.b * (1.0 - a),
        a: 1.0,
    }
}

// ---------------------------------------------------------------------------
// Dim visibility.
// ---------------------------------------------------------------------------

#[test]
fn dim_visible_against_background_all_presets() {
    for (name, t) in named_presets() {
        let background = to_iced_color(t.palette.background);
        let dim = dim_color(&t);
        let composited = composite(dim, background);
        let r = contrast_ratio(to_sn(composited), to_sn(background));
        assert!(
            r >= NON_TEXT_MIN,
            "{name}: dim composited over background contrast {r:.3} < {NON_TEXT_MIN} \
             — the modal dim is not visibly distinguishable from the page"
        );
    }
}

#[test]
fn dim_alpha_is_always_dim_alpha_constant() {
    for (name, t) in named_presets() {
        let dim = dim_color(&t);
        assert_eq!(
            dim.a, DIM_ALPHA,
            "{name}: dim_color's alpha must stay fixed at DIM_ALPHA regardless of preset"
        );
    }
}

/// Direct unit test of the pole choice, isolated from any specific
/// preset. `dim_color` must pick the pole *opposite* the background's
/// own darkness — this is the property that makes the derivation safe at
/// both luminance extremes without needing `shift_away_from`'s
/// clamp-fallback: the two poles can never both describe the same input.
#[test]
fn dim_color_picks_the_opposite_pole() {
    let mut white_bg_tokens = Tokens::light();
    white_bg_tokens.palette.background = SnColor::rgb(1.0, 1.0, 1.0);
    let dim = dim_color(&white_bg_tokens);
    assert_eq!(
        (dim.r, dim.g, dim.b),
        (0.0, 0.0, 0.0),
        "a light (white) background must pick the black pole"
    );

    let mut black_bg_tokens = Tokens::high_contrast_dark();
    black_bg_tokens.palette.background = SnColor::rgb(0.0, 0.0, 0.0);
    let dim = dim_color(&black_bg_tokens);
    assert_eq!(
        (dim.r, dim.g, dim.b),
        (1.0, 1.0, 1.0),
        "a dark (black) background must pick the white pole"
    );
}

// ---------------------------------------------------------------------------
// Card border distinguishable from background.
// ---------------------------------------------------------------------------

#[test]
fn card_border_distinguishable_from_background_all_presets() {
    for (name, t) in named_presets() {
        let background = to_iced_color(t.palette.background);
        let card = dialog_card_style(&t);
        let r = contrast_ratio(to_sn(card.style.border.color), to_sn(background));
        assert!(
            r >= NON_TEXT_MIN,
            "{name}: card border vs background contrast {r:.3} < {NON_TEXT_MIN} \
             — a border this close to the page would make the card's edge invisible"
        );
    }
}

/// `light` and `high_contrast_light` have `surface_raised == background`
/// in the token data itself (both pure white) — pinning that fact
/// directly, since it is *why* the border-distinguishable test above
/// exists instead of a fill-distinguishable one.
#[test]
fn fill_equals_background_in_light_presets_by_token_design() {
    for name in ["light", "high_contrast_light"] {
        let t = if name == "light" {
            Tokens::light()
        } else {
            Tokens::high_contrast_light()
        };
        assert_eq!(
            t.palette.surface_raised, t.palette.background,
            "{name}: this test's premise (fill == background in these two presets) no longer \
             holds — re-evaluate whether card_border_distinguishable_from_background_all_presets \
             is still the right test, or whether a fill-distinguishable test is now possible too"
        );
    }
}

// ---------------------------------------------------------------------------
// Card fidelity: fill/border/radius are the exact token roles RFC-039
// specifies, not an invented derivation.
// ---------------------------------------------------------------------------

#[test]
fn card_style_matches_token_roles_exactly() {
    for (name, t) in named_presets() {
        let card = dialog_card_style(&t);

        assert_eq!(
            card.style.background,
            Some(to_iced_color(t.palette.surface_raised).into()),
            "{name}: card fill must equal surface_raised exactly"
        );
        assert_eq!(
            card.style.border.color,
            to_iced_color(t.palette.border),
            "{name}: card border color must equal the border role exactly"
        );
        assert_eq!(
            card.style.border.radius,
            t.radius.lg.into(),
            "{name}: card border radius must equal radius.lg exactly"
        );
        assert_eq!(
            card.padding, t.spacing.lg,
            "{name}: card padding must equal spacing.lg exactly"
        );
        assert_eq!(
            card.style.shadow,
            iced::Shadow::default(),
            "{name}: card must be border-defined, not shadow-defined (RFC-039) — \
             card_raised's shadow must be zeroed, not inherited"
        );
    }
}

// ---------------------------------------------------------------------------
// Card text contrast.
// ---------------------------------------------------------------------------

#[test]
fn card_text_meets_aa_all_presets() {
    for (name, t) in named_presets() {
        let card = dialog_card_style(&t);
        let text = card
            .style
            .text_color
            .expect("card style must set a text color");
        let fill = to_iced_color(t.palette.surface_raised);
        let r = contrast_ratio(to_sn(text), to_sn(fill));
        assert!(
            r >= AA,
            "{name}: card text contrast {r:.2} < {AA} (WCAG AA)"
        );
    }
}

// ---------------------------------------------------------------------------
// The dialog identifier resolves to the card, not its centring wrapper
// (RFC-049).
// ---------------------------------------------------------------------------

/// The defect RFC-049 exists to catch: before this release,
/// `snora-dialog-card` was attached to the dialog's full-window centring
/// container, not the styled card — "present" in every render, on both
/// paths, but never resolving to the actual card. A presence-only check
/// (`sim.find(id).is_ok()`) cannot distinguish this from a correct fix,
/// because the old, wrong identifier was present too. This test instead
/// asserts a property only the real card has: its bounds are strictly
/// smaller than the window, since it is padded content behind a border,
/// not a container that fills the screen — see `dialog_card_style`
/// above, whose `padding` and `style.border` this element actually
/// applies.
///
/// If this regresses to asserting presence alone, it stops catching the
/// RFC-049 defect class even though it would still pass.
#[test]
fn dialog_card_identifier_resolves_to_the_card_not_the_window() {
    let tokens = Tokens::light();
    let window_size = Size::new(1024.0, 768.0);

    let dialog: Dialog<Element<'_, Msg>, Msg> =
        Dialog::new(iced::widget::text("dialog content").into());
    let layout = AppLayout::new(iced::widget::text("body").into())
        .dialog(dialog)
        .on_close_modals(Msg::Noop);

    let element = render(layout, &tokens);
    let mut sim = Simulator::with_size(iced_test::core::Settings::default(), window_size, element);

    let wrapper_bounds = sim
        .find(Id::new(crate::identifiers::DIALOG))
        .expect("snora-dialog must resolve on the design path")
        .bounds();
    let card_bounds = sim
        .find(Id::new(crate::identifiers::DIALOG_CARD))
        .expect("snora-dialog-card must resolve on the design path")
        .bounds();

    assert!(
        (wrapper_bounds.width - window_size.width).abs() < 1.0
            && (wrapper_bounds.height - window_size.height).abs() < 1.0,
        "sanity check: snora-dialog's centring wrapper is expected to fill the window \
         ({wrapper_bounds:?} vs window {window_size:?}) — if this no longer holds, this \
         test needs a different baseline to compare the card's bounds against"
    );

    assert!(
        card_bounds.width < wrapper_bounds.width && card_bounds.height < wrapper_bounds.height,
        "snora-dialog-card must resolve to bounds strictly smaller than snora-dialog's \
         ({card_bounds:?} vs wrapper {wrapper_bounds:?}) — equal bounds would mean it is \
         still resolving to the full-window wrapper, the RFC-049 defect this test exists \
         to catch"
    );
}

// ---------------------------------------------------------------------------
// `design::responsive_render` keeps the design chrome (RFC-053).
// ---------------------------------------------------------------------------

/// The defect RFC-053 exists to catch: `crate::responsive::responsive_render`
/// (the engine path) renders through `crate::render::render`
/// unconditionally, so a `design`-path application that adopted it lost
/// the styled dialog card and the derived modal dim — silently, since
/// the layout still renders and the width still reaches the closure. An
/// identifier- or width-*presence* check cannot see this, the same shape
/// as RFC-049: the defect is "renders, but through the wrong path," and
/// presence is unchanged by which path rendered it.
///
/// This test instead resolves `snora-dialog-card` — emitted **only** on
/// the design path (see [`identifiers_present_in_rendered_output`] in
/// `identifiers/tests.rs`, which asserts its absence on the engine path)
/// — through `design::responsive_render`'s own output. If this function
/// ever silently forwarded to the engine path instead of calling
/// [`render`], `snora-dialog-card` would not exist to resolve. Verified
/// empirically, not just by reading the test: pointed this same body at
/// `crate::responsive::responsive_render` (the engine path) instead, and
/// it failed with `SelectorNotFound` for `snora-dialog-card` — the exact
/// RFC-053 defect. Restored and reconfirmed passing before landing.
#[test]
fn responsive_render_keeps_the_design_chrome() {
    let tokens = Tokens::light();
    let window_size = Size::new(1024.0, 768.0);

    let element = responsive_render(
        move |_width| {
            let dialog: Dialog<Element<'_, Msg>, Msg> =
                Dialog::new(iced::widget::text("dialog content").into());
            AppLayout::new(iced::widget::text("body").into())
                .dialog(dialog)
                .on_close_modals(Msg::Noop)
        },
        &tokens,
    );

    let mut sim = Simulator::with_size(iced_test::core::Settings::default(), window_size, element);

    sim.find(Id::new(crate::identifiers::DIALOG_CARD)).expect(
        "snora-dialog-card must resolve through design::responsive_render — if it does not, \
         this function is silently rendering through the engine path instead of design::render, \
         which is exactly the RFC-053 defect this test exists to catch",
    );
}