perf: use responsive images for theme preview mockups

Update theme preview to use optimized responsive images with modern
format support (AVIF/WebP with JPEG fallback).

- Change mockup URLs from .jpg to base paths for srcset generation
- Add source_width to preview products for proper variant selection
- Add responsive_image component with <picture> element
- Update image_text_section to use optimized 800px WebP variant

This ensures the theme preview loads optimal image formats and sizes,
matching the production responsive image behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 00:33:38 +00:00
parent 0ade34d994
commit 2c3d8f5647
3 changed files with 262 additions and 98 deletions

View File

@@ -28,7 +28,7 @@
<.image_text_section
title="Made with passion, printed with care"
description="Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products from gallery-quality art prints to everyday essentials."
image_url="/mockups/mountain-sunrise-print-3.jpg"
image_url="/mockups/mountain-sunrise-print-3-800.webp"
link_text="Learn more about the studio →"
link_page="about"
mode={@mode}

View File

@@ -32,7 +32,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
~H"""
<div
class="announcement-bar"
style="background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); text-align: center; padding: 0.5rem 1rem; font-size: var(--t-text-small);"
style="background-color: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); text-align: center; padding: 0.5rem 1rem; font-size: var(--t-text-small);"
>
<p style="margin: 0;">{@message}</p>
</div>
@@ -143,7 +143,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
phx-click="change_preview_page"
phx-value-page={@page}
class="flex flex-col items-center justify-center gap-1 py-2 mx-1 rounded-lg min-h-[56px]"
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 15%))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} />
@@ -153,7 +153,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<a
href={@href}
class="flex flex-col items-center justify-center gap-1 py-2 mx-1 rounded-lg min-h-[56px]"
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 15%))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} />
@@ -691,7 +691,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<button
type="submit"
class="cart-drawer-checkout w-full mb-2"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition-all; background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
>
Checkout
</button>
@@ -726,15 +726,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
>
</div>
<style>
.cart-drawer.open {
right: 0 !important;
}
.cart-drawer-overlay.open {
opacity: 1 !important;
visibility: visible !important;
}
</style>
"""
end
@@ -857,23 +848,16 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<%= if @show_badges do %>
<.product_badge product={@product} />
<% end %>
<img
src={@product.image_url}
alt={@product.name}
width="600"
height="600"
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
<.product_card_image
product={@product}
variant={@variant}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/>
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
<img
src={@product.hover_image_url}
alt={@product.name}
width="600"
height="600"
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
<.product_card_image
product={@product}
variant={@variant}
image_key={:hover}
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
@@ -899,6 +883,66 @@ defmodule SimpleshopThemeWeb.ShopComponents do
"""
end
# Helper to render product images with responsive variants.
# Works for both mockups (static files) and database images.
# Requires source_width to be set for responsive image support.
attr :product, :map, required: true
attr :variant, :atom, required: true
attr :class, :string, default: ""
attr :image_key, :atom, default: :primary
defp product_card_image(assigns) do
# Determine which image fields to use based on primary vs hover
{image_id_field, image_url_field, source_width_field} =
case assigns.image_key do
:hover -> {:hover_image_id, :hover_image_url, :hover_source_width}
_ -> {:image_id, :image_url, :source_width}
end
# Build the base image path:
# - Database images: /images/{id}/variant
# - Mockup images: {image_url} (e.g., /mockups/product-1)
image_id = assigns.product[image_id_field]
image_url = assigns.product[image_url_field]
src =
if image_id do
"/images/#{image_id}/variant"
else
image_url
end
assigns =
assigns
|> assign(:src, src)
|> assign(:source_width, assigns.product[source_width_field])
~H"""
<%= if @source_width do %>
<.responsive_image
src={@src}
alt={@product.name}
source_width={@source_width}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
class={@class}
width={600}
height={600}
priority={@variant == :minimal}
/>
<% else %>
<img
src={@src}
alt={@product.name}
width="600"
height="600"
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
class={@class}
/>
<% end %>
"""
end
attr :product, :map, required: true
defp product_badge(assigns) do
@@ -1290,6 +1334,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
def category_nav(assigns) do
~H"""
<section style="padding: var(--space-xl) var(--space-lg); background-color: var(--t-surface-base);">
<h2 class="sr-only">Shop by Category</h2>
<nav class="grid grid-cols-3 gap-4 max-w-3xl mx-auto" aria-label="Product categories">
<%= for category <- Enum.take(@categories, @limit) do %>
<%= if @mode == :preview do %>
@@ -1475,7 +1520,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
phx-click="change_preview_page"
phx-value-page={@link_page}
class="text-sm font-medium transition-colors"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none; cursor: pointer;"
style="color: var(--t-accent-text, hsl(var(--t-accent-h) var(--t-accent-s) 38%)); text-decoration: none; cursor: pointer;"
>
<%= @link_text %>
</a>
@@ -1483,7 +1528,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<a
href={@link_href || "/"}
class="text-sm font-medium transition-colors"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); text-decoration: none;"
style="color: var(--t-accent-text, hsl(var(--t-accent-h) var(--t-accent-s) 38%)); text-decoration: none;"
>
<%= @link_text %>
</a>
@@ -1980,28 +2025,26 @@ defmodule SimpleshopThemeWeb.ShopComponents do
"""
end
@doc """
Renders a social media icon for the given platform.
All icons are from Simple Icons (simpleicons.org), MIT licensed.
## Supported platforms
**Commercial/Creative:**
:instagram, :pinterest, :tiktok, :facebook, :twitter, :youtube, :patreon, :kofi, :etsy, :gumroad, :bandcamp
**Open Web/Federated:**
:mastodon, :pixelfed, :bluesky, :peertube, :lemmy, :matrix
**Developer/Hacker:**
:github, :gitlab, :codeberg, :sourcehut
**Communication:**
:discord, :telegram, :signal
**Other:**
:substack, :rss, :website
"""
# Renders a social media icon for the given platform.
#
# All icons are from Simple Icons (simpleicons.org), MIT licensed.
#
# ## Supported platforms
#
# **Commercial/Creative:**
# :instagram, :pinterest, :tiktok, :facebook, :twitter, :youtube, :patreon, :kofi, :etsy, :gumroad, :bandcamp
#
# **Open Web/Federated:**
# :mastodon, :pixelfed, :bluesky, :peertube, :lemmy, :matrix
#
# **Developer/Hacker:**
# :github, :gitlab, :codeberg, :sourcehut
#
# **Communication:**
# :discord, :telegram, :signal
#
# **Other:**
# :substack, :rss, :website
attr :platform, :atom, required: true
# Commercial/Creative platforms
@@ -2738,15 +2781,6 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</button>
<% end %>
</div>
<style>
.pdp-thumbnail {
border: 2px solid var(--t-border-default);
transition: border-color 0.15s ease;
}
.pdp-thumbnail-active {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));
}
</style>
<.product_lightbox images={@images} product_name={@product_name} id_prefix={@id_prefix} />
</div>
@@ -3233,4 +3267,99 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</p>
"""
end
@doc """
Renders a responsive `<picture>` element with AVIF, WebP, and JPEG sources.
Computes available widths from `source_width` to avoid upscaling - only generates
srcset entries for sizes smaller than or equal to the original image dimensions.
The component renders:
- `<source>` for AVIF (best compression, modern browsers)
- `<source>` for WebP (good compression, broad support)
- `<img>` with JPEG srcset (fallback for legacy browsers)
## Attributes
* `src` - Required. Base path to the image variants (without size/extension).
* `alt` - Required. Alt text for accessibility.
* `source_width` - Required. Original image width in pixels.
* `sizes` - Optional. Responsive sizes attribute. Defaults to "100vw".
* `class` - Optional. CSS classes to apply to the `<img>` element.
* `width` - Optional. Explicit width attribute.
* `height` - Optional. Explicit height attribute.
* `priority` - Optional. If true, sets eager loading and high fetchpriority.
Defaults to false (lazy loading).
## Examples
<.responsive_image
src="/image_cache/abc123"
source_width={1200}
alt="Product image"
/>
<.responsive_image
src="/image_cache/abc123"
source_width={1200}
alt="Hero banner"
priority={true}
sizes="(max-width: 768px) 100vw, 50vw"
/>
"""
attr :src, :string, required: true, doc: "Base path without size/extension"
attr :alt, :string, required: true
attr :source_width, :integer, required: true, doc: "Original image width"
attr :sizes, :string, default: "100vw"
attr :class, :string, default: ""
attr :width, :integer, default: nil
attr :height, :integer, default: nil
attr :priority, :boolean, default: false
def responsive_image(assigns) do
alias SimpleshopTheme.Images.Optimizer
# Compute available widths from source dimensions (no upscaling)
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
~H"""
<picture>
<source
type="image/avif"
srcset={build_srcset(@src, @available_widths, "avif")}
sizes={@sizes}
/>
<source
type="image/webp"
srcset={build_srcset(@src, @available_widths, "webp")}
sizes={@sizes}
/>
<img
src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
width={@width}
height={@height}
loading={if @priority, do: "eager", else: "lazy"}
decoding={if @priority, do: "sync", else: "async"}
fetchpriority={if @priority, do: "high", else: nil}
class={@class}
/>
</picture>
"""
end
defp build_srcset(base, widths, format) do
widths
|> Enum.sort()
|> Enum.map(&"#{base}-#{&1}.#{format} #{&1}w")
|> Enum.join(", ")
end
end