Image
Optimized image component with lazy loading, CLS prevention, responsive srcset, and fetch priority.
Basic
Lazy-loaded by default with async decoding.
Hero Image (Eager + High Priority)
For above-the-fold images, use eager loading with high fetch priority.
Object Fit
Control how the image fills its container.
Cover
Contain
Fill
Responsive srcset
Serve different image sizes based on viewport width.
Built-in Performance
- loading="lazy" by default — only loads when entering viewport
- decoding="async" — non-blocking image decode
- width/height attributes prevent Cumulative Layout Shift (CLS)
- fetchpriority="high" for LCP images
- srcset + sizes for responsive image delivery
- Optional blur placeholder with aspect-ratio preservation
- Alt text is a required field — accessible by default
FreeImage
Responsive image output.
Install with the Proa CLI
$ proa ui add imageInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Image recipe — typed fit/layout axes and a named placeholder slot.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Object-fit mode for the image.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "fit")]
pub enum ImageFit {
#[default]
None,
Cover,
Contain,
Fill,
}
impl ImageFit {
pub const fn classes(self) -> &'static str {
Image source
Browse and copy the reviewed source included with this Free component.
image.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Image component — optimized `<img>` with lazy loading, srcset, and CLS prevention.
pub use super::recipe::ImageFit;
use super::recipe::{ImageRecipeProps, IMAGE_RECIPE};
use proa_core::shared::attr_value::AttrValue;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
/// Loading strategy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImageLoading {
/// Lazy-load when the image enters the viewport.
#[default]
Lazy,
/// Load immediately (use for above-the-fold / LCP images).
Eager,
}
impl ImageLoading {
pub fn as_str(self) -> &'static str {
match self {
Self::Lazy => "lazy",
Self::Eager => "eager",
}
}
}
/// Fetch priority hint for the browser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImagePriority {
#[default]
Auto,
High,
Low,
}
impl ImagePriority {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::High => "high",
Self::Low => "low",
}
}
}
/// Optimized image component with built-in performance best practices.
///
/// Renders an `<img>` tag with:
/// - `loading="lazy"` by default (opt into `eager` for LCP images)
/// - `decoding="async"` for non-blocking decode
/// - `width` / `height` attributes to prevent CLS
/// - Optional `srcset` for responsive images
/// - `fetchpriority` hint for the browser
/// - `object-fit` via Tailwind class
///
/// # Example
///
/// ```ignore
/// use proa_ui::image::{Image, ImageLoading, ImagePriority};
///
/// Image {
/// src: "/images/hero.jpg",
/// alt: "Hero banner",
/// width: Some(1200),
/// height: Some(630),
/// loading: ImageLoading::Eager,
/// priority: ImagePriority::High,
/// ..Default::default()
/// }
/// ```
pub struct Image {
/// Image URL (required).
pub src: &'static str,
/// Alt text for accessibility (required).
pub alt: &'static str,
/// Intrinsic width in pixels — prevents layout shift.
pub width: Option<u32>,
/// Intrinsic height in pixels — prevents layout shift.
pub height: Option<u32>,
/// Responsive image sources (e.g. `"/img-480.jpg 480w, /img-800.jpg 800w"`).
pub srcset: Option<&'static str>,
/// Responsive sizes hint (e.g. `"(max-width: 600px) 480px, 800px"`).
pub sizes: Option<&'static str>,
/// Loading strategy. Default: lazy.
pub loading: ImageLoading,
/// Fetch priority. Default: auto.
pub priority: ImagePriority,
/// Object-fit mode.
pub fit: ImageFit,
/// Fill mode — image stretches to fill its parent container. When `true`,
/// `width` and `height` are ignored and the image renders with
/// `position:absolute; inset:0; width:100%; height:100%`. The parent
/// element MUST have `position:relative` (or `absolute`/`fixed`).
/// Equivalent to Next.js `<Image fill>`.
pub fill: bool,
/// Whether to apply rounded corners.
pub rounded: bool,
/// Additional CSS classes.
pub class: Option<&'static str>,
/// Optional blur placeholder data URI (e.g. a tiny base64 image).
pub placeholder: Option<&'static str>,
}
impl Default for Image {
fn default() -> Self {
Self {
src: "",
alt: "",
width: None,
height: None,
srcset: None,
sizes: None,
loading: ImageLoading::Lazy,
priority: ImagePriority::Auto,
fit: ImageFit::None,
fill: false,
rounded: false,
class: None,
placeholder: None,
}
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Image {
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = IMAGE_RECIPE.resolve(ImageRecipeProps {
fit: Some(self.fit),
fill: Some(self.fill),
rounded: Some(self.rounded),
});
let classes = proa_macros::text!("{} {}", style, self.class);
if let Some(placeholder) = self.placeholder {
let placeholder_classes = proa_macros::text!(
"{} {}",
IMAGE_RECIPE.placeholder,
style.rounded().then_some(IMAGE_RECIPE.rounded)
);
let _ = cx // proa-perf-ok: trusted static markup emitted through the low-level writer
.out
.extend(b"<div data-image-placeholder data-slot=\"image-placeholder\" class=\"");
placeholder_classes.render_attr_value(cx)?;
let _ = cx.out.extend(b"\" style=\"background-image:url('"); // proa-perf-ok: trusted static markup emitted through the low-level writer
css_url_escape_into(placeholder, cx);
let _ = cx.out.extend(b"');background-size:cover");
if let (Some(w), Some(h)) = (self.width, self.height) {
let _ = cx.out.extend(b";aspect-ratio:");
write_u32(w, cx);
let _ = cx.out.extend(b"/");
write_u32(h, cx);
}
let _ = cx.out.extend(b"\">");
}
let _ = cx // proa-perf-ok: trusted static markup emitted through the low-level writer
.out
.extend(b"<img data-image data-slot=\"image\" data-fit=\"");
let _ = cx.out.extend(style.fit().as_str().as_bytes());
let _ = cx.out.extend(b"\" data-fill=\""); // proa-perf-ok: trusted static markup emitted through the low-level writer
let _ = cx.out.extend(if style.fill() {
&b"true"[..]
} else {
&b"false"[..]
});
let _ = cx.out.extend(b"\" data-rounded=\""); // proa-perf-ok: trusted static markup emitted through the low-level writer
let _ = cx.out.extend(if style.rounded() {
&b"true"[..]
} else {
&b"false"[..]
});
let _ = cx.out.extend(b"\"");
let _ = cx.out.extend(b" src=\"");
attr_escape_into(self.src, cx);
let _ = cx.out.extend(b"\"");
let _ = cx.out.extend(b" alt=\"");
attr_escape_into(self.alt, cx);
let _ = cx.out.extend(b"\"");
let _ = cx.out.extend(b" class=\""); // proa-perf-ok: trusted static markup emitted through the low-level writer
classes.render_attr_value(cx)?;
let _ = cx.out.extend(b"\"");
if !self.fill {
if let Some(w) = self.width {
let _ = cx.out.extend(b" width=\"");
write_u32(w, cx);
let _ = cx.out.extend(b"\"");
}
if let Some(h) = self.height {
let _ = cx.out.extend(b" height=\"");
write_u32(h, cx);
let _ = cx.out.extend(b"\"");
}
}
if let Some(srcset) = self.srcset {
let _ = cx.out.extend(b" srcset=\"");
attr_escape_into(srcset, cx);
let _ = cx.out.extend(b"\"");
}
if let Some(sizes) = self.sizes {
let _ = cx.out.extend(b" sizes=\"");
attr_escape_into(sizes, cx);
let _ = cx.out.extend(b"\"");
}
let _ = cx.out.extend(b" loading=\"");
let _ = cx.out.extend(self.loading.as_str().as_bytes());
let _ = cx.out.extend(b"\"");
let _ = cx.out.extend(b" decoding=\"async\"");
if self.priority != ImagePriority::Auto {
let _ = cx.out.extend(b" fetchpriority=\"");
let _ = cx.out.extend(self.priority.as_str().as_bytes());
let _ = cx.out.extend(b"\"");
}
let _ = cx.out.extend(b" />");
if self.placeholder.is_some() {
let _ = cx.out.extend(b"</div>"); // proa-perf-ok: trusted static markup emitted through the low-level writer
}
Ok(())
}
}
fn write_u32<B: WriteBuf, L: proa_core::DataLoader>(n: u32, cx: &mut WebContext<L, B>) {
let mut buf = [0u8; 10];
let s = format_u32(n, &mut buf);
let _ = cx.out.extend(s);
}
fn format_u32(mut n: u32, buf: &mut [u8; 10]) -> &[u8] {
if n == 0 {
buf[9] = b'0';
return &buf[9..];
}
let mut i = 10;
while n > 0 {
i -= 1;
buf[i] = b'0' + (n % 10) as u8;
n /= 10;
}
&buf[i..]
}
fn attr_escape_into<B: WriteBuf, L: proa_core::DataLoader>(text: &str, cx: &mut WebContext<L, B>) {
for ch in text.bytes() {
match ch {
b'<' => {
let _ = cx.out.extend(b"<");
}
b'>' => {
let _ = cx.out.extend(b">");
}
b'&' => {
let _ = cx.out.extend(b"&");
}
b'"' => {
let _ = cx.out.extend(b""");
}
_ => {
let _ = cx.out.push(ch); // proa-perf-ok: pre-existing pattern grandfathered at framework pin fd7517be; tracked for burn-down
}
}
}
}
/// Escape a URL for embedding in a CSS `url('...')` function inside a
/// double-quoted HTML `style` attribute. Handles both layers: CSS single-quote
/// / backslash escapes, and HTML `&` / `"` / `<` / `>` entity escapes.
/// Newline and control characters are dropped (they are never valid in CSS URL
/// tokens and are the primary vector for style-attribute breakout).
fn css_url_escape_into<B: WriteBuf, L: proa_core::DataLoader>(
text: &str,
cx: &mut WebContext<L, B>,
) {
for ch in text.bytes() {
match ch {
0..=0x1f | 0x7f => {}
b'\\' => {
let _ = cx.out.extend(b"\\\\");
}
b'\'' => {
let _ = cx.out.extend(b"\\'");
}
b'&' => {
let _ = cx.out.extend(b"&");
}
b'"' => {
let _ = cx.out.extend(b""");
}
b'<' => {
let _ = cx.out.extend(b"<");
}
b'>' => {
let _ = cx.out.extend(b">");
}
_ => {
let _ = cx.out.push(ch); // proa-perf-ok: pre-existing pattern grandfathered at framework pin fd7517be; tracked for burn-down
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn image_renders_basic() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/hero.jpg",
alt: "Hero",
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-image"));
assert!(html.contains("data-slot=\"image\""));
assert!(html.contains("data-fit=\"none\""));
assert!(html.contains("src=\"/hero.jpg\""));
assert!(html.contains("alt=\"Hero\""));
assert!(html.contains("loading=\"lazy\""));
assert!(html.contains("decoding=\"async\""));
}
#[test]
fn image_with_dimensions() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/photo.jpg",
alt: "Photo",
width: Some(800),
height: Some(600),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("width=\"800\""));
assert!(html.contains("height=\"600\""));
}
#[test]
fn image_eager_loading_with_priority() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/lcp.jpg",
alt: "Above fold",
loading: ImageLoading::Eager,
priority: ImagePriority::High,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("loading=\"eager\""));
assert!(html.contains("fetchpriority=\"high\""));
}
#[test]
fn image_auto_priority_omits_attr() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/img.jpg",
alt: "test",
priority: ImagePriority::Auto,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(!html.contains("fetchpriority"));
}
#[test]
fn image_with_srcset() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/img-800.jpg",
alt: "Responsive",
srcset: Some("/img-480.jpg 480w, /img-800.jpg 800w, /img-1200.jpg 1200w"),
sizes: Some("(max-width: 600px) 480px, 800px"),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("srcset=\""));
assert!(html.contains("sizes=\""));
}
#[test]
fn image_with_object_fit() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/cover.jpg",
alt: "Cover",
fit: ImageFit::Cover,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("object-cover"));
}
#[test]
fn image_rounded() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/avatar.jpg",
alt: "Avatar",
rounded: true,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("rounded-lg"));
}
#[test]
fn image_with_placeholder() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/photo.jpg",
alt: "Photo",
width: Some(400),
height: Some(300),
placeholder: Some("data:image/png;base64,abc"),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("background-image:url('data:image/png;base64,abc')"));
assert!(html.contains("data-slot=\"image-placeholder\""));
assert!(html.contains("aspect-ratio:400/300"));
assert!(html.contains("</div>"));
}
#[test]
fn image_escapes_alt_text() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/img.jpg",
alt: "A \"quoted\" & <tagged> image",
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(""quoted""));
assert!(html.contains("&"));
assert!(html.contains("<tagged>"));
}
#[test]
fn image_fill_mode() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Image {
src: "/bg.jpg",
alt: "Background",
width: Some(1200),
height: Some(800),
fill: true,
fit: ImageFit::Cover,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("absolute"));
assert!(html.contains("inset-0"));
assert!(html.contains("w-full"));
assert!(html.contains("h-full"));
assert!(html.contains("object-cover"));
assert!(html.contains("data-fit=\"cover\""));
assert!(html.contains("data-fill=\"true\""));
assert!(!html.contains("width=\"1200\""));
assert!(!html.contains("height=\"800\""));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::Image;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Image renders as a standard markdown image: ``.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Image {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { "?;
cx.write_md_url(self.src)?;
md_sync! { ")" }.render_md(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_renders_markdown_image() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Image {
src: "/hero.jpg",
alt: "Hero banner",
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "");
}
#[test]
fn image_ignores_visual_props() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Image {
src: "/photo.jpg",
alt: "Photo",
width: Some(800),
height: Some(600),
rounded: true,
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "");
}
#[test]
fn image_sanitizes_dangerous_sources() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Image {
src: "data:image/svg+xml,<svg onload=alert(1)>",
alt: "Unsafe",
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "![Unsafe]()");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Image component with lazy loading, responsive srcset, and CLS prevention.
#[allow(clippy::module_inception)]
mod image;
mod md;
mod recipe;
pub use image::{Image, ImageLoading, ImagePriority};
pub use recipe::{
image_base, image_recipe, ImageFit, ImageRecipe, ImageRecipeProps, ImageStyle, IMAGE_RECIPE,
};
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Image recipe — typed fit/layout axes and a named placeholder slot.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Object-fit mode for the image.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "fit")]
pub enum ImageFit {
#[default]
None,
Cover,
Contain,
Fill,
}
impl ImageFit {
pub const fn classes(self) -> &'static str {
match self {
Self::None => "",
Self::Cover => "object-cover",
Self::Contain => "object-contain",
Self::Fill => "object-fill",
}
}
/// Compatibility form of the former component-local helper.
pub const fn class(self) -> Option<&'static str> {
match self {
Self::None => None,
_ => Some(self.classes()),
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Cover => "cover",
Self::Contain => "contain",
Self::Fill => "fill",
}
}
}
/// Optional inputs accepted by [`ImageRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ImageRecipeProps {
pub fit: Option<ImageFit>,
pub fill: Option<bool>,
pub rounded: Option<bool>,
}
/// Static Image slots, modifiers, and defaults.
#[derive(Debug, Clone, Copy)]
pub struct ImageRecipe {
pub root: &'static str,
pub placeholder: &'static str,
pub fill: &'static str,
pub rounded: &'static str,
fit_default: ImageFit,
fill_default: bool,
rounded_default: bool,
}
impl ImageRecipe {
pub const fn resolve(&'static self, props: ImageRecipeProps) -> ImageStyle {
let fit = match props.fit {
Some(fit) => fit,
None => self.fit_default,
};
let fill = match props.fill {
Some(fill) => fill,
None => self.fill_default,
};
let rounded = match props.rounded {
Some(rounded) => rounded,
None => self.rounded_default,
};
ImageStyle {
recipe: self,
fit,
fill,
rounded,
}
}
}
/// Fully resolved, streamable Image style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ImageStyle {
recipe: &'static ImageRecipe,
fit: ImageFit,
fill: bool,
rounded: bool,
}
impl ImageStyle {
pub const fn fit(self) -> ImageFit {
self.fit
}
pub const fn fill(self) -> bool {
self.fill
}
pub const fn rounded(self) -> bool {
self.rounded
}
}
impl AttrValue for ImageStyle {
#[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())?;
if !self.fit.classes().is_empty() {
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.fit.classes().as_bytes())?;
}
if self.fill {
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.recipe.fill.as_bytes())?;
}
if self.rounded {
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.recipe.rounded.as_bytes())?;
}
Ok(())
}
}
pub const IMAGE_RECIPE: ImageRecipe = ImageRecipe {
root: "max-w-full",
placeholder: tw_join!("relative", "overflow-hidden"),
fill: tw_join!("absolute", "inset-0", "w-full", "h-full"),
rounded: "rounded-lg",
fit_default: ImageFit::None,
fill_default: false,
rounded_default: false,
};
pub const fn image_recipe() -> &'static ImageRecipe {
&IMAGE_RECIPE
}
impl crate::variant_spec::ComponentSpec for ImageRecipe {
const NAME: &'static str = "Image";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<ImageFit>()]
}
}
/// Compatibility accessor for the former base helper.
pub const fn image_base() -> &'static str {
IMAGE_RECIPE.root
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_image_and_placeholder_parts() {
let recipe = image_recipe();
assert!(recipe.root.contains("max-w-full"));
assert!(recipe.placeholder.contains("overflow-hidden"));
assert!(recipe.fill.contains("absolute"));
assert_eq!(recipe.rounded, "rounded-lg");
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = IMAGE_RECIPE.resolve(ImageRecipeProps::default());
assert_eq!(default.fit(), ImageFit::None);
assert!(!default.fill());
assert!(!default.rounded());
let custom = IMAGE_RECIPE.resolve(ImageRecipeProps {
fit: Some(ImageFit::Cover),
fill: Some(true),
rounded: Some(true),
});
assert_eq!(custom.fit(), ImageFit::Cover);
assert!(custom.fill());
assert!(custom.rounded());
}
#[test]
fn fit_variants_have_stable_values() {
assert_eq!(ImageFit::None.as_str(), "none");
assert_eq!(ImageFit::Cover.classes(), "object-cover");
assert_eq!(ImageFit::Contain.classes(), "object-contain");
assert_eq!(ImageFit::Fill.classes(), "object-fill");
}
#[test]
fn recipe_metadata_exposes_the_fit_axis() {
let metadata = crate::variant_spec::component_metadata(image_recipe());
assert_eq!(metadata.name, "Image");
assert!(metadata.anatomy.is_none());
assert_eq!(metadata.variants.len(), 1);
let fit = &metadata.variants[0];
assert_eq!(fit.name, "fit");
assert_eq!(fit.default_value, "none");
assert_eq!(
fit.options
.iter()
.map(|option| option.value)
.collect::<Vec<_>>(),
["none", "cover", "contain", "fill"]
);
metadata.validate().unwrap();
}
}