damascene-core 0.4.4

Damascene — backend-agnostic UI library core
Documentation
//! Card — shadcn-shaped content container anatomy.
//!
//! The boring path mirrors the common web component shape:
//! `card([card_header([...]), card_content([...]), card_footer([...])])`.
//! `titled_card(title, body)` is a convenience wrapper for older/simple
//! examples, built from the same anatomy rather than a separate layout.
//!
//! `card()` is also the canonical "panel surface" — the bundle of
//! [`SurfaceRole::Panel`] + `tokens::CARD` fill + `tokens::BORDER`
//! stroke + `tokens::RADIUS_LG` (= shadcn `rounded-xl`) + shadow that an
//! LLM might otherwise hand-roll. If the helpers (`card_header`,
//! `card_content`, `card_footer`) don't fit your data shape, **wrap
//! your custom composition in `card([...])` instead of replacing it**
//! — that keeps the surface recipe correct everywhere. Same applies to
//! right-rail inspector panes and other "boxed" wrappers that aren't
//! navigation (use `sidebar()` for that).
//!
//! Slot padding is baked into each constructor as `default_padding(...)`
//! — shadcn's stock recipe, visible at the call site:
//!
//! - `card_header` — `SPACE_6` on all sides, plus `default_gap(SPACE_2)`
//!   for the title + description rhythm (≈ `space-y-1.5`).
//! - `card_content` — `SPACE_6` on left / right / bottom, `0` on top
//!   (= `p-6 pt-0`), so the visual gap below the header comes from the
//!   header's bottom padding rather than from doubling. Also
//!   `default_gap(SPACE_4)` so a stack of body rows breathes without
//!   the author remembering `.gap(...)` (override per call). When
//!   `card_content` is the *leading* slot (a header-less
//!   `card([card_content([...])])`), the metrics pass restores its top
//!   padding so the body isn't flush against the card edge; an explicit
//!   `.padding(...)` / `.pt(...)` still wins.
//! - `card_footer` — same recipe as `card_content`, with
//!   `Align::Center`.
//!
//! Override at the call site, Tailwind-shaped: `.padding(...)` replaces
//! the whole `Sides` struct (= `p-N`); the additive shorthands
//! (`.pt(...)`, `.pb(...)`, `.pl(...)`, `.pr(...)`, `.px(...)`,
//! `.py(...)`) override a single side or axis while preserving the
//! constructor's defaults for the others (= `p-6 pt-0` is
//! `card_content([...]).pt(0.0)` here). The metrics pass does not
//! touch these slots, so any explicit value sticks.
//!
//! A header bar with a tinted strip — common for inspector panes and
//! diff/hunk frames — is `card_header([...]).fill(tokens::MUTED)`; do
//! not hand-roll the strip as a `row(...).fill(MUTED).stroke(BORDER)`
//! sibling of the body.
//!
//! The metrics pass propagates the card's top-corner radii onto a
//! leading `card_header` that has a fill (and symmetric for a trailing
//! `card_footer`) so the strip follows the card's curve instead of
//! poking flat corners through it. Author-set `.radius(...)` on the
//! slot wins; explicit `.padding(...)` on the card itself disables
//! inheritance since the slot is no longer flush with the card edge.

// Lock in full per-item documentation for this module (issue #73).
#![warn(missing_docs)]

use std::panic::Location;

use super::text::{h3, text};
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;

/// The canonical panel surface (shadcn's `Card`) — bordered, rounded,
/// shadowed column. Fill it with [`card_header`] / [`card_content`] /
/// [`card_footer`], or wrap a custom composition in `card([...])`
/// instead of hand-rolling the surface recipe.
#[track_caller]
pub fn card<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    El::new(Kind::Card)
        .at_loc(Location::caller())
        .style_profile(StyleProfile::Surface)
        .metrics_role(MetricsRole::Card)
        .surface_role(SurfaceRole::Panel)
        .children(children)
        .fill(tokens::CARD)
        .stroke(tokens::BORDER)
        .default_radius(tokens::RADIUS_LG)
        .shadow(tokens::SHADOW_MD)
        .width(Size::Fill(1.0))
        .default_height(Size::Hug)
        .axis(Axis::Column)
        .align(Align::Stretch)
}

/// Convenience: a [`card`] with a [`card_title`] header above the body
/// in a [`card_content`].
#[track_caller]
pub fn titled_card<I, E>(title: impl Into<String>, body: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    card([
        card_header([card_title(title)]),
        card_content(body.into_iter().map(Into::into).collect::<Vec<_>>()),
    ])
    .at_loc(Location::caller())
}

/// Top slot of a card (shadcn's `CardHeader`) — a padded column for
/// [`card_title`] and [`card_description`]. Add `.fill(tokens::MUTED)`
/// for a tinted header strip.
#[track_caller]
pub fn card_header<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    column(children)
        .at_loc(Location::caller())
        .metrics_role(MetricsRole::CardHeader)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .default_padding(tokens::SPACE_6)
        .default_gap(tokens::SPACE_2)
}

/// Card heading text (shadcn's `CardTitle`).
#[track_caller]
pub fn card_title(title: impl Into<String>) -> El {
    h3(title)
        .at_loc(Location::caller())
        .line_height(tokens::TEXT_BASE.size)
}

/// Muted, wrapping text under the title (shadcn's `CardDescription`).
#[track_caller]
pub fn card_description(description: impl Into<String>) -> El {
    text(description)
        .at_loc(Location::caller())
        .muted()
        .wrap_text()
        .fill_width()
}

/// Main body slot of a card (shadcn's `CardContent`) — padded on
/// left/right/bottom only (`p-6 pt-0`); the gap below the header comes
/// from the header's own bottom padding.
#[track_caller]
pub fn card_content<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    column(children)
        .at_loc(Location::caller())
        .metrics_role(MetricsRole::CardContent)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .default_padding(Sides {
            left: tokens::SPACE_6,
            right: tokens::SPACE_6,
            top: 0.0,
            bottom: tokens::SPACE_6,
        })
        // A card body is a vertical stack — field rows, sub-cards,
        // paragraphs. Like its sibling stacking slots (`form`,
        // `item_group`), it breathes by default so consecutive
        // interactive rows don't pack their focus-ring / hit bands
        // flush (the `FocusRingObscured` / `HitOverflowCollision`
        // lints). Author `.gap(...)` overrides; single-child bodies
        // (a `scroll`, a `form`, a `table`) are unaffected.
        .default_gap(tokens::SPACE_4)
}

/// Bottom slot of a card (shadcn's `CardFooter`) — a centered row with
/// the same padding recipe as [`card_content`].
#[track_caller]
pub fn card_footer<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    row(children)
        .at_loc(Location::caller())
        .metrics_role(MetricsRole::CardFooter)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .align(Align::Center)
        .default_padding(Sides {
            left: tokens::SPACE_6,
            right: tokens::SPACE_6,
            top: 0.0,
            bottom: tokens::SPACE_6,
        })
}

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

    #[test]
    fn card_title_uses_tight_heading_rhythm() {
        let title = card_title("Latency");

        assert_eq!(title.text_role, TextRole::Title);
        assert_eq!(title.font_size, tokens::TEXT_BASE.size);
        assert_eq!(title.line_height, tokens::TEXT_BASE.size);
        assert_eq!(title.font_weight, FontWeight::Semibold);
    }
}