Card source
Inspect and copy the reviewed source included with this Free component.
Free source · 4 files
recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Card recipe — static named slots for a composable card surface.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const ROOT: &str = tw_join!(
"rounded-xl",
"border",
"bg-card",
"text-card-foreground",
"shadow-sm"
);
pub const HEADER: &str = tw_join!("flex", "flex-col", "gap-1.5", "p-6");
pub const TITLE: &str = tw_join!("leading-none", "font-semibold", "tracking-tight");
pub const DESCRIPTION: &str = tw_join!("text-sm", "text-muted-foreground");
pub const CONTENT: &str = tw_join!("p-6", "pt-0");
pub const FOOTER: &str = tw_join!("flex", "items-center", "p-6", "pt-0");
}
/// Optional inputs accepted by [`CardRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CardRecipeProps;
/// Static named classes for every Card part.
#[derive(Debug, Clone, Copy)]
pub struct CardRecipe {
pub root: &'static str,
pub header: &'static str,
pub title: &'static str,
pub description: &'static str,
pub content: &'static str,
pub footer: &'static str,
}
impl CardRecipe {
pub const fn resolve(&'static self, _props: CardRecipeProps) -> CardStyle {
CardStyle { recipe: self }
}
}
/// Fully resolved, streamable Card root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct CardStyle {
recipe: &'static CardRecipe,
}
impl AttrValue for CardStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
pub const CARD_RECIPE: CardRecipe = CardRecipe {
root: base::ROOT,
header: base::HEADER,
title: base::TITLE,
description: base::DESCRIPTION,
content: base::CONTENT,
footer: base::FOOTER,
};
pub const fn card_recipe() -> &'static CardRecipe {
&CARD_RECIPE
}
impl crate::variant_spec::ComponentSpec for CardRecipe {
const NAME: &'static str = "Card";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::CARD_ANATOMY)
}
}
/// Compatibility accessors for the former standalone helpers.
pub const fn card_classes() -> &'static str {
CARD_RECIPE.root
}
pub const fn card_header_classes() -> &'static str {
CARD_RECIPE.header
}
pub const fn card_title_classes() -> &'static str {
CARD_RECIPE.title
}
pub const fn card_description_classes() -> &'static str {
CARD_RECIPE.description
}
pub const fn card_content_classes() -> &'static str {
CARD_RECIPE.content
}
pub const fn card_footer_classes() -> &'static str {
CARD_RECIPE.footer
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = card_recipe();
assert!(recipe.root.contains("bg-card"));
assert!(recipe.header.contains("flex-col"));
assert!(recipe.title.contains("font-semibold"));
assert!(recipe.description.contains("text-muted-foreground"));
assert!(recipe.content.contains("pt-0"));
assert!(recipe.footer.contains("items-center"));
}
#[test]
fn recipe_is_const_and_copy() {
const STYLE: CardStyle = CARD_RECIPE.resolve(CardRecipeProps);
let copied = STYLE;
assert!(copied.recipe.root.contains("rounded-xl"));
}
#[test]
fn recipe_metadata_exposes_the_complete_anatomy() {
let metadata = crate::variant_spec::component_metadata(card_recipe());
let anatomy = metadata.anatomy.as_ref().unwrap();
assert_eq!(metadata.name, "Card");
assert!(metadata.variants.is_empty());
assert_eq!(anatomy.scope, "card");
assert_eq!(anatomy.parts, super::super::CARD_ANATOMY.parts());
metadata.validate().unwrap();
}
}
FreeCard
Composable content containers.
Install with the Proa CLI
$ proa ui add cardInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Card recipe — static named slots for a composable card surface.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const ROOT: &str = tw_join!(
"rounded-xl",
"border",
"bg-card",
"text-card-foreground",
"shadow-sm"
);
pub const HEADER: &str = tw_join!("flex", "flex-col", "gap-1.5", "p-6");
pub const TITLE: &str = tw_join!("leading-none", "font-semibold", "tracking-tight");
pub const DESCRIPTION: &str = tw_join!("text-sm", "text-muted-foreground");
Card source
Browse and copy the reviewed source included with this Free component.
card.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Card component implementation
//!
//! Renders a container for grouped content with header, body, and footer sections.
use super::recipe::{CardRecipeProps, CARD_RECIPE};
use super::CARD_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Card component - a container for grouped content
pub struct Card<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The card content
pub children: Option<C>,
}
impl Card<()> {
pub const fn new() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<C> Default for Card<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Card<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = CARD_RECIPE.resolve(CardRecipeProps);
html_sync! {
<div
data-card
data-scope={CARD_ANATOMY.scope()}
data-part="root"
data-slot="card"
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// CardHeader component - container for title and description
pub struct CardHeader<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The header content
pub children: Option<C>,
}
impl<C> Default for CardHeader<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for CardHeader<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-card-header
data-scope={CARD_ANATOMY.scope()}
data-part="header"
data-slot="card-header"
class={proa_macros::text!("{} {}", CARD_RECIPE.header, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// CardTitle component - the card's heading
pub struct CardTitle<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The title content
pub children: Option<C>,
}
impl<C> Default for CardTitle<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for CardTitle<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<h3
data-card-title
data-scope={CARD_ANATOMY.scope()}
data-part="title"
data-slot="card-title"
class={proa_macros::text!("{} {}", CARD_RECIPE.title, self.class)}
>
{self.children}
</h3>
}
.render(cx)
}
}
/// CardDescription component - supplementary text below the title
pub struct CardDescription<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The description content
pub children: Option<C>,
}
impl<C> Default for CardDescription<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for CardDescription<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<p
data-card-description
data-scope={CARD_ANATOMY.scope()}
data-part="description"
data-slot="card-description"
class={proa_macros::text!("{} {}", CARD_RECIPE.description, self.class)}
>
{self.children}
</p>
}
.render(cx)
}
}
/// CardContent component - the main body of the card
pub struct CardContent<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The content
pub children: Option<C>,
}
impl<C> Default for CardContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for CardContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-card-content
data-scope={CARD_ANATOMY.scope()}
data-part="content"
data-slot="card-content"
class={proa_macros::text!("{} {}", CARD_RECIPE.content, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// CardFooter component - container for action buttons at the bottom
pub struct CardFooter<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The footer content
pub children: Option<C>,
}
impl<C> Default for CardFooter<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for CardFooter<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-card-footer
data-scope={CARD_ANATOMY.scope()}
data-part="footer"
data-slot="card-footer"
class={proa_macros::text!("{} {}", CARD_RECIPE.footer, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn card_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Card {
class: None,
children: Some(text("Hello")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-card"));
assert!(html.contains("data-slot=\"card\""));
assert!(html.contains("Hello"));
}
#[test]
fn card_has_base_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Card {
class: None,
children: Some(text("Card")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("rounded-xl"));
assert!(html.contains("border"));
assert!(html.contains("bg-card"));
assert!(html.contains("shadow-sm"));
}
#[test]
fn card_accepts_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Card {
class: Some("custom-class"),
children: Some(text("Custom")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-class"));
}
#[test]
fn card_header_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardHeader {
class: None,
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-card-header"));
assert!(html.contains("data-slot=\"card-header\""));
assert!(html.contains("Header"));
assert!(html.contains("flex"));
assert!(html.contains("flex-col"));
}
#[test]
fn card_title_renders_as_h3() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardTitle {
class: None,
children: Some(text("My Title")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<h3"));
assert!(html.contains("data-card-title"));
assert!(html.contains("data-slot=\"card-title\""));
assert!(html.contains("My Title"));
assert!(html.contains("font-semibold"));
}
#[test]
fn card_description_renders_as_p() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardDescription {
class: None,
children: Some(text("A description")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<p"));
assert!(html.contains("data-card-description"));
assert!(html.contains("data-slot=\"card-description\""));
assert!(html.contains("A description"));
assert!(html.contains("text-muted-foreground"));
}
#[test]
fn card_content_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardContent {
class: None,
children: Some(text("Body content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-card-content"));
assert!(html.contains("data-slot=\"card-content\""));
assert!(html.contains("Body content"));
assert!(html.contains("p-6"));
}
#[test]
fn card_footer_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardFooter {
class: None,
children: Some(text("Footer")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-card-footer"));
assert!(html.contains("data-slot=\"card-footer\""));
assert!(html.contains("Footer"));
assert!(html.contains("items-center"));
}
#[test]
fn full_card_composition() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Card {
class: None,
children: Some(html_sync! {
{CardHeader {
class: None,
children: Some(html_sync! {
{CardTitle { class: None, children: Some(text("Title")), }}
{CardDescription { class: None, children: Some(text("Description")), }}
}),
}}
{CardContent { class : None, children : Some(text("Content here")), }}
{CardFooter { class : None, children : Some(text("Actions")), }}
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-card"));
assert!(html.contains("data-card-header"));
assert!(html.contains("data-card-title"));
assert!(html.contains("data-card-description"));
assert!(html.contains("data-card-content"));
assert!(html.contains("data-card-footer"));
assert!(html.contains("Title"));
assert!(html.contains("Description"));
assert!(html.contains("Content here"));
assert!(html.contains("Actions"));
assert!(html.contains(r#"data-scope="card" data-part="root""#));
for part in ["header", "title", "description", "content", "footer"] {
assert!(html.contains(&format!(r#"data-part="{part}""#)));
}
}
#[test]
fn card_parts_append_custom_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CardHeader {
class: Some("header-extra"),
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
CardTitle {
class: Some("title-extra"),
children: Some(text("Title")),
}
.render(&mut cx)
.unwrap();
CardDescription {
class: Some("description-extra"),
children: Some(text("Description")),
}
.render(&mut cx)
.unwrap();
CardContent {
class: Some("content-extra"),
children: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
CardFooter {
class: Some("footer-extra"),
children: Some(text("Footer")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
for class in [
"header-extra",
"title-extra",
"description-extra",
"content-extra",
"footer-extra",
] {
assert!(html.contains(class));
}
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
use crate::components::md_support::block_boundary;
use proa_core::{MdRenderSync, WebContext, WriteBuf, WriteError};
/// Card pushes heading offset so nested cards get deeper headings.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Card<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
let prev = cx.md_push_heading_offset(1);
self.children.render_md(cx)?;
cx.md_pop_heading_offset(prev);
block_boundary(cx)?;
Ok(())
}
}
/// CardHeader is a passthrough — its children (title, description) handle formatting.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for CardHeader<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// CardTitle renders as a heading at the current level.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for CardTitle<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
// `heading` adds the current offset itself, so the logical level is
// constant: a top-level Card title is `##`, one nesting deeper `###`.
cx.md_heading_with(1, |cx| self.children.render_md(cx))
}
}
/// CardDescription renders as paragraph text.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for CardDescription<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
cx.md_blank_line()?;
Ok(())
}
}
/// CardContent is a passthrough.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for CardContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
cx.md_blank_line()?;
Ok(())
}
}
/// CardFooter is a passthrough.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for CardFooter<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
cx.md_blank_line()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Compare HTML vs Markdown render time for the same card.
/// Not a proper benchmark (use criterion for that), but shows the ballpark.
#[test]
fn perf_comparison_html_vs_md() {
use proa_core::{text::text, WebContext, WebRenderSync};
use proa_macros::html_sync;
use std::time::Instant;
let iterations: u128 = 100_000;
// Warm up + measure HTML
let start = Instant::now();
for _ in 0..iterations {
let mut buf = Vec::with_capacity(512);
let mut cx = WebContext::with_buffer(&mut buf);
Card {
class: None,
children: Some(html_sync! {
{CardHeader {
class: None,
children: Some(html_sync! {
{CardTitle { class: None, children: Some(text("Notifications")) }}
{CardDescription {
class: None,
children: Some(text("You have 3 unread messages.")),
}}
}),
}}
{CardContent { class: None, children: Some(text("Body text here")), }}
}),
}
.render(&mut cx)
.unwrap();
std::hint::black_box(&buf);
}
let html_elapsed = start.elapsed();
// Warm up + measure Markdown
let start = Instant::now();
for _ in 0..iterations {
let mut buf = Vec::with_capacity(256);
let mut cx = WebContext::with_buffer(&mut buf);
Card {
class: None,
children: Some((
CardHeader {
class: None,
children: Some((
CardTitle {
class: None,
children: Some("Notifications"),
},
CardDescription {
class: None,
children: Some("You have 3 unread messages."),
},
)),
},
CardContent {
class: None,
children: Some("Body text here"),
},
)),
}
.render_md(&mut cx)
.unwrap();
std::hint::black_box(&buf);
}
let md_elapsed = start.elapsed();
let html_ns = html_elapsed.as_nanos() / iterations;
let md_ns = md_elapsed.as_nanos() / iterations;
eprintln!("\n Single Card component ({iterations} iterations, release mode):");
eprintln!(" HTML: {html_ns}ns ({:.3}µs)", html_ns as f64 / 1000.0);
eprintln!(" MD: {md_ns}ns ({:.3}µs)", md_ns as f64 / 1000.0);
if html_ns > 0 && md_ns > 0 {
let faster = if html_ns < md_ns { "HTML" } else { "MD" };
let ratio = html_ns.max(md_ns) as f64 / html_ns.min(md_ns) as f64;
eprintln!(" {faster} is {ratio:.1}x faster");
}
assert!(html_ns > 0);
assert!(md_ns > 0);
}
#[test]
fn card_title_renders_heading() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Card {
class: None,
children: Some(CardTitle {
class: None,
children: Some("Notifications"),
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("## Notifications"), "output: {output}");
}
#[test]
fn nested_cards_increase_heading_level() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
// Outer card pushes offset to 1, inner card pushes to 2
Card {
class: None,
children: Some((
CardTitle {
class: None,
children: Some("Outer"),
},
Card {
class: None,
children: Some(CardTitle {
class: None,
children: Some("Inner"),
}),
},
)),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("## Outer"), "output: {output}");
assert!(output.contains("### Inner"), "output: {output}");
assert!(!output.contains("#### Inner"), "output: {output}");
}
#[test]
fn card_with_description() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Card {
class: None,
children: Some((
CardHeader {
class: None,
children: Some((
CardTitle {
class: None,
children: Some("API Key"),
},
CardDescription {
class: None,
children: Some("Manage your API key."),
},
)),
},
CardContent {
class: None,
children: Some("Key details here."),
},
)),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("## API Key"), "output: {output}");
assert!(
output.contains("Manage your API key\\."),
"output: {output}"
);
assert!(output.contains("Key details here\\."), "output: {output}");
}
#[test]
fn card_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Card {
class: None,
children: Some(CardTitle {
class: None,
children: Some("Notifications"),
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n## Notifications"),
"card title heading must start at a block boundary, got:\n{output}"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Card component - container for grouped content
//!
//! Cards organize content into sections with header, body, and footer.
mod card;
pub mod md;
mod recipe;
/// Stable scope/part contract shared by every Card renderer.
pub const CARD_ANATOMY: crate::Anatomy = crate::Anatomy::new(
"card",
&[
"root",
"header",
"title",
"description",
"content",
"footer",
],
);
// Re-export card types
pub use card::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
// Re-export recipe types
pub use recipe::{
card_classes, card_content_classes, card_description_classes, card_footer_classes,
card_header_classes, card_recipe, card_title_classes, CardRecipe, CardRecipeProps, CardStyle,
CARD_RECIPE,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anatomy_names_every_public_card_part() {
assert_eq!(CARD_ANATOMY.scope(), "card");
for part in [
"root",
"header",
"title",
"description",
"content",
"footer",
] {
assert!(CARD_ANATOMY.contains(part));
}
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Card recipe — static named slots for a composable card surface.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const ROOT: &str = tw_join!(
"rounded-xl",
"border",
"bg-card",
"text-card-foreground",
"shadow-sm"
);
pub const HEADER: &str = tw_join!("flex", "flex-col", "gap-1.5", "p-6");
pub const TITLE: &str = tw_join!("leading-none", "font-semibold", "tracking-tight");
pub const DESCRIPTION: &str = tw_join!("text-sm", "text-muted-foreground");
pub const CONTENT: &str = tw_join!("p-6", "pt-0");
pub const FOOTER: &str = tw_join!("flex", "items-center", "p-6", "pt-0");
}
/// Optional inputs accepted by [`CardRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CardRecipeProps;
/// Static named classes for every Card part.
#[derive(Debug, Clone, Copy)]
pub struct CardRecipe {
pub root: &'static str,
pub header: &'static str,
pub title: &'static str,
pub description: &'static str,
pub content: &'static str,
pub footer: &'static str,
}
impl CardRecipe {
pub const fn resolve(&'static self, _props: CardRecipeProps) -> CardStyle {
CardStyle { recipe: self }
}
}
/// Fully resolved, streamable Card root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct CardStyle {
recipe: &'static CardRecipe,
}
impl AttrValue for CardStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
pub const CARD_RECIPE: CardRecipe = CardRecipe {
root: base::ROOT,
header: base::HEADER,
title: base::TITLE,
description: base::DESCRIPTION,
content: base::CONTENT,
footer: base::FOOTER,
};
pub const fn card_recipe() -> &'static CardRecipe {
&CARD_RECIPE
}
impl crate::variant_spec::ComponentSpec for CardRecipe {
const NAME: &'static str = "Card";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::CARD_ANATOMY)
}
}
/// Compatibility accessors for the former standalone helpers.
pub const fn card_classes() -> &'static str {
CARD_RECIPE.root
}
pub const fn card_header_classes() -> &'static str {
CARD_RECIPE.header
}
pub const fn card_title_classes() -> &'static str {
CARD_RECIPE.title
}
pub const fn card_description_classes() -> &'static str {
CARD_RECIPE.description
}
pub const fn card_content_classes() -> &'static str {
CARD_RECIPE.content
}
pub const fn card_footer_classes() -> &'static str {
CARD_RECIPE.footer
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = card_recipe();
assert!(recipe.root.contains("bg-card"));
assert!(recipe.header.contains("flex-col"));
assert!(recipe.title.contains("font-semibold"));
assert!(recipe.description.contains("text-muted-foreground"));
assert!(recipe.content.contains("pt-0"));
assert!(recipe.footer.contains("items-center"));
}
#[test]
fn recipe_is_const_and_copy() {
const STYLE: CardStyle = CARD_RECIPE.resolve(CardRecipeProps);
let copied = STYLE;
assert!(copied.recipe.root.contains("rounded-xl"));
}
#[test]
fn recipe_metadata_exposes_the_complete_anatomy() {
let metadata = crate::variant_spec::component_metadata(card_recipe());
let anatomy = metadata.anatomy.as_ref().unwrap();
assert_eq!(metadata.name, "Card");
assert!(metadata.variants.is_empty());
assert_eq!(anatomy.scope, "card");
assert_eq!(anatomy.parts, super::super::CARD_ANATOMY.parts());
metadata.validate().unwrap();
}
}