Card
Displays a card with header, content, and footer.
Basic Card
A simple card with header, content, and footer sections.
Notifications
You have 3 unread messages.
Your recent notifications will appear here. Stay up to date with the latest updates from your team.
Card with Form
Cards can contain form elements for user input.
Create Project
Deploy your new project in one click.
Multiple Cards
Cards in a grid layout showing different content.
Total Revenue
January - DATE
+20.1% from last month
Subscriptions
Active subscribers
+180.1% from last month
Active Now
Current online users
+201 since last hour
Card with Badge
Cards combined with badges for status indicators.
API Key
ActiveManage your API key and permissions.
Custom Styling
Cards can be customized with additional classes.
Info
This is an informational card with custom blue styling.
Success
This is a success card with custom green styling.
Composable content containers.
$ proa ui add cardInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// 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.
// 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));
}
}
}
// 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}"
);
}
}
// 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));
}
}
}
// 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();
}
}