use serde_json::Value;
use crate::action::HttpMethod;
use crate::component::{
ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonProps, ButtonType,
CalendarCellProps, ChecklistProps, DescriptionListProps, DropdownMenuAction, EmptyStateProps,
HeaderProps, IconPosition, ImageProps, NotificationDropdownProps, Orientation, PaginationProps,
ProgressProps, RawHtmlProps, SeparatorProps, SidebarNavItem, SidebarProps, Size, SkeletonProps,
StatCardProps, StreamTextProps, TextElement, TextProps, TileProps, ToastProps, Tone, Variant,
};
use crate::spec::{Element, Spec};
use super::classes::{
DISABLED_BASE, FOCUS_RING, HIT_TARGET_MIN, HIT_TARGET_NUMPAD, INTERACTIVE_BASE, MOTION_BASE,
MOTION_FAST, PRESS_ACTIVE, TAP_HIGHLIGHT, TOAST_TONE_DESTRUCTIVE, TOAST_TONE_NEUTRAL,
TOAST_TONE_SUCCESS, TOAST_TONE_WARNING, TOUCH_ACTION,
};
use super::html_escape;
fn decode_diagnostic(type_name: &str, err: impl std::fmt::Display) -> String {
format!(
"<!-- ferro-json-ui: failed to decode {} props: {} -->",
type_name,
html_escape(&err.to_string())
)
}
fn decode_props<TProps: serde::de::DeserializeOwned>(
props: &Value,
) -> Result<TProps, serde_json::Error> {
if props.is_null() {
serde_json::from_value(Value::Object(serde_json::Map::new()))
} else {
serde_json::from_value(props.clone())
}
}
const ICON_INFO: &str = concat!(
"<span aria-hidden=\"true\" class=\"shrink-0\">",
"<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
"<path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\" clip-rule=\"evenodd\"/>",
"</svg></span>"
);
const ICON_SUCCESS: &str = concat!(
"<span aria-hidden=\"true\" class=\"shrink-0\">",
"<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
"<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\" clip-rule=\"evenodd\"/>",
"</svg></span>"
);
const ICON_WARNING: &str = concat!(
"<span aria-hidden=\"true\" class=\"shrink-0\">",
"<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
"<path fill-rule=\"evenodd\" d=\"M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z\" clip-rule=\"evenodd\"/>",
"</svg></span>"
);
const ICON_ERROR: &str = concat!(
"<span aria-hidden=\"true\" class=\"shrink-0\">",
"<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
"<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\"/>",
"</svg></span>"
);
const SHIMMER_CSS: &str = concat!(
"<style>",
"@keyframes ferro-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}",
".ferro-shimmer{",
"background:linear-gradient(90deg,var(--color-card,#f1f5f9) 25%,var(--color-border,#e2e8f0) 50%,var(--color-card,#f1f5f9) 75%);",
"background-size:200% 100%;",
"animation:ferro-shimmer 1.5s ease-in-out infinite;",
"}",
"</style>"
);
const BREADCRUMB_SEP: &str = concat!(
"<span aria-hidden=\"true\" class=\"text-text-muted\">",
"<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
"<path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>",
"</svg></span>"
);
const BELL_SVG: &str = concat!(
"<svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">",
"<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" ",
"d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/>",
"</svg>"
);
pub(crate) fn render_text(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: TextProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Text", e),
};
let content = html_escape(&props.content);
match props.element {
TextElement::P => format!("<p class=\"text-base leading-relaxed text-text\">{content}</p>"),
TextElement::H1 => format!("<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">{content}</h1>"),
TextElement::H2 => format!("<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">{content}</h2>"),
TextElement::H3 => format!("<h3 class=\"text-xl font-semibold leading-snug text-text\">{content}</h3>"),
TextElement::Span => format!("<span class=\"text-base text-text\">{content}</span>"),
TextElement::Div => format!("<div class=\"text-base leading-relaxed text-text\">{content}</div>"),
TextElement::Section => format!("<section class=\"text-base leading-relaxed text-text\">{content}</section>"),
}
}
fn render_button_inner(props: &ButtonProps) -> String {
let base = format!(
"inline-flex items-center justify-center rounded-md font-medium {INTERACTIVE_BASE} {DISABLED_BASE}"
);
let variant_classes = match props.variant {
Variant::Primary => "bg-primary text-primary-foreground hover:bg-primary/90",
Variant::Secondary => "bg-secondary text-secondary-foreground hover:bg-secondary/90",
Variant::Outline => "border border-border bg-background text-text hover:bg-surface",
Variant::Ghost => "text-text hover:bg-surface",
Variant::Destructive => "bg-destructive text-primary-foreground hover:bg-destructive/90",
};
let size_classes = match props.size {
Size::Sm => "px-3 py-1.5 text-sm",
Size::Md => "px-4 py-2 text-sm",
Size::Lg => "px-6 py-3 text-base",
};
let disabled_classes = if props.disabled == Some(true) {
" pointer-events-none opacity-50"
} else {
""
};
let disabled_attr = if props.disabled == Some(true) {
" disabled aria-disabled=\"true\""
} else {
""
};
let label = html_escape(&props.label);
let content = if let Some(ref icon) = props.icon {
let icon_span = format!(
"<span class=\"icon\" data-icon=\"{}\">{}</span>",
html_escape(icon),
html_escape(icon)
);
let position = props.icon_position.as_ref().cloned().unwrap_or_default();
match position {
IconPosition::Left => format!("{icon_span} {label}"),
IconPosition::Right => format!("{label} {icon_span}"),
}
} else {
label
};
let type_attr = match props.button_type.as_ref() {
Some(ButtonType::Button) => " type=\"button\"",
Some(ButtonType::Submit) => " type=\"submit\"",
None => "",
};
let form_attr = match props.form.as_deref() {
Some(f) => format!(" form=\"{}\"", html_escape(f)),
None => String::new(),
};
let disable_on_submit_attr = if props.disable_on_submit == Some(true) {
" data-disable-on-submit"
} else {
""
};
format!(
"<button{type_attr}{form_attr}{disable_on_submit_attr} class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
)
}
pub(crate) fn render_button(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: ButtonProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Button", e),
};
if let Some(action) = &el.action {
if matches!(action.method, HttpMethod::Get) {
if props.disabled == Some(true) {
return render_button_inner(&props);
}
let mut anchored_props = props.clone();
anchored_props.button_type = Some(ButtonType::Button);
let inner_html = render_button_inner(&anchored_props);
let target_attr = match action.target.as_deref() {
Some(t) => format!(" target=\"{}\" rel=\"noopener noreferrer\"", html_escape(t)),
None => String::new(),
};
let (href, diagnostic) = match &action.url {
Some(u) => (html_escape(u), String::new()),
None => (
"#".to_string(),
format!(
"<!-- ferro-json-ui: action '{}' has no resolved url -->",
html_escape(action.handler.as_str())
),
),
};
return format!(
"{diagnostic}<a href=\"{href}\" class=\"block\"{target_attr}>{inner_html}</a>"
);
}
}
render_button_inner(&props)
}
pub(crate) fn render_badge(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: BadgeProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Badge", e),
};
badge_inline_html(props.tone, &props.label)
}
pub(crate) fn badge_inline_html(tone: Tone, label: &str) -> String {
let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
let tone_classes = match tone {
Tone::Neutral => "border border-border text-text",
Tone::Success => "bg-success/10 text-success",
Tone::Warning => "bg-warning/10 text-warning",
Tone::Destructive => "bg-destructive/10 text-destructive",
};
format!(
"<span class=\"{} {}\" style=\"justify-self: start;\">{}</span>",
base,
tone_classes,
html_escape(label)
)
}
pub(crate) fn render_alert(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: AlertProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Alert", e),
};
let tone_classes = match props.tone {
Tone::Neutral => "bg-surface border-border text-text",
Tone::Success => "bg-success/10 border-success text-success",
Tone::Warning => "bg-warning/10 border-warning text-warning",
Tone::Destructive => "bg-destructive/10 border-destructive text-destructive",
};
let icon = match props.tone {
Tone::Neutral => ICON_INFO,
Tone::Success => ICON_SUCCESS,
Tone::Warning => ICON_WARNING,
Tone::Destructive => ICON_ERROR,
};
let mut html = format!(
"<div role=\"alert\" class=\"rounded-md border p-4 flex items-start gap-3 {tone_classes}\">"
);
html.push_str(icon);
html.push_str("<div>");
if let Some(ref title) = props.title {
html.push_str(&format!(
"<h4 class=\"font-semibold mb-1\">{}</h4>",
html_escape(title)
));
}
html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
html.push_str("</div>");
html.push_str("</div>");
html
}
pub(crate) fn render_separator(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: SeparatorProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Separator", e),
};
let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
match orientation {
Orientation::Horizontal => "<hr class=\"my-4 border-border\">".to_string(),
Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-border\"></div>".to_string(),
}
}
pub(crate) fn render_progress(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: ProgressProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Progress", e),
};
let max = props.max.unwrap_or(100) as f64;
let pct = if max > 0.0 {
((props.value as f64 * 100.0 / max).round() as u8).min(100)
} else {
0
};
let mut html = String::from("<div class=\"w-full\" role=\"progressbar\">");
if let Some(ref label) = props.label {
html.push_str(&format!(
"<div class=\"mb-1 text-sm text-text-muted\">{}</div>",
html_escape(label)
));
}
html.push_str(&format!(
"<div class=\"w-full rounded-full bg-border h-2.5\"><div class=\"rounded-full bg-primary h-2.5\" style=\"width: {pct}%\"></div></div>"
));
html.push_str("</div>");
html
}
pub(crate) fn render_avatar(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: AvatarProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Avatar", e),
};
let size = props.size.unwrap_or_default();
let size_classes = match size {
Size::Sm => "h-8 w-8 text-sm",
Size::Md => "h-10 w-10 text-sm",
Size::Lg => "h-12 w-12 text-base",
};
if let Some(ref src) = props.src {
format!(
"<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
html_escape(src),
html_escape(&props.alt),
size_classes
)
} else {
let fallback_text = props.fallback.as_deref().unwrap_or(&props.alt);
let initials: String = fallback_text.chars().take(2).collect();
format!(
"<span class=\"inline-flex items-center justify-center rounded-full bg-card text-text-muted {}\">{}</span>",
size_classes,
html_escape(&initials)
)
}
}
pub(crate) fn render_image(el: &Element, _spec: &Spec, data: &Value, _depth: usize) -> String {
let props: ImageProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Image", e),
};
if let Some(ref svg) = props.inline_svg {
return format!(
"<div aria-label=\"{}\">{}</div>",
html_escape(&props.alt),
svg );
}
let resolved_src = props
.data_path
.as_deref()
.and_then(|p| crate::data::resolve_path(data, p))
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| props.src.clone());
let container_style = match &props.aspect_ratio {
Some(ratio) => format!(" style=\"aspect-ratio: {}\"", html_escape(ratio)),
None => String::new(),
};
let placeholder = match &props.placeholder_label {
Some(label) => format!(
"<div class=\"absolute inset-0 flex items-center justify-center \
rounded-md bg-surface text-xs text-text-muted\">{}</div>",
html_escape(label)
),
None => String::from("<div class=\"absolute inset-0 rounded-md bg-surface\"></div>"),
};
format!(
"<div class=\"relative w-full\"{container_style}>\
{placeholder}\
<img src=\"{src}\" alt=\"{alt}\" \
class=\"relative w-full h-full rounded-md object-cover object-top\" \
loading=\"lazy\" onerror=\"this.style.display='none'\">\
</div>",
src = html_escape(&resolved_src),
alt = html_escape(&props.alt),
)
}
pub(crate) fn render_skeleton(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: SkeletonProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Skeleton", e),
};
let width = props.width.as_deref().unwrap_or("100%");
let height = props.height.as_deref().unwrap_or("1rem");
let rounded = if props.rounded == Some(true) {
"rounded-full"
} else {
"rounded-md"
};
format!(
"{SHIMMER_CSS}<div class=\"ferro-shimmer {rounded}\" style=\"width: {}; height: {}\"></div>",
html_escape(width),
html_escape(height)
)
}
pub(crate) fn render_breadcrumb(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: BreadcrumbProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Breadcrumb", e),
};
let mut html =
String::from("<nav class=\"flex items-center space-x-2 text-sm text-text-muted\">");
let len = props.items.len();
for (i, item) in props.items.iter().enumerate() {
let is_last = i == len - 1;
if is_last {
html.push_str(&format!(
"<span class=\"text-text font-medium\">{}</span>",
html_escape(&item.label)
));
} else if let Some(ref url) = item.url {
html.push_str(&format!(
"<a href=\"{}\" class=\"hover:text-text {INTERACTIVE_BASE}\">{}</a>",
html_escape(url),
html_escape(&item.label)
));
} else {
html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
}
if !is_last {
html.push_str(BREADCRUMB_SEP);
}
}
html.push_str("</nav>");
html
}
pub(crate) fn render_pagination(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: PaginationProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Pagination", e),
};
if props.total == 0 || props.per_page == 0 {
return String::new();
}
let total_pages = props.total.div_ceil(props.per_page);
if total_pages <= 1 {
return String::new();
}
let base_url = props.base_url.as_deref().unwrap_or("?");
let current = props.current_page.clamp(1, total_pages);
let base_url_escaped = html_escape(base_url);
let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
if current > 1 {
let prev = current - 1;
html.push_str(&format!(
"<a href=\"{base_url_escaped}page={prev}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface {INTERACTIVE_BASE}\">«</a>"
));
}
let pages = compute_page_range(current, total_pages);
let mut prev_page = 0u32;
for page in pages {
if prev_page > 0 && page > prev_page + 1 {
html.push_str("<span class=\"px-2 text-text-muted\">…</span>");
}
if page == current {
html.push_str(&format!(
"<span class=\"px-3 py-1 rounded-md bg-primary text-primary-foreground\">{page}</span>"
));
} else {
html.push_str(&format!(
"<a href=\"{base_url_escaped}page={page}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface {INTERACTIVE_BASE}\">{page}</a>"
));
}
prev_page = page;
}
if current < total_pages {
let next = current + 1;
html.push_str(&format!(
"<a href=\"{base_url_escaped}page={next}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface {INTERACTIVE_BASE}\">»</a>"
));
}
html.push_str("</nav>");
html
}
fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
if total <= 7 {
return (1..=total).collect();
}
let mut pages = Vec::new();
pages.push(1);
let start = current.saturating_sub(1).max(2);
let end = (current + 1).min(total - 1);
for p in start..=end {
if !pages.contains(&p) {
pages.push(p);
}
}
if !pages.contains(&total) {
pages.push(total);
}
pages.sort();
pages.dedup();
pages
}
pub(crate) fn render_description_list(
el: &Element,
_spec: &Spec,
data: &Value,
_depth: usize,
) -> String {
let props: DescriptionListProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("DescriptionList", e),
};
let items: Vec<crate::component::DescriptionItem> = props
.data_path
.as_deref()
.and_then(|p| crate::data::resolve_path(data, p))
.and_then(|v| v.as_array().cloned())
.map(|arr| {
arr.into_iter()
.filter_map(|v| serde_json::from_value(v).ok())
.collect()
})
.unwrap_or_else(|| props.items.clone());
let columns = props.columns.unwrap_or(1);
let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
for item in &items {
html.push_str(&format!(
"<div><dt class=\"text-sm font-medium text-text-muted\">{}</dt><dd class=\"mt-1 text-sm text-text\">{}</dd></div>",
html_escape(&item.label),
html_escape(&item.value)
));
}
html.push_str("</dl>");
html
}
pub(crate) fn render_empty_state(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: EmptyStateProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("EmptyState", e),
};
let mut html = String::from(
"<div class=\"rounded-lg border border-border bg-card min-h-40 py-8 px-6 flex items-center justify-center\">\
<div class=\"text-center max-w-md\">",
);
if !props.title.is_empty() {
html.push_str(&format!(
"<h3 class=\"text-base font-semibold text-text mb-2\">{}</h3>",
html_escape(&props.title)
));
}
if let Some(ref desc) = props.description {
html.push_str(&format!(
"<p class=\"text-sm text-text-muted\">{}</p>",
html_escape(desc)
));
}
if let Some(ref action) = props.action {
let label = props.action_label.as_deref().unwrap_or("Action");
let url = action.url.as_deref().unwrap_or("#");
html.push_str(&format!(
"<a href=\"{}\" class=\"mt-4 inline-flex items-center justify-center rounded-md \
border border-border bg-card text-text px-4 py-2 text-sm font-medium \
hover:bg-surface {INTERACTIVE_BASE}\">{}</a>",
html_escape(url),
html_escape(label)
));
}
html.push_str("</div></div>");
html
}
pub(crate) fn render_stat_card(el: &Element, _spec: &Spec, data: &Value, _depth: usize) -> String {
let props: StatCardProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("StatCard", e),
};
let display_value = props
.value_path
.as_deref()
.and_then(|p| crate::data::resolve_path_string(data, p))
.unwrap_or_else(|| props.value.clone());
let (value_class, icon_class) = match props.tone {
Tone::Neutral => ("text-text", ""),
Tone::Success => ("text-success", " text-success"),
Tone::Warning => ("text-warning", " text-warning"),
Tone::Destructive => ("text-destructive", " text-destructive"),
};
let mut html =
String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
if let Some(ref icon) = props.icon {
html.push_str(&format!(
"<span class=\"inline-block mb-2 w-6 h-6{icon_class}\">{icon}</span>"
));
}
html.push_str(&format!(
"<p class=\"text-sm text-text-muted\">{}</p>",
html_escape(&props.label)
));
if let Some(ref sse) = props.sse_target {
html.push_str(&format!(
"<p class=\"text-2xl font-bold {value_class}\" data-sse-target=\"{}\" data-live-value>{}</p>",
html_escape(sse),
html_escape(&display_value)
));
} else {
html.push_str(&format!(
"<p class=\"text-2xl font-bold {value_class}\">{}</p>",
html_escape(&display_value)
));
}
if let Some(ref subtitle) = props.subtitle {
html.push_str(&format!(
"<p class=\"text-xs text-text-muted mt-1\">{}</p>",
html_escape(subtitle)
));
}
html.push_str("</div>");
html
}
pub(crate) fn render_checklist(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: ChecklistProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Checklist", e),
};
let mut html =
String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
html.push_str("<div class=\"flex items-center justify-between mb-3\">");
html.push_str(&format!(
"<h3 class=\"text-sm font-semibold leading-snug text-text\">{}</h3>",
html_escape(&props.title)
));
if props.dismissible {
let dismiss_label = props.dismiss_label.as_deref().unwrap_or("Dismiss");
html.push_str(&format!(
"<button type=\"button\" class=\"text-xs font-medium text-text hover:text-primary {INTERACTIVE_BASE}\" data-dismissible>{}</button>",
html_escape(dismiss_label)
));
}
html.push_str("</div>");
if let Some(ref key) = props.data_key {
html.push_str(&format!(
"<div data-checklist-key=\"{}\">",
html_escape(key)
));
} else {
html.push_str("<div>");
}
if props.dismissible {
html.push_str("<ul data-dismissible class=\"space-y-2\">");
} else {
html.push_str("<ul class=\"space-y-2\">");
}
for item in &props.items {
html.push_str("<li class=\"flex items-center gap-2\">");
if item.checked {
html.push_str(&format!(
"<input type=\"checkbox\" checked class=\"h-4 w-4 rounded-sm border-border text-primary {MOTION_FAST} {FOCUS_RING}\">"
));
} else {
html.push_str(&format!(
"<input type=\"checkbox\" class=\"h-4 w-4 rounded-sm border-border text-primary {MOTION_FAST} {FOCUS_RING}\">"
));
}
let label_class = if item.checked {
"text-sm line-through text-text-muted"
} else {
"text-sm text-text"
};
if let Some(ref href) = item.href {
html.push_str(&format!(
"<a href=\"{}\" class=\"{} {INTERACTIVE_BASE}\">{}</a>",
html_escape(href),
label_class,
html_escape(&item.label)
));
} else {
html.push_str(&format!(
"<span class=\"{}\">{}</span>",
label_class,
html_escape(&item.label)
));
}
html.push_str("</li>");
}
html.push_str("</ul></div></div>");
html
}
pub(crate) fn render_toast(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: ToastProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Toast", e),
};
let tone_classes = match props.tone {
Tone::Neutral => TOAST_TONE_NEUTRAL,
Tone::Success => TOAST_TONE_SUCCESS,
Tone::Warning => TOAST_TONE_WARNING,
Tone::Destructive => TOAST_TONE_DESTRUCTIVE,
};
let tone_str = props.tone.as_ref();
let timeout = props.timeout.unwrap_or(5);
let timeout = if props.dismissible {
timeout
} else {
timeout.max(1)
};
let close_button = if props.dismissible {
"<button class=\"text-current opacity-70 hover:opacity-100 text-lg leading-none\" \
data-toast-close>×</button>"
} else {
""
};
format!(
"<div class=\"fixed top-4 right-4 z-50 flex items-start gap-3 rounded-lg px-4 py-3 shadow-lg max-w-sm {MOTION_BASE} backdrop-blur-md {tone_classes}\" \
data-toast-tone=\"{tone_str}\" data-toast-timeout=\"{timeout}\">\
<span class=\"flex-1 text-sm\">{}</span>\
{close_button}\
</div>",
html_escape(&props.message)
)
}
pub(crate) fn render_notification_dropdown(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: NotificationDropdownProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("NotificationDropdown", e),
};
let unread_count = props.notifications.iter().filter(|n| !n.read).count();
let mut html = String::from("<div class=\"relative\" data-notification-dropdown>");
html.push_str(&format!(
"<button type=\"button\" class=\"relative p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\" data-notification-count=\"{unread_count}\">"
));
html.push_str(BELL_SVG);
if unread_count > 0 {
html.push_str(&format!(
"<span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{unread_count}</span>"
));
}
html.push_str("</button>");
html.push_str(
"<div class=\"hidden absolute right-0 mt-2 w-80 bg-card rounded-lg shadow-lg border border-border z-50\" data-notification-panel>",
);
if props.notifications.is_empty() {
let empty = props.empty_text.as_deref().unwrap_or("No notifications");
html.push_str(&format!(
"<p class=\"p-4 text-sm text-text-muted\">{}</p>",
html_escape(empty)
));
} else {
html.push_str("<ul class=\"divide-y divide-border\">");
for item in &props.notifications {
html.push_str("<li class=\"flex items-start gap-3 p-3\">");
if let Some(ref icon) = item.icon {
html.push_str(&format!(
"<span class=\"text-lg shrink-0\">{}</span>",
html_escape(icon)
));
}
html.push_str("<div class=\"flex-1 min-w-0\">");
if let Some(ref url) = item.action_url {
html.push_str(&format!(
"<a href=\"{}\" class=\"text-sm text-text hover:underline {INTERACTIVE_BASE}\">{}</a>",
html_escape(url),
html_escape(&item.text)
));
} else {
html.push_str(&format!(
"<p class=\"text-sm text-text\">{}</p>",
html_escape(&item.text)
));
}
if let Some(ref ts) = item.timestamp {
html.push_str(&format!(
"<p class=\"text-xs text-text-muted mt-0.5\">{}</p>",
html_escape(ts)
));
}
html.push_str("</div>");
if !item.read {
html.push_str(
"<span class=\"h-2 w-2 mt-1 shrink-0 rounded-full bg-primary\"></span>",
);
}
html.push_str("</li>");
}
html.push_str("</ul>");
}
html.push_str("</div></div>");
html
}
pub(crate) fn render_sidebar(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: SidebarProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Sidebar", e),
};
let mut html =
String::from("<aside class=\"flex flex-col h-full bg-background border-r border-border\">");
if !props.fixed_top.is_empty() {
html.push_str("<nav class=\"p-4 space-y-1\">");
for item in &props.fixed_top {
html.push_str(&render_sidebar_nav_item(item));
}
html.push_str("</nav>");
}
if !props.groups.is_empty() {
html.push_str("<div class=\"flex-1 overflow-y-auto p-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
for group in &props.groups {
html.push_str("<div data-sidebar-group");
if group.collapsed {
html.push_str(" data-collapsed");
}
html.push('>');
html.push_str(&format!(
"<p class=\"px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wider\">{}</p>",
html_escape(&group.label)
));
html.push_str("<nav class=\"space-y-1\">");
for item in &group.items {
html.push_str(&render_sidebar_nav_item(item));
}
html.push_str("</nav></div>");
}
html.push_str("</div>");
}
if !props.fixed_bottom.is_empty() {
html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
for item in &props.fixed_bottom {
html.push_str(&render_sidebar_nav_item(item));
}
html.push_str("</nav>");
}
html.push_str("</aside>");
html
}
fn render_sidebar_nav_item(item: &SidebarNavItem) -> String {
let disabled = item.disabled.unwrap_or(false);
let (tag, classes) = if disabled {
(
"span",
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted opacity-50 pointer-events-none select-none".to_string(),
)
} else if item.active {
(
"a",
format!(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary {INTERACTIVE_BASE}"
),
)
} else {
(
"a",
format!(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface {INTERACTIVE_BASE}"
),
)
};
let mut html = if disabled {
format!("<{tag} aria-disabled=\"true\" class=\"{classes}\">",)
} else {
format!(
"<{tag} href=\"{}\" class=\"{classes}\">",
html_escape(&item.href),
)
};
if let Some(ref icon) = item.icon {
html.push_str(&format!(
"<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>"
));
}
html.push_str(&format!("{}</{tag}>", html_escape(&item.label)));
html
}
pub(crate) fn render_header(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: HeaderProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Header", e),
};
let mut html = String::from(
"<header class=\"relative flex items-center justify-between px-6 py-4 bg-background border-b border-border\">",
);
html.push_str("<div></div>");
html.push_str(&format!(
"<span class=\"absolute left-1/2 -translate-x-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
html_escape(&props.business_name)
));
html.push_str("<div class=\"flex items-center gap-4\">");
if let Some(count) = props.notification_count {
if count > 0 {
html.push_str(&format!(
"<div class=\"relative\"><span class=\"text-text-muted\">{BELL_SVG}</span><span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\" data-notification-count=\"{count}\">{count}</span></div>"
));
} else {
html.push_str(&format!(
"<span class=\"text-text-muted\" data-notification-count=\"{count}\">{BELL_SVG}</span>"
));
}
}
html.push_str("<div class=\"flex items-center gap-2\">");
if let Some(ref avatar) = props.user_avatar {
html.push_str(&format!(
"<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
html_escape(avatar)
));
} else if let Some(ref name) = props.user_name {
let initials: String = name
.split_whitespace()
.filter_map(|w| w.chars().next())
.take(2)
.collect();
html.push_str(&format!(
"<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full bg-card text-text-muted text-sm font-medium\">{}</span>",
html_escape(&initials)
));
html.push_str(&format!(
"<span class=\"text-sm text-text\">{}</span>",
html_escape(name)
));
}
if let Some(ref logout) = props.logout_url {
html.push_str(&format!(
"<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text {INTERACTIVE_BASE}\">Logout</a>",
html_escape(logout)
));
}
html.push_str("</div></div></header>");
html
}
pub(crate) fn render_menu_item(
item: &DropdownMenuAction,
normal_class: &str,
destructive_class: &str,
role_attr: &str,
) -> String {
let url = match item.action.url.as_deref().filter(|s| !s.is_empty()) {
Some(u) => u,
None => {
let h = item.action.handler.as_str();
if h.is_empty() {
"#"
} else {
h
}
}
};
let class_attr = if item.destructive {
destructive_class
} else {
normal_class
};
let (confirm_attrs, onclick) = if let Some(ref confirm) = item.action.confirm {
let mut attrs = format!(" data-confirm-title=\"{}\"", html_escape(&confirm.title));
if let Some(ref message) = confirm.message {
attrs.push_str(&format!(
" data-confirm-message=\"{}\"",
html_escape(message)
));
}
(
attrs,
" onclick=\"return confirm(this.dataset.confirmTitle || this.dataset.confirmMessage)\"",
)
} else {
(String::new(), "")
};
match item.action.method {
HttpMethod::Get => format!(
"<a href=\"{}\"{} class=\"{}\"{}{}>{}</a>",
html_escape(url),
role_attr,
class_attr,
confirm_attrs,
onclick,
html_escape(&item.label),
),
HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
let method_spoof = match item.action.method {
HttpMethod::Put => Some("PUT"),
HttpMethod::Patch => Some("PATCH"),
HttpMethod::Delete => Some("DELETE"),
_ => None,
};
let mut html = format!("<form action=\"{}\" method=\"post\">", html_escape(url));
if let Some(m) = method_spoof {
html.push_str(&format!(
"<input type=\"hidden\" name=\"_method\" value=\"{m}\">"
));
}
html.push_str(&format!(
"<button type=\"submit\"{} class=\"w-full text-left {}\"{}{}>{}</button>",
role_attr,
class_attr,
confirm_attrs,
onclick,
html_escape(&item.label),
));
html.push_str("</form>");
html
}
}
}
pub(crate) fn render_calendar_cell(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: CalendarCellProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("CalendarCell", e),
};
let opacity = if props.is_current_month {
""
} else {
" opacity-40"
};
let hover = if props.is_current_month {
format!(" hover:bg-surface/60 {MOTION_FAST} cursor-pointer")
} else {
" cursor-pointer".to_string()
};
let closed_pos = if props.closed { " relative" } else { "" };
let mut html = format!(
"<div class=\"flex flex-col min-h-[5rem] p-2 border border-border -mt-px -ml-px{opacity}{hover}{closed_pos}\">",
);
if props.is_today {
html.push_str(&format!(
"<span class=\"w-7 h-7 flex items-center justify-center text-sm font-semibold bg-primary text-primary-foreground rounded-full\">{}</span>",
props.day
));
} else {
html.push_str(&format!(
"<span class=\"text-sm text-text\">{}</span>",
props.day
));
}
let total = if !props.dot_colors.is_empty() {
props.dot_colors.len() as u32
} else {
props.event_count
};
if total > 0 && total <= 3 {
html.push_str("<div class=\"flex gap-1 mt-auto pt-1\">");
if props.dot_colors.is_empty() {
for _ in 0..total {
html.push_str("<span class=\"w-1.5 h-1.5 rounded-full bg-primary\"></span>");
}
} else {
for color in props.dot_colors.iter().take(3) {
html.push_str(&format!(
"<span class=\"w-1.5 h-1.5 rounded-full {}\"></span>",
html_escape(color)
));
}
}
html.push_str("</div>");
} else if total > 3 {
html.push_str(&format!(
"<span class=\"mt-auto pt-1 text-xs font-medium text-primary\">{total} prenot.</span>"
));
}
if props.closed {
html.push_str(
"<div class=\"pointer-events-none absolute inset-0 text-text-muted\" \
style=\"opacity:0.14;background-image:repeating-linear-gradient(45deg,transparent 0,transparent 5px,currentColor 5px,currentColor 6px)\" \
aria-hidden=\"true\"></div>",
);
}
html.push_str("</div>");
if let Some(action) = &el.action {
if matches!(action.method, HttpMethod::Get) {
let (href, diagnostic) = match &action.url {
Some(u) => (html_escape(u), String::new()),
None => (
"#".to_string(),
format!(
"<!-- ferro-json-ui: CalendarCell action '{}' has no resolved url -->",
html_escape(action.handler.as_str())
),
),
};
return format!(
"{diagnostic}<a href=\"{href}\" class=\"block {FOCUS_RING}\">{html}</a>"
);
}
}
html
}
pub(crate) fn render_action_card(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: ActionCardProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("ActionCard", e),
};
let border_class = match props.tone {
Tone::Neutral => "border-l-border",
Tone::Success => "border-l-success",
Tone::Warning => "border-l-warning",
Tone::Destructive => "border-l-destructive",
};
let (open_tag, close_tag) = if let Some(ref href) = props.href {
(
format!(
"<a href=\"{}\" aria-label=\"{}\" class=\"rounded-lg border-l-4 {} border border-border bg-card shadow-sm p-4 flex items-center gap-4 hover:bg-surface {INTERACTIVE_BASE} no-underline\">",
html_escape(href),
html_escape(&props.title),
border_class,
),
"</a>".to_string(),
)
} else {
(
format!(
"<div class=\"rounded-lg border-l-4 {border_class} border border-border bg-card shadow-sm p-4 flex items-center gap-4 cursor-pointer hover:bg-surface {INTERACTIVE_BASE}\">"
),
"</div>".to_string(),
)
};
let mut html = open_tag;
if let Some(ref icon) = props.icon {
html.push_str(&format!(
"<div class=\"w-10 h-10 flex-shrink-0 rounded-md bg-surface flex items-center justify-center text-text-muted\">{icon}</div>",
));
}
html.push_str(&format!(
"<div class=\"flex-1 min-w-0\"><p class=\"text-sm font-semibold text-text\">{}</p><p class=\"text-sm text-text-muted mt-0.5\">{}</p></div>",
html_escape(&props.title),
html_escape(&props.description)
));
html.push_str("<span class=\"text-text-muted flex-shrink-0 text-lg\">›</span>");
html.push_str(&close_tag);
html
}
pub(crate) fn render_tile(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
use crate::component::Tone;
let props: TileProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Tile", e),
};
let name = html_escape(&props.name);
let price = html_escape(&props.price);
let field = html_escape(&props.field);
let qty = props.default_quantity.unwrap_or(0);
let categories_attr = if props.categories.is_empty() {
String::new()
} else {
format!(
" data-filter-tokens=\"{}\"",
html_escape(
&props
.categories
.iter()
.map(|c| c.replace(' ', "-"))
.collect::<Vec<_>>()
.join(" ")
)
)
};
let unit_price_attr = match props.price_cents {
Some(cents) => format!(" data-unit-price=\"{cents}\""),
None => String::new(),
};
let border_class = match props.color {
None | Some(Tone::Neutral) => "border border-border",
Some(Tone::Success) => "border border-success",
Some(Tone::Warning) => "border border-warning",
Some(Tone::Destructive) => "border border-destructive",
};
let image_html = match &props.image_url {
Some(url) => {
let escaped_url = html_escape(url);
format!(
"<img src=\"{escaped_url}\" alt=\"{name}\" loading=\"lazy\" \
class=\"w-full aspect-square object-cover rounded-md\">"
)
}
None => String::new(),
};
let badge_html = match &props.stock_badge {
Some(badge) => {
let escaped_badge = html_escape(badge);
format!(
"<span class=\"inline-flex items-center rounded-full px-2.5 py-0.5 \
text-xs font-medium bg-secondary/10 text-secondary-foreground\">\
{escaped_badge}</span>"
)
}
None => String::new(),
};
format!(
"<div class=\"{border_class} bg-card rounded-lg {TOUCH_ACTION}\" \
data-filter-text=\"{name}\"{categories_attr}{unit_price_attr}>\
<button type=\"button\" data-qty-inc=\"{field}\" \
class=\"{HIT_TARGET_MIN} {TOUCH_ACTION} {PRESS_ACTIVE} {TAP_HIGHLIGHT} {INTERACTIVE_BASE} \
w-full flex flex-col gap-2 p-3 rounded-lg text-left\" \
aria-label=\"Add {name}\">\
{image_html}\
<span class=\"text-sm font-semibold text-text\">{name}</span>\
<span class=\"text-sm text-text-muted\">{price}</span>\
{badge_html}\
</button>\
<input type=\"hidden\" name=\"{field}\" data-qty-input=\"{field}\" value=\"{qty}\">\
</div>"
)
}
pub(crate) fn render_filter_tab_strip(items: &[String], all_label: &str) -> String {
let tab_base = format!(
"{HIT_TARGET_MIN} {TOUCH_ACTION} {INTERACTIVE_BASE} px-4 text-sm font-medium border-b-2"
);
let all_tab = format!(
"<button role=\"tab\" data-filter-tab=\"\" \
class=\"{tab_base} border-primary text-primary font-semibold\" \
aria-selected=\"true\">{}</button>",
html_escape(all_label)
);
let item_tabs: String = items
.iter()
.map(|item| {
let token = html_escape(&item.replace(' ', "-"));
let label = html_escape(item);
format!(
"<button role=\"tab\" data-filter-tab=\"{token}\" \
class=\"{tab_base} border-transparent text-text-muted hover:text-text\" \
aria-selected=\"false\">{label}</button>"
)
})
.collect();
format!("<div class=\"flex overflow-x-auto\" role=\"tablist\">{all_tab}{item_tabs}</div>")
}
pub(crate) fn render_filter_tabs(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
use crate::component::FilterTabsProps;
let props: FilterTabsProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("FilterTabs", e),
};
let all_label = props.all_label.as_deref().unwrap_or("All");
let strip = render_filter_tab_strip(&props.items, all_label);
format!("<div data-filter-scope class=\"w-full\">{strip}</div>")
}
pub(crate) fn render_raw_html(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
let props: RawHtmlProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("RawHtml", e),
};
format!("<div data-ferro-raw-html>{}</div>", props.html)
}
pub(crate) fn render_streamtext(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
let props: StreamTextProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("StreamText", e),
};
let escaped_url = html_escape(&props.sse_url);
let placeholder_html = props
.placeholder
.as_deref()
.map(|t| {
format!(
"<span data-ferro-stream-placeholder class=\"text-sm text-text-muted\">{}</span>",
html_escape(t)
)
})
.unwrap_or_default();
let loading_html = props
.loading_text
.as_deref()
.map(|t| {
format!(
"<span data-ferro-stream-loading class=\"text-sm text-text-muted\">{}</span>",
html_escape(t)
)
})
.unwrap_or_default();
format!("<div data-ferro-stream-url=\"{escaped_url}\">{placeholder_html}{loading_html}</div>")
}
pub(crate) fn render_quantity_stepper(
el: &Element,
_spec: &Spec,
_data: &Value,
_depth: usize,
) -> String {
use crate::component::QuantityStepperProps;
let props: QuantityStepperProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("QuantityStepper", e),
};
let field = html_escape(&props.field);
let initial = props.min.unwrap_or(0);
let min_attr = props
.min
.map(|v| format!(" data-qty-min=\"{v}\""))
.unwrap_or_default();
let max_attr = props
.max
.map(|v| format!(" data-qty-max=\"{v}\""))
.unwrap_or_default();
let step_attr = props
.step
.map(|v| format!(" data-qty-step=\"{v}\""))
.unwrap_or_default();
let btn_classes = format!(
"{HIT_TARGET_MIN} {TOUCH_ACTION} {PRESS_ACTIVE} flex items-center justify-center \
rounded-md border border-border bg-surface text-text text-lg font-semibold \
hover:bg-border {INTERACTIVE_BASE}"
);
format!(
"<div class=\"flex items-center gap-2\">\
<button type=\"button\" data-qty-dec=\"{field}\"{min_attr}{max_attr}{step_attr} \
class=\"{btn_classes}\" aria-label=\"Decrease {field}\">\u{2212}</button>\
<span data-qty-display=\"{field}\" \
class=\"text-sm font-semibold text-text min-w-[2ch] text-center\">{initial}</span>\
<button type=\"button\" data-qty-inc=\"{field}\"{min_attr}{max_attr}{step_attr} \
class=\"{btn_classes}\" aria-label=\"Increase {field}\">+</button>\
<input type=\"hidden\" name=\"{field}\" data-qty-input=\"{field}\" value=\"{initial}\">\
</div>"
)
}
pub(crate) fn render_numpad(el: &Element, _spec: &Spec, _data: &Value, _depth: usize) -> String {
use crate::component::{NumpadMode, NumpadProps};
let props: NumpadProps = match decode_props(&el.props) {
Ok(p) => p,
Err(e) => return decode_diagnostic("Numpad", e),
};
let field = html_escape(&props.target_field);
let mode_attr = match props.mode {
NumpadMode::Price => " data-numpad-mode=\"price\"",
NumpadMode::Quantity => "",
};
let key_classes = format!(
"{HIT_TARGET_NUMPAD} {TOUCH_ACTION} {PRESS_ACTIVE} {TAP_HIGHLIGHT} flex items-center \
justify-center rounded-md border border-border bg-surface text-text text-lg \
font-semibold hover:bg-border {INTERACTIVE_BASE}"
);
let key_buttons: String = [
("1", "1"),
("2", "2"),
("3", "3"),
("4", "4"),
("5", "5"),
("6", "6"),
("7", "7"),
("8", "8"),
("9", "9"),
("clear", "Clear"),
("0", "0"),
("backspace", "Backspace"),
]
.iter()
.map(|(key_val, aria_label)| {
let glyph = match *key_val {
"backspace" => "\u{232B}",
"clear" => "C",
_ => key_val,
};
format!(
"<button type=\"button\" data-numpad-key=\"{key_val}\" \
class=\"{key_classes}\" aria-label=\"{aria_label}\">{glyph}</button>"
)
})
.collect();
format!(
"<div class=\"flex flex-col gap-2\">\
<div data-numpad data-numpad-target=\"{field}\"{mode_attr} \
class=\"flex flex-col gap-2\">\
<div data-numpad-display \
class=\"text-2xl font-semibold text-text text-right px-2\">0</div>\
<div class=\"grid grid-cols-3 gap-1\">{key_buttons}</div>\
</div>\
<input type=\"hidden\" name=\"{field}\" data-numpad-input=\"{field}\" value=\"\">\
</div>"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::{Action, HttpMethod};
use crate::spec::{Element, Spec};
use serde_json::json;
fn spec_with_root(el: crate::spec::ElementBuilder) -> Spec {
Spec::builder()
.element("root", el)
.build()
.expect("trivial spec builds")
}
#[test]
fn text_emits_paragraph_by_default() {
let spec = spec_with_root(Element::new("Text").prop("content", "Hello"));
let el = spec.elements.get("root").unwrap();
let html = render_text(el, &spec, &json!({}), 1);
assert!(html.contains("Hello"), "got: {html}");
assert!(html.contains("<p"), "got: {html}");
assert!(html.contains("</p>"), "got: {html}");
}
#[test]
fn text_h1_variant() {
let spec = spec_with_root(
Element::new("Text")
.prop("content", "Title")
.prop("element", "h1"),
);
let el = spec.elements.get("root").unwrap();
let html = render_text(el, &spec, &json!({}), 1);
assert!(html.contains("<h1"), "got: {html}");
assert!(html.contains("Title"));
}
#[test]
fn text_html_escaping_in_content() {
let spec =
spec_with_root(Element::new("Text").prop("content", "<script>alert(1)</script>"));
let el = spec.elements.get("root").unwrap();
let html = render_text(el, &spec, &json!({}), 1);
assert!(html.contains("<script>"), "got: {html}");
assert!(
!html.contains("<script>"),
"raw script tag must not appear; got: {html}"
);
}
#[test]
fn button_bare_without_action_emits_button_tag() {
let spec = spec_with_root(Element::new("Button").prop("label", "Go"));
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("<button"), "got: {html}");
assert!(html.contains("Go"));
assert!(
!html.contains("<a href"),
"no wrapping <a> without action; got: {html}"
);
}
#[test]
fn button_get_action_wraps_in_anchor() {
let el_builder = Element::new("Button").prop("label", "Go").action(Action {
handler: "users.list".into(),
url: Some("/users".into()),
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: None,
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("<a href=\"/users\""), "got: {html}");
assert!(
html.contains("<button"),
"inner button still emitted; got: {html}"
);
}
#[test]
fn button_get_action_inner_button_has_type_button() {
let el_builder = Element::new("Button")
.prop("label", "Annulla")
.action(Action {
handler: "products.show".into(),
url: Some("/dashboard/cassa/prodotti/1".into()),
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: None,
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("<a href=\"/dashboard/cassa/prodotti/1\""));
assert!(
html.contains("<button type=\"button\""),
"GET-action inner button must carry type=\"button\"; got: {html}"
);
}
#[test]
fn button_action_url_none_uses_href_hash_with_diagnostic() {
let el_builder = Element::new("Button").prop("label", "Go").action(Action {
handler: "users.list".into(),
url: None,
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: None,
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("href=\"#\""), "got: {html}");
assert!(
html.contains("<!-- ferro-json-ui: action 'users.list' has no resolved url -->"),
"got: {html}"
);
}
#[test]
fn button_disabled_skips_anchor_wrap_and_carries_aria_disabled() {
let el_builder = Element::new("Button")
.prop("label", "Go")
.prop("disabled", true)
.action(Action {
handler: "users.list".into(),
url: Some("/users".into()),
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: None,
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(
!html.contains("<a href"),
"disabled Button must not be anchor-wrapped; got: {html}"
);
assert!(
html.contains("aria-disabled=\"true\""),
"aria-disabled on the disabled button; got: {html}"
);
assert!(
html.contains("pointer-events-none opacity-50"),
"literal disabled classes for non-native contexts; got: {html}"
);
assert!(
html.contains("disabled:pointer-events-none"),
"uniform DISABLED_BASE on the button base; got: {html}"
);
}
#[test]
fn button_carries_token_focus_ring_and_fast_motion() {
let spec = spec_with_root(Element::new("Button").prop("label", "Go"));
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("focus-visible:ring-ring"), "got: {html}");
assert!(html.contains("duration-fast"), "got: {html}");
assert!(html.contains("ease-base"), "got: {html}");
}
#[test]
fn button_post_action_does_not_wrap_in_anchor() {
let el_builder = Element::new("Button").prop("label", "Save").action(Action {
handler: "users.store".into(),
url: Some("/users".into()),
method: HttpMethod::Post,
confirm: None,
on_success: None,
on_error: None,
target: None,
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(!html.contains("<a href"), "POST must not wrap; got: {html}");
assert!(html.contains("<button"));
}
#[test]
fn button_get_action_target_blank_includes_rel_noopener() {
let el_builder = Element::new("Button")
.prop("label", "External")
.action(Action {
handler: "ext".into(),
url: Some("https://example.com".into()),
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: Some("_blank".into()),
});
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(html.contains("target=\"_blank\""), "got: {html}");
assert!(html.contains("rel=\"noopener noreferrer\""), "got: {html}");
}
#[test]
fn render_button_emits_disable_on_submit() {
let spec = spec_with_root(
Element::new("Button")
.prop("label", "Confirm")
.prop("disable_on_submit", true),
);
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(
html.contains("data-disable-on-submit"),
"Button with disable_on_submit:true must carry data-disable-on-submit; got: {html}"
);
}
#[test]
fn render_button_omits_disable_on_submit_by_default() {
let spec = spec_with_root(Element::new("Button").prop("label", "Go"));
let el = spec.elements.get("root").unwrap();
let html = render_button(el, &spec, &json!({}), 1);
assert!(
!html.contains("data-disable-on-submit"),
"Button without disable_on_submit must NOT carry data-disable-on-submit; got: {html}"
);
}
#[test]
fn badge_emits_label() {
let spec = spec_with_root(Element::new("Badge").prop("label", "New"));
let el = spec.elements.get("root").unwrap();
let html = render_badge(el, &spec, &json!({}), 1);
assert!(html.contains("New"), "got: {html}");
assert!(html.contains("<span"), "got: {html}");
}
#[test]
fn alert_emits_message_and_role() {
let spec = spec_with_root(
Element::new("Alert")
.prop("message", "OK")
.prop("variant", "info"),
);
let el = spec.elements.get("root").unwrap();
let html = render_alert(el, &spec, &json!({}), 1);
assert!(html.contains("OK"), "got: {html}");
assert!(html.contains("role=\"alert\""), "got: {html}");
}
#[test]
fn separator_default_is_horizontal_hr() {
let spec = spec_with_root(Element::new("Separator"));
let el = spec.elements.get("root").unwrap();
let html = render_separator(el, &spec, &json!({}), 1);
assert!(html.contains("<hr"), "got: {html}");
}
#[test]
fn separator_vertical_is_div() {
let spec = spec_with_root(Element::new("Separator").prop("orientation", "vertical"));
let el = spec.elements.get("root").unwrap();
let html = render_separator(el, &spec, &json!({}), 1);
assert!(html.contains("<div"), "got: {html}");
assert!(html.contains("w-px"), "got: {html}");
}
#[test]
fn progress_emits_progressbar_role() {
let spec = spec_with_root(Element::new("Progress").prop("value", 50));
let el = spec.elements.get("root").unwrap();
let html = render_progress(el, &spec, &json!({}), 1);
assert!(html.contains("role=\"progressbar\""), "got: {html}");
assert!(html.contains("50%"), "got: {html}");
}
#[test]
fn avatar_with_src_emits_img() {
let spec = spec_with_root(
Element::new("Avatar")
.prop("src", "/img/a.png")
.prop("alt", "Alice"),
);
let el = spec.elements.get("root").unwrap();
let html = render_avatar(el, &spec, &json!({}), 1);
assert!(html.contains("<img"), "got: {html}");
assert!(html.contains("src=\"/img/a.png\""), "got: {html}");
assert!(html.contains("alt=\"Alice\""), "got: {html}");
}
#[test]
fn avatar_with_no_src_emits_fallback_initials() {
let spec = spec_with_root(
Element::new("Avatar")
.prop("alt", "Alice")
.prop("fallback", "AL"),
);
let el = spec.elements.get("root").unwrap();
let html = render_avatar(el, &spec, &json!({}), 1);
assert!(html.contains("AL"), "got: {html}");
assert!(!html.contains("<img"), "no img without src; got: {html}");
}
#[test]
fn image_emits_img_tag() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "/foo.png")
.prop("alt", "hero"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(html.contains("<img"), "got: {html}");
assert!(html.contains("src=\"/foo.png\""));
assert!(html.contains("alt=\"hero\""));
}
#[test]
fn image_xss_src_escaped() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "javascript:alert('xss')")
.prop("alt", "test"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(
!html.contains("alert('xss')"),
"single quote must be escaped; got: {html}"
);
assert!(html.contains("'"), "got: {html}");
}
#[test]
fn image_xss_quote_break_out_escaped() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "x\" onerror=\"alert(1)")
.prop("alt", "Test"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(
html.contains("src=\"x" onerror="alert(1)\""),
"got: {html}"
);
}
#[test]
fn image_inline_svg_renders_without_img_tag() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "")
.prop("alt", "chart")
.prop("inline_svg", "<svg><rect/></svg>"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(
html.contains("<svg><rect/></svg>"),
"inline SVG must be emitted verbatim; got: {html}"
);
assert!(
html.contains("aria-label=\"chart\""),
"aria-label must contain alt text; got: {html}"
);
assert!(
!html.contains("<img"),
"inline_svg path must not emit <img; got: {html}"
);
}
#[test]
fn image_inline_svg_escapes_alt_text() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "")
.prop("alt", "<script>x</script>")
.prop("inline_svg", "<svg/>"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(
html.contains("aria-label=\"<script>x</script>\""),
"alt must be HTML-escaped in aria-label; got: {html}"
);
assert!(
!html.contains("<script>"),
"raw <script> must not appear; got: {html}"
);
assert!(
html.contains("<svg/>"),
"SVG body must still be verbatim; got: {html}"
);
}
#[test]
fn skeleton_emits_shimmer_div() {
let spec = spec_with_root(Element::new("Skeleton"));
let el = spec.elements.get("root").unwrap();
let html = render_skeleton(el, &spec, &json!({}), 1);
assert!(html.contains("ferro-shimmer"), "got: {html}");
}
#[test]
fn breadcrumb_emits_nav_with_items() {
let spec = spec_with_root(Element::new("Breadcrumb").prop(
"items",
json!([{"label": "Home", "url": "/"}, {"label": "Dashboard"}]),
));
let el = spec.elements.get("root").unwrap();
let html = render_breadcrumb(el, &spec, &json!({}), 1);
assert!(html.contains("<nav"), "got: {html}");
assert!(html.contains("Home"));
assert!(html.contains("Dashboard"));
}
#[test]
fn pagination_emits_page_links() {
let spec = spec_with_root(
Element::new("Pagination")
.prop("current_page", 1)
.prop("per_page", 10)
.prop("total", 50)
.prop("base_url", "/list?"),
);
let el = spec.elements.get("root").unwrap();
let html = render_pagination(el, &spec, &json!({}), 1);
assert!(html.contains("<nav"), "got: {html}");
assert!(html.contains("page=2"), "got: {html}");
}
#[test]
fn pagination_single_page_returns_empty() {
let spec = spec_with_root(
Element::new("Pagination")
.prop("current_page", 1)
.prop("per_page", 10)
.prop("total", 5)
.prop("base_url", "/list?"),
);
let el = spec.elements.get("root").unwrap();
let html = render_pagination(el, &spec, &json!({}), 1);
assert!(html.is_empty(), "got: {html}");
}
#[test]
fn pagination_clamps_current_page() {
let spec = spec_with_root(
Element::new("Pagination")
.prop("current_page", 99)
.prop("per_page", 10)
.prop("total", 50)
.prop("base_url", "/list?"),
);
let el = spec.elements.get("root").unwrap();
let html = render_pagination(el, &spec, &json!({}), 1);
assert!(
!html.contains(">99<"),
"page 99 must be clamped away; got: {html}"
);
assert!(
html.contains("bg-primary text-primary-foreground\">5<"),
"active page 5 missing; got: {html}"
);
}
#[test]
fn props_decode_failure_emits_diagnostic() {
let mut el = Element {
type_name: "Text".into(),
props: json!(42),
children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
};
let spec = Spec::builder()
.element("__dummy__", Element::new("Text").prop("content", "x"))
.build()
.expect("ok");
let html = render_text(&el, &spec, &json!({}), 1);
assert!(
html.contains("<!-- ferro-json-ui: failed to decode Text props"),
"got: {html}"
);
el.type_name = "Badge".into();
let html2 = render_badge(&el, &spec, &json!({}), 1);
assert!(
html2.contains("<!-- ferro-json-ui: failed to decode Badge props"),
"got: {html2}"
);
}
#[test]
fn description_list_emits_dl_dt_dd() {
let spec = spec_with_root(
Element::new("DescriptionList")
.prop("items", json!([{"label": "Name", "value": "Alice"}])),
);
let el = spec.elements.get("root").unwrap();
let html = render_description_list(el, &spec, &json!({}), 1);
assert!(html.contains("<dl"), "got: {html}");
assert!(html.contains("<dt"), "got: {html}");
assert!(html.contains("<dd"), "got: {html}");
assert!(html.contains("Name"));
assert!(html.contains("Alice"));
}
#[test]
fn empty_state_emits_title() {
let spec = spec_with_root(
Element::new("EmptyState")
.prop("title", "No data")
.prop("description", "Try again"),
);
let el = spec.elements.get("root").unwrap();
let html = render_empty_state(el, &spec, &json!({}), 1);
assert!(html.contains("No data"));
assert!(html.contains("Try again"));
}
#[test]
fn empty_state_cta_carries_token_focus_ring() {
let spec = spec_with_root(
Element::new("EmptyState")
.prop("title", "No data")
.prop(
"action",
json!({"handler": "items.create", "url": "/items/new", "method": "GET"}),
)
.prop("action_label", "Create"),
);
let el = spec.elements.get("root").unwrap();
let html = render_empty_state(el, &spec, &json!({}), 1);
assert!(html.contains("Create"), "got: {html}");
assert!(
html.contains("focus-visible:ring-ring"),
"CTA must carry the token focus ring; got: {html}"
);
assert!(html.contains("duration-fast"), "got: {html}");
}
#[test]
fn stat_card_emits_value() {
let spec = spec_with_root(
Element::new("StatCard")
.prop("label", "Users")
.prop("value", "42"),
);
let el = spec.elements.get("root").unwrap();
let html = render_stat_card(el, &spec, &json!({}), 1);
assert!(html.contains("42"));
assert!(html.contains("Users"));
}
#[test]
fn stat_card_tone_accents_value() {
let spec = spec_with_root(
Element::new("StatCard")
.prop("label", "Errors")
.prop("value", "3")
.prop("tone", "destructive"),
);
let el = spec.elements.get("root").unwrap();
let html = render_stat_card(el, &spec, &json!({}), 1);
assert!(
html.contains("text-2xl font-bold text-destructive"),
"destructive tone must accent the value; got: {html}"
);
}
#[test]
fn stat_card_neutral_tone_keeps_default_value_color() {
let spec = spec_with_root(
Element::new("StatCard")
.prop("label", "Users")
.prop("value", "42"),
);
let el = spec.elements.get("root").unwrap();
let html = render_stat_card(el, &spec, &json!({}), 1);
assert!(
html.contains("text-2xl font-bold text-text"),
"neutral (default) tone keeps the untinted value; got: {html}"
);
}
#[test]
fn checklist_emits_items() {
let spec = spec_with_root(
Element::new("Checklist")
.prop("title", "Todo")
.prop("items", json!([{"label": "Buy milk", "checked": false}])),
);
let el = spec.elements.get("root").unwrap();
let html = render_checklist(el, &spec, &json!({}), 1);
assert!(html.contains("Buy milk"));
assert!(html.contains("Todo"));
}
#[test]
fn toast_emits_message() {
let spec = spec_with_root(
Element::new("Toast")
.prop("message", "Saved")
.prop("tone", "success"),
);
let el = spec.elements.get("root").unwrap();
let html = render_toast(el, &spec, &json!({}), 1);
assert!(html.contains("Saved"));
assert!(html.contains("data-toast-tone=\"success\""));
}
#[test]
fn toast_dismissible_emits_close_button_and_clamps_timeout() {
let spec = spec_with_root(
Element::new("Toast")
.prop("message", "Hi")
.prop("timeout", 0),
);
let el = spec.elements.get("root").unwrap();
let html = render_toast(el, &spec, &json!({}), 1);
assert!(html.contains("data-toast-close"), "got: {html}");
assert!(html.contains("data-toast-timeout=\"0\""), "got: {html}");
let spec = spec_with_root(
Element::new("Toast")
.prop("message", "Hi")
.prop("timeout", 0)
.prop("dismissible", false),
);
let el = spec.elements.get("root").unwrap();
let html = render_toast(el, &spec, &json!({}), 1);
assert!(!html.contains("data-toast-close"), "got: {html}");
assert!(html.contains("data-toast-timeout=\"1\""), "got: {html}");
}
#[test]
fn toast_uses_base_motion_tier() {
let spec = spec_with_root(Element::new("Toast").prop("message", "Saved"));
let el = spec.elements.get("root").unwrap();
let html = render_toast(el, &spec, &json!({}), 1);
assert!(
html.contains("transition-opacity duration-base ease-base"),
"Toast must use the base motion tier; got: {html}"
);
assert!(!html.contains("duration-300"), "got: {html}");
}
#[test]
fn notification_dropdown_emits_items() {
let spec = spec_with_root(
Element::new("NotificationDropdown")
.prop("notifications", json!([{"text": "Welcome", "read": false}])),
);
let el = spec.elements.get("root").unwrap();
let html = render_notification_dropdown(el, &spec, &json!({}), 1);
assert!(html.contains("Welcome"), "got: {html}");
}
#[test]
fn sidebar_emits_nav_items() {
let spec = spec_with_root(
Element::new("Sidebar").prop("fixed_top", json!([{"label": "Home", "href": "/"}])),
);
let el = spec.elements.get("root").unwrap();
let html = render_sidebar(el, &spec, &json!({}), 1);
assert!(html.contains("<aside"), "got: {html}");
assert!(html.contains("Home"));
}
#[test]
fn header_emits_business_name() {
let spec = spec_with_root(Element::new("Header").prop("business_name", "My Co"));
let el = spec.elements.get("root").unwrap();
let html = render_header(el, &spec, &json!({}), 1);
assert!(html.contains("<header"), "got: {html}");
assert!(html.contains("My Co"));
}
#[test]
fn calendar_cell_emits_day() {
let spec = spec_with_root(Element::new("CalendarCell").prop("day", 15));
let el = spec.elements.get("root").unwrap();
let html = render_calendar_cell(el, &spec, &json!({}), 1);
assert!(html.contains(">15<"), "got: {html}");
}
#[test]
fn calendar_cell_closed_draws_hatch() {
let spec = spec_with_root(
Element::new("CalendarCell")
.prop("day", 9)
.prop("closed", true),
);
let el = spec.elements.get("root").unwrap();
let html = render_calendar_cell(el, &spec, &json!({}), 1);
assert!(
html.contains("repeating-linear-gradient"),
"diagonal hatch missing: {html}"
);
assert!(html.contains(" relative"), "overlay anchor missing: {html}");
assert!(
!html.contains("destructive"),
"closed marker should be neutral, not destructive: {html}"
);
}
#[test]
fn calendar_cell_open_has_no_hatch() {
let spec = spec_with_root(Element::new("CalendarCell").prop("day", 9));
let el = spec.elements.get("root").unwrap();
let html = render_calendar_cell(el, &spec, &json!({}), 1);
assert!(
!html.contains("repeating-linear-gradient"),
"unexpected hatch: {html}"
);
}
#[test]
fn action_card_emits_title_and_description() {
let spec = spec_with_root(
Element::new("ActionCard")
.prop("title", "Click me")
.prop("description", "Go somewhere"),
);
let el = spec.elements.get("root").unwrap();
let html = render_action_card(el, &spec, &json!({}), 1);
assert!(html.contains("Click me"));
assert!(html.contains("Go somewhere"));
}
#[test]
fn action_card_with_href_renders_anchor() {
let spec = spec_with_root(
Element::new("ActionCard")
.prop("title", "Go")
.prop("description", "Link")
.prop("href", "/go"),
);
let el = spec.elements.get("root").unwrap();
let html = render_action_card(el, &spec, &json!({}), 1);
assert!(html.contains("<a href=\"/go\""), "got: {html}");
}
#[test]
fn tile_buttons_carry_token_focus_ring() {
let spec = spec_with_root(
Element::new("Tile")
.prop("item_id", "1")
.prop("name", "Caffè")
.prop("price", "€1,20")
.prop("field", "qty_1"),
);
let el = spec.elements.get("root").unwrap();
let html = render_tile(el, &spec, &json!({}), 1);
assert!(
html.contains("focus-visible:ring-ring"),
"+/- buttons must carry the token ring; got: {html}"
);
assert!(
!html.contains("focus-visible:ring-primary"),
"retired ring-primary must not survive; got: {html}"
);
}
#[test]
fn tile_emits_name_and_price() {
let spec = spec_with_root(
Element::new("Tile")
.prop("item_id", "p1")
.prop("name", "Widget")
.prop("price", "9.99")
.prop("field", "qty_p1"),
);
let el = spec.elements.get("root").unwrap();
let html = render_tile(el, &spec, &json!({}), 1);
assert!(html.contains("Widget"));
assert!(html.contains("9.99"));
assert!(html.contains("data-qty-inc=\"qty_p1\""), "got: {html}");
}
#[test]
fn image_data_path_resolves_src_from_data() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "fallback.png")
.prop("alt", "x")
.prop("data_path", "/avatar"),
);
let el = spec.elements.get("root").unwrap();
let data = json!({"avatar": "real.png"});
let html = render_image(el, &spec, &data, 1);
assert!(
html.contains(r#"src="real.png""#),
"data_path override; got: {html}"
);
assert!(
!html.contains("fallback.png"),
"static src must be overridden; got: {html}"
);
}
#[test]
fn image_data_path_none_uses_static_src() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "static.png")
.prop("alt", "x"),
);
let el = spec.elements.get("root").unwrap();
let html = render_image(el, &spec, &json!({}), 1);
assert!(
html.contains(r#"src="static.png""#),
"static src when no data_path; got: {html}"
);
}
#[test]
fn image_data_path_missing_in_data_falls_back_to_src() {
let spec = spec_with_root(
Element::new("Image")
.prop("src", "fallback.png")
.prop("alt", "x")
.prop("data_path", "/missing"),
);
let el = spec.elements.get("root").unwrap();
let data = json!({"other": "something"});
let html = render_image(el, &spec, &data, 1);
assert!(
html.contains(r#"src="fallback.png""#),
"must fall back to static src; got: {html}"
);
}
#[test]
fn description_list_data_path_overrides_static_items() {
let spec = spec_with_root(
Element::new("DescriptionList")
.prop("items", json!([{"label": "Static", "value": "X"}]))
.prop("data_path", "/items"),
);
let el = spec.elements.get("root").unwrap();
let data = json!({"items": [{"label": "Dynamic", "value": "Y"}]});
let html = render_description_list(el, &spec, &data, 1);
assert!(html.contains("Dynamic"), "data_path items; got: {html}");
assert!(
!html.contains("Static"),
"static items must be overridden; got: {html}"
);
}
#[test]
fn description_list_data_path_serde_roundtrip() {
use crate::component::DescriptionListProps;
let p = DescriptionListProps {
items: vec![],
columns: None,
data_path: Some("/items".to_string()),
};
let j = serde_json::to_value(&p).unwrap();
let back: DescriptionListProps = serde_json::from_value(j).unwrap();
assert_eq!(p, back);
}
#[test]
fn raw_html_props_serde_roundtrip() {
use crate::component::RawHtmlProps;
let p = RawHtmlProps {
html: "<span>x</span>".into(),
};
let j = serde_json::to_value(&p).unwrap();
let back: RawHtmlProps = serde_json::from_value(j).unwrap();
assert_eq!(p, back);
}
#[test]
fn render_raw_html_emits_verbatim() {
let spec = spec_with_root(Element::new("RawHtml").prop("html", "<b>hi</b>"));
let el = spec.elements.get("root").unwrap();
let html = render_raw_html(el, &spec, &json!(null), 1);
assert_eq!(html, "<div data-ferro-raw-html><b>hi</b></div>");
}
#[test]
fn render_raw_html_null_props_emits_diagnostic() {
let spec = Spec::builder()
.element("root", Element::new("RawHtml"))
.build()
.unwrap();
let el = crate::spec::Element {
type_name: "RawHtml".into(),
props: json!(42),
children: vec![],
action: None,
visible: None,
each: None,
if_: None,
};
let html = render_raw_html(&el, &spec, &json!(null), 1);
assert!(
html.contains("<!-- ferro-json-ui: failed to decode RawHtml props"),
"got: {html}"
);
}
#[test]
fn builtin_types_includes_raw_html() {
assert!(crate::render::BUILTIN_TYPES.contains(&"RawHtml"));
}
#[test]
fn stream_text_props_serde_roundtrip() {
use crate::component::StreamTextProps;
let p = StreamTextProps {
sse_url: "/ai/stream".to_string(),
placeholder: Some("Response will appear here\u{2026}".to_string()),
loading_text: Some("Generating\u{2026}".to_string()),
};
let j = serde_json::to_value(&p).unwrap();
let back: StreamTextProps = serde_json::from_value(j).unwrap();
assert_eq!(p, back);
}
#[test]
fn stream_text_props_minimal_serde_roundtrip() {
use crate::component::StreamTextProps;
let p = StreamTextProps {
sse_url: "/stream".to_string(),
placeholder: None,
loading_text: None,
};
let j = serde_json::to_value(&p).unwrap();
assert!(
j.get("placeholder").is_none(),
"placeholder must be absent when None"
);
assert!(
j.get("loading_text").is_none(),
"loading_text must be absent when None"
);
let back: StreamTextProps = serde_json::from_value(j).unwrap();
assert_eq!(p, back);
}
#[test]
fn render_streamtext_emits_data_attribute() {
let spec = spec_with_root(Element::new("StreamText").prop("sse_url", "/api/stream"));
let el = spec.elements.get("root").unwrap();
let html = render_streamtext(el, &spec, &json!(null), 1);
assert!(
html.contains("data-ferro-stream-url=\"/api/stream\""),
"got: {html}"
);
}
#[test]
fn render_streamtext_escapes_url() {
let spec =
spec_with_root(Element::new("StreamText").prop("sse_url", "/stream?q=a&b=\"><script>"));
let el = spec.elements.get("root").unwrap();
let html = render_streamtext(el, &spec, &json!(null), 1);
assert!(!html.contains("&b="), "raw & must be escaped; got: {html}");
assert!(
!html.contains("<script>"),
"raw <script> must be escaped; got: {html}"
);
}
#[test]
fn stat_card_value_path_resolves_from_data() {
let spec = spec_with_root(
Element::new("StatCard")
.prop("label", "Revenue")
.prop("value", "")
.prop("value_path", "/data/statistics/total_revenue"),
);
let el = spec.elements.get("root").unwrap();
let data = json!({"data": {"statistics": {"total_revenue": "€12,450"}}});
let html = render_stat_card(el, &spec, &data, 1);
assert!(
html.contains("€12,450"),
"resolved value must appear in HTML; got: {html}"
);
}
#[test]
fn stat_card_value_path_fallback_to_static_value() {
let spec = spec_with_root(
Element::new("StatCard")
.prop("label", "Revenue")
.prop("value", "static"),
);
let el = spec.elements.get("root").unwrap();
let html = render_stat_card(el, &spec, &json!({}), 1);
assert!(
html.contains("static"),
"static value must appear when value_path is absent; got: {html}"
);
}
fn make_tile(categories: Vec<&str>) -> (crate::spec::Element, Spec) {
use crate::spec::Element as SpecElement;
let mut el_builder = SpecElement::new("Tile")
.prop("item_id", "p1")
.prop("name", "Espresso")
.prop("price", "€2,50")
.prop("field", "qty_espresso");
if !categories.is_empty() {
el_builder = el_builder.prop("categories", categories);
}
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap().clone();
(el, spec)
}
#[test]
fn tile_tap_to_add_emits_qty_inc_button() {
use crate::spec::Element as SpecElement;
let spec = spec_with_root(
SpecElement::new("Tile")
.prop("item_id", "p1")
.prop("name", "Coffee")
.prop("price", "€2,00")
.prop("field", "p1"),
);
let el = spec.elements.get("root").unwrap();
let html = render_tile(el, &spec, &json!({}), 1);
assert!(
html.contains("data-qty-inc=\"p1\""),
"must emit data-qty-inc; got: {html}"
);
assert!(
html.contains("data-qty-input=\"p1\""),
"must emit hidden input; got: {html}"
);
assert!(
!html.contains("data-qty-display"),
"tap-to-add: no on-tile qty display; got: {html}"
);
assert!(
!html.contains("data-qty-dec"),
"tap-to-add: no dec button on tile; got: {html}"
);
assert!(
html.contains("Add Coffee"),
"neutral English aria-label must say 'Add <name>'; got: {html}"
);
assert!(
html.contains("data-filter-text=\"Coffee\""),
"data-filter-text must always be emitted; got: {html}"
);
let btn_end = html.find("</button>").expect("must have </button>");
let input_pos = html
.find("data-qty-input")
.expect("must have data-qty-input");
assert!(
input_pos > btn_end,
"hidden input must be a sibling of <button>, not inside it; got: {html}"
);
}
#[test]
fn tile_text_only_when_no_image() {
let (el, spec) = make_tile(vec![]);
let html = render_tile(&el, &spec, &json!({}), 1);
assert!(
!html.contains("<img"),
"no image_url → no <img> in output; got: {html}"
);
}
#[test]
fn tile_renders_image_and_badge() {
use crate::spec::Element as SpecElement;
let spec = spec_with_root(
SpecElement::new("Tile")
.prop("item_id", "p2")
.prop("name", "Latte")
.prop("price", "€3,50")
.prop("field", "qty_latte")
.prop("image_url", "https://example.com/latte.jpg")
.prop("stock_badge", "Low"),
);
let el = spec.elements.get("root").unwrap();
let html = render_tile(el, &spec, &json!({}), 1);
assert!(
html.contains("<img"),
"image_url set → must emit <img>; got: {html}"
);
assert!(
html.contains("loading=\"lazy\""),
"image must be lazy-loaded; got: {html}"
);
assert!(
html.contains("Low"),
"stock_badge must render badge chip; got: {html}"
);
}
#[test]
fn tile_emits_data_filter_tokens() {
let (el, spec) = make_tile(vec!["drinks", "food"]);
let html = render_tile(&el, &spec, &json!({}), 1);
assert_eq!(
html.matches("data-filter-tokens").count(),
1,
"expected exactly one data-filter-tokens attribute; got: {html}"
);
assert!(
html.contains("data-filter-tokens=\"drinks food\""),
"expected space-separated tokens; got: {html}"
);
}
#[test]
fn tile_normalizes_spaces_in_category_names() {
let (el, spec) = make_tile(vec!["Bevande calde", "food"]);
let html = render_tile(&el, &spec, &json!({}), 1);
assert!(
html.contains("data-filter-tokens=\"Bevande-calde food\""),
"expected space-in-name normalized to hyphen; got: {html}"
);
}
#[test]
fn tile_escapes_categories() {
let (el, spec) = make_tile(vec!["caf\u{00E9}", "\"special\""]);
let html = render_tile(&el, &spec, &json!({}), 1);
assert!(
!html.contains("\"special\""),
"raw double-quote must not appear in attribute value; got: {html}"
);
assert!(
html.contains(""") || html.contains("""),
"double-quote must be HTML-escaped; got: {html}"
);
}
#[test]
fn tile_escapes_filter_text() {
use crate::spec::Element as SpecElement;
let el_builder = SpecElement::new("Tile")
.prop("item_id", "p1")
.prop("name", "Ba\"r <item>")
.prop("price", "\u{20AC}1,00")
.prop("field", "qty_test");
let spec = spec_with_root(el_builder);
let el = spec.elements.get("root").unwrap().clone();
let html = render_tile(&el, &spec, &json!({}), 1);
assert!(
html.contains("data-filter-text="),
"data-filter-text must always be emitted; got: {html}"
);
assert!(
!html.contains("data-filter-text=\"Ba\""),
"raw double-quote must not break attribute boundary in data-filter-text; got: {html}"
);
assert!(
html.contains(""") || html.contains("""),
"double-quote in name must be HTML-escaped in data-filter-text; got: {html}"
);
assert!(
html.contains("<"),
"less-than in name must be HTML-escaped in data-filter-text; got: {html}"
);
}
#[test]
fn filter_tabs_all_label_defaults_to_all() {
let spec = spec_with_root(Element::new("FilterTabs"));
let el = spec.elements.get("root").unwrap();
let html = render_filter_tabs(el, &spec, &json!({}), 1);
assert!(
html.contains(">All<"),
"default all_label must be 'All'; got: {html}"
);
assert!(
!html.contains("Tutte"),
"must not contain locale string 'Tutte'; got: {html}"
);
}
#[test]
fn filter_tabs_emits_scope_and_tabs() {
let spec = spec_with_root(
Element::new("FilterTabs")
.prop("items", vec!["Drinks", "Food"])
.prop("all_label", "All"),
);
let el = spec.elements.get("root").unwrap();
let html = render_filter_tabs(el, &spec, &json!({}), 1);
assert!(
html.contains("data-filter-scope"),
"must carry data-filter-scope; got: {html}"
);
assert!(
html.contains("data-filter-tab=\"\""),
"All tab must carry empty data-filter-tab; got: {html}"
);
assert!(
html.contains("data-filter-tab=\"Drinks\""),
"must have Drinks tab; got: {html}"
);
assert!(
html.contains("data-filter-tab=\"Food\""),
"must have Food tab; got: {html}"
);
}
#[test]
fn filter_tabs_inactive_classes_match_runtime() {
let spec = spec_with_root(Element::new("FilterTabs").prop("items", vec!["Drinks"]));
let el = spec.elements.get("root").unwrap();
let html = render_filter_tabs(el, &spec, &json!({}), 1);
assert!(
html.contains("border-transparent"),
"inactive tab must have border-transparent; got: {html}"
);
assert!(
html.contains("text-text-muted"),
"inactive tab must have text-text-muted; got: {html}"
);
assert!(
html.contains("hover:text-text"),
"inactive tab must have hover:text-text; got: {html}"
);
}
#[test]
fn filter_tabs_targets_are_44px() {
let spec = spec_with_root(Element::new("FilterTabs").prop("items", vec!["Coffee"]));
let el = spec.elements.get("root").unwrap();
let html = render_filter_tabs(el, &spec, &json!({}), 1);
assert!(
html.contains("min-h-[44px]"),
"tabs must carry HIT_TARGET_MIN (min-h-[44px]); got: {html}"
);
}
#[test]
fn filter_tabs_tokens_match_tile_tokens() {
let spec = spec_with_root(Element::new("FilterTabs").prop("items", vec!["Drinks", "Food"]));
let el = spec.elements.get("root").unwrap();
let html = render_filter_tabs(el, &spec, &json!({}), 1);
assert!(
html.contains("data-filter-tab=\"Drinks\""),
"FilterTabs emits tab token 'Drinks'; got: {html}"
);
let tile_spec = spec_with_root(
Element::new("Tile")
.prop("item_id", "p1")
.prop("name", "Espresso")
.prop("price", "€2,50")
.prop("field", "qty_espresso")
.prop("categories", vec!["Drinks"]),
);
let tile_el = tile_spec.elements.get("root").unwrap();
let tile_html = render_tile(tile_el, &tile_spec, &json!({}), 1);
assert!(
tile_html.contains("data-filter-tokens=\"Drinks\""),
"Tile with categories:['Drinks'] must carry data-filter-tokens=\"Drinks\"; got: {tile_html}"
);
}
#[test]
fn quantity_stepper_self_contained() {
let spec = spec_with_root(Element::new("QuantityStepper").prop("field", "qty_coffee"));
let el = spec.elements.get("root").unwrap();
let html = render_quantity_stepper(el, &spec, &json!({}), 1);
assert!(
html.contains("data-qty-dec=\"qty_coffee\""),
"dec button missing; got: {html}"
);
assert!(
html.contains("data-qty-display=\"qty_coffee\""),
"display span missing; got: {html}"
);
assert!(
html.contains("data-qty-inc=\"qty_coffee\""),
"inc button missing; got: {html}"
);
assert!(
html.contains("data-qty-input=\"qty_coffee\""),
"own hidden input missing; got: {html}"
);
assert!(
html.contains("name=\"qty_coffee\""),
"input must carry field name; got: {html}"
);
}
#[test]
fn quantity_stepper_targets_44px() {
let spec = spec_with_root(Element::new("QuantityStepper").prop("field", "qty_coffee"));
let el = spec.elements.get("root").unwrap();
let html = render_quantity_stepper(el, &spec, &json!({}), 1);
let count = html.matches(crate::render::classes::HIT_TARGET_MIN).count();
assert!(
count >= 2,
"both buttons must be >=44px (HIT_TARGET_MIN x2); count={count}; got: {html}"
);
}
#[test]
fn quantity_stepper_emits_bounds() {
let spec = spec_with_root(
Element::new("QuantityStepper")
.prop("field", "qty_coffee")
.prop("min", 1u32)
.prop("max", 10u32)
.prop("step", 2u32),
);
let el = spec.elements.get("root").unwrap();
let html = render_quantity_stepper(el, &spec, &json!({}), 1);
assert!(html.contains("data-qty-min=\"1\""), "min attr; got: {html}");
assert!(
html.contains("data-qty-max=\"10\""),
"max attr; got: {html}"
);
assert!(
html.contains("data-qty-step=\"2\""),
"step attr; got: {html}"
);
let spec_no_bounds =
spec_with_root(Element::new("QuantityStepper").prop("field", "qty_coffee"));
let el2 = spec_no_bounds.elements.get("root").unwrap();
let html2 = render_quantity_stepper(el2, &spec_no_bounds, &json!({}), 1);
assert!(
!html2.contains("data-qty-min"),
"no min when unset; got: {html2}"
);
assert!(
!html2.contains("data-qty-max"),
"no max when unset; got: {html2}"
);
assert!(
!html2.contains("data-qty-step"),
"no step when unset; got: {html2}"
);
}
#[test]
fn numpad_emits_contract() {
let spec = spec_with_root(Element::new("Numpad").prop("target_field", "total_cents"));
let el = spec.elements.get("root").unwrap();
let html = render_numpad(el, &spec, &json!({}), 1);
assert!(html.contains("data-numpad"), "container attr; got: {html}");
assert!(
html.contains("data-numpad-target=\"total_cents\""),
"target attr; got: {html}"
);
assert!(
html.contains("data-numpad-display"),
"display attr; got: {html}"
);
for k in &[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
"clear",
"backspace",
] {
let attr = format!("data-numpad-key=\"{k}\"");
assert!(html.contains(&attr), "missing key {k}; got: {html}");
}
assert!(
html.contains("data-numpad-input=\"total_cents\""),
"hidden input attr; got: {html}"
);
assert!(
!html.contains("type=\"text\""),
"no native text input allowed; got: {html}"
);
assert!(
!html.contains("type=\"number\""),
"no native number input allowed; got: {html}"
);
}
#[test]
fn numpad_price_mode() {
let spec_price = spec_with_root(
Element::new("Numpad")
.prop("target_field", "total_cents")
.prop("mode", "price"),
);
let el = spec_price.elements.get("root").unwrap();
let html = render_numpad(el, &spec_price, &json!({}), 1);
assert!(
html.contains("data-numpad-mode=\"price\""),
"price mode attr missing; got: {html}"
);
let spec_qty = spec_with_root(Element::new("Numpad").prop("target_field", "total_cents"));
let el2 = spec_qty.elements.get("root").unwrap();
let html2 = render_numpad(el2, &spec_qty, &json!({}), 1);
assert!(
!html2.contains("data-numpad-mode"),
"quantity mode must not emit mode attr; got: {html2}"
);
}
#[test]
fn numpad_keys_56px() {
let spec = spec_with_root(Element::new("Numpad").prop("target_field", "total_cents"));
let el = spec.elements.get("root").unwrap();
let html = render_numpad(el, &spec, &json!({}), 1);
let count = html
.matches(crate::render::classes::HIT_TARGET_NUMPAD)
.count();
assert_eq!(
count, 12,
"all 12 keys must carry HIT_TARGET_NUMPAD (>=56px); count={count}; got: {html}"
);
}
}