feat(hero): Phase 3 — media slot and split-layout variants

- New `content.media` field accepts image (Hugo-pipeline-processed for
  responsive srcset) or video (passes through as <video>). Optional
  `dark_src` provides a dark-mode image variant.
- New `design.layout` enum: centered (default) | split-left |
  split-right | stacked. Split layouts auto-set alignment to 'left'
  unless explicitly overridden.
- Bump trust-strip star size from w-4 to w-5 for visual presence.
- Two new shared partials in layouts/_partials/functions/:
  - resolve_image_resource: path → {resource | fallback_url}
  - process_image_for_props: path → {src, srcset, width, height}
  These factor out the resource-resolution + responsive-processing
  pipeline so media-bearing blocks don't have to inline it.
- Capabilities registry: media-image, media-video, split-layouts.
- Landing page _index.md: keeps the centered hero (no behavioural
  change) but documents the opt-in pattern for split layouts inline.
This commit is contained in:
George Cushen committed 2026-05-02 17:52:57 +01:00
1 parent f32f468cf3
commit 80913c028a
6 files changed
+260 -41

No files matched your search

+132 -40
View File
@@ -81,7 +81,7 @@ function StarStrip({stars}) {
return (
<div class="inline-flex items-center gap-0.5 text-amber-400">
{Array.from({length: 5}).map((_, i) => (
<span key={i} class="w-4 h-4 inline-block" dangerouslySetInnerHTML={{__html: i < full ? STAR_FULL : STAR_EMPTY}} />
<span key={i} class="w-5 h-5 inline-block" dangerouslySetInnerHTML={{__html: i < full ? STAR_FULL : STAR_EMPTY}} />
))}
</div>
);
@@ -155,58 +155,150 @@ const ALIGN_TEXT = {center: "text-center", left: "text-left"};
const ALIGN_FLEX = {center: "justify-center", left: "justify-start"};
const ALIGN_MX = {center: "mx-auto", left: ""};
// Layout presets. `centered` is the classic stacked hero. `split-*` puts text and media
// side-by-side (the dominant 2026 SaaS pattern). `stacked` keeps text top-aligned with
// media full-width below — useful for product screenshots that need horizontal real estate.
const LAYOUTS = {
centered: {container: "max-w-2xl", grid: false, stacked: false, reverse: false},
"split-left": {container: "max-w-7xl", grid: true, stacked: false, reverse: false},
"split-right": {container: "max-w-7xl", grid: true, stacked: false, reverse: true},
stacked: {container: "max-w-6xl", grid: false, stacked: true, reverse: false},
};
// Media: image (with optional dark variant) or video. Hero `media.src` is processed
// through Hugo's responsive image pipeline upstream and arrives as `media_image` with
// {src, srcset, width, height}. Videos pass straight through.
function Media({media, mediaImage, mediaImageDark}) {
if (!media) return null;
const type = media.type || "image";
if (type === "video") {
return (
<div class="relative">
<video
src={media.src}
poster={media.poster}
autoplay={media.autoplay !== false}
loop={media.loop !== false}
muted={media.muted !== false}
playsinline
class="w-full h-auto rounded-2xl shadow-2xl ring-1 ring-gray-900/10 dark:ring-white/10"
/>
</div>
);
}
// image (default)
if (!mediaImage) return null;
const hasDark = !!mediaImageDark;
return (
<div class="relative">
<img
src={mediaImage.src}
srcset={mediaImage.srcset}
sizes="(max-width: 1024px) 100vw, 50vw"
width={mediaImage.width}
height={mediaImage.height}
alt={media.alt || ""}
loading="eager"
class={`w-full h-auto rounded-2xl shadow-2xl ring-1 ring-gray-900/10 dark:ring-white/10 ${hasDark ? "block dark:hidden" : ""}`}
/>
{hasDark && (
<img
src={mediaImageDark.src}
srcset={mediaImageDark.srcset}
sizes="(max-width: 1024px) 100vw, 50vw"
width={mediaImageDark.width}
height={mediaImageDark.height}
alt={media.alt || ""}
loading="eager"
class="hidden dark:block w-full h-auto rounded-2xl shadow-2xl ring-1 ring-white/10"
/>
)}
</div>
);
}
// Hero Block Component - Single implementation
export const HeroBlock = ({content, design, _id, icon_svg, secondary_icon_svg}) => {
export const HeroBlock = ({content, design, _id, icon_svg, secondary_icon_svg, media_image, media_image_dark}) => {
// Backward compat: legacy `no_padding: true` maps to size: "none"
const sizeKey = SIZE_CLASSES[design?.size] ? design.size : design?.no_padding ? "none" : "default";
const sizeClasses = SIZE_CLASSES[sizeKey];
const alignKey = design?.alignment === "left" ? "left" : "center";
const layoutKey = LAYOUTS[design?.layout] ? design.layout : "centered";
const layout = LAYOUTS[layoutKey];
// Auto-align: split layouts read better left-aligned unless explicitly centred
const alignKey = design?.alignment === "left" || design?.alignment === "center" ? design.alignment : layout.grid ? "left" : "center";
const textAlign = ALIGN_TEXT[alignKey];
const flexAlign = ALIGN_FLEX[alignKey];
const mxAuto = ALIGN_MX[alignKey];
return (
<div class="relative isolate px-6 lg:px-8">
<div class={`mx-auto max-w-2xl ${sizeClasses}`}>
<AnnouncementPill announcement={content.announcement} alignKey={alignKey} />
const contentStack = (
<>
<AnnouncementPill announcement={content.announcement} alignKey={alignKey} />
<div class={textAlign}>
{content.eyebrow && (
<p
class="mb-4 text-sm font-semibold uppercase tracking-wider text-primary-600 dark:text-primary-400"
dangerouslySetInnerHTML={{__html: renderText(content.eyebrow)}}
/>
)}
{content.title && (
<h1
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
dangerouslySetInnerHTML={{__html: renderTitle(content.title)}}
/>
)}
{content.text && (
<p
class={`mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl ${mxAuto}`}
dangerouslySetInnerHTML={{__html: renderText(content.text)}}
/>
)}
{(content.primary_action?.url || content.secondary_action?.url) && (
<div class={`mt-10 flex items-center ${flexAlign} gap-x-6 flex-wrap gap-y-3`}>
<ActionButton action={content.primary_action} defaultStyle="gradient" iconSvg={icon_svg} />
<ActionButton action={content.secondary_action} defaultStyle="text" iconSvg={secondary_icon_svg} />
</div>
)}
<TrustStrip trust={content.trust} alignKey={alignKey} />
</div>
</>
);
<div class={textAlign}>
{/* Eyebrow */}
{content.eyebrow && (
<p
class="mb-4 text-sm font-semibold uppercase tracking-wider text-primary-600 dark:text-primary-400"
dangerouslySetInnerHTML={{__html: renderText(content.eyebrow)}}
/>
)}
const mediaEl = content.media ? <Media media={content.media} mediaImage={media_image} mediaImageDark={media_image_dark} /> : null;
{/* Title */}
{content.title && (
<h1
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
dangerouslySetInnerHTML={{__html: renderTitle(content.title)}}
/>
)}
{/* Subtitle/Text */}
{content.text && (
<p
class={`mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl ${mxAuto}`}
dangerouslySetInnerHTML={{__html: renderText(content.text)}}
/>
)}
{/* Action Buttons */}
{(content.primary_action?.url || content.secondary_action?.url) && (
<div class={`mt-10 flex items-center ${flexAlign} gap-x-6 flex-wrap gap-y-3`}>
<ActionButton action={content.primary_action} defaultStyle="gradient" iconSvg={icon_svg} />
<ActionButton action={content.secondary_action} defaultStyle="text" iconSvg={secondary_icon_svg} />
</div>
)}
<TrustStrip trust={content.trust} alignKey={alignKey} />
// Split layout: two-column grid, optionally reversed
if (layout.grid) {
return (
<div class="relative isolate px-6 lg:px-8">
<div class={`mx-auto ${layout.container} ${sizeClasses}`}>
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<div class={layout.reverse ? "lg:order-2" : ""}>{contentStack}</div>
{mediaEl && <div class={layout.reverse ? "lg:order-1" : ""}>{mediaEl}</div>}
</div>
</div>
</div>
);
}
// Stacked layout: text top (constrained), media full-width below
if (layout.stacked) {
return (
<div class="relative isolate px-6 lg:px-8">
<div class={`mx-auto ${layout.container} ${sizeClasses}`}>
<div class="mx-auto max-w-3xl">{contentStack}</div>
{mediaEl && <div class="mt-16">{mediaEl}</div>}
</div>
</div>
);
}
// Centered (default)
return (
<div class="relative isolate px-6 lg:px-8">
<div class={`mx-auto ${layout.container} ${sizeClasses}`}>{contentStack}</div>
</div>
);
};
@@ -33,6 +33,24 @@
{{ end }}
{{ end }}
{{/* Resolve hero media (single image, optional dark variant). Videos pass through unprocessed. */}}
{{ with $block.content.media }}
{{ if or (not .type) (eq .type "image") }}
{{ with .src }}
{{ $img_data := partial "functions/process_image_for_props" (dict "path" . "Page" $page) }}
{{ with $img_data.src }}
{{ $props = merge $props (dict "media_image" $img_data) }}
{{ end }}
{{ end }}
{{ with .dark_src }}
{{ $img_data := partial "functions/process_image_for_props" (dict "path" . "Page" $page) }}
{{ with $img_data.src }}
{{ $props = merge $props (dict "media_image_dark" $img_data) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{/* Resolve icon for single button (cta-card) */}}
{{ with $block.content.button.icon }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
@@ -0,0 +1,45 @@
{{/*
process_image_for_props — One-shot helper: resolves an image path and returns a
{src, srcset?, width?, height?} dict ready to merge into a Preact block's props.
SVGs and GIFs pass through unprocessed (preserves vectors / animation frames).
Inputs:
.path: image path (required)
.Page: current page (recommended for page-resource lookup)
.sizes: slice of widths for srcset generation
(optional, defaults to [600, 900, 1200, 1800])
Returns: image data dict {src, srcset?, width?, height?}, or empty dict if the
path is missing or unresolvable.
*/}}
{{ $result := dict }}
{{ $path := .path | default "" }}
{{ if $path }}
{{ $sizes := .sizes | default (slice 600 900 1200 1800) }}
{{ $resolved := partial "functions/resolve_image_resource" (dict "path" $path "Page" .Page) }}
{{ with $resolved.resource }}
{{ $canProcess := and (reflect.IsImageResourceProcessable .) (ne .MediaType.SubType "gif") }}
{{ if not $canProcess }}
{{ $result = dict "src" .RelPermalink }}
{{ else }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" .
"mode" "fit"
"sizes" $sizes
) }}
{{ $result = dict
"src" $responsive.fallback.RelPermalink
"srcset" $responsive.srcset
"width" $responsive.fallback.Width
"height" $responsive.fallback.Height
}}
{{ end }}
{{ end }}
{{ with $resolved.fallback_url }}
{{ $result = dict "src" . }}
{{ end }}
{{ end }}
{{ return $result }}
@@ -0,0 +1,54 @@
{{/*
resolve_image_resource — Resolves an image path string to either a Hugo image
resource or a fallback URL. Handles remote URLs, page resources, and assets/media
lookups using the same rules as the inline per-item image resolver.
Inputs:
.path: image path (required) — relative ("dashboard.png", "media/foo.jpg")
or absolute URL ("https://…").
.Page: current page (optional, recommended for page-resource lookup).
Returns dict with one of:
.resource: Hugo image resource (use with .Process, srcset, etc.)
.fallback_url: plain URL string (use as <img src>)
Or an empty dict if path is missing.
*/}}
{{ $result := dict }}
{{ $path := strings.TrimSpace (printf "%v" (.path | default "")) }}
{{ if $path }}
{{ $is_remote := or (strings.HasPrefix $path "http://") (strings.HasPrefix $path "https://") }}
{{ if $is_remote }}
{{ $remote := try (resources.GetRemote $path) }}
{{ if and $remote (not $remote.Err) }}
{{ $result = dict "resource" $remote.Value }}
{{ else }}
{{ $result = dict "fallback_url" $path }}
{{ end }}
{{ else }}
{{ $normalized := $path }}
{{ $normalized = strings.TrimPrefix "/" $normalized }}
{{ $normalized = strings.TrimPrefix "./" $normalized }}
{{ $normalized = strings.TrimPrefix "assets/" $normalized }}
{{ $normalized = strings.TrimPrefix "media/" $normalized }}
{{ $normalized = path.Clean $normalized }}
{{ if and $normalized (ne $normalized ".") }}
{{ $resource := false }}
{{ with .Page }}
{{ $resource = (.Resources.ByType "image").GetMatch $normalized }}
{{ end }}
{{ if not $resource }}
{{ $resource = resources.Get (path.Join "media" $normalized) }}
{{ end }}
{{ if $resource }}
{{ $result = dict "resource" $resource }}
{{ else }}
{{ $result = dict "fallback_url" (printf "/media/%s" $normalized) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ return $result }}
+4 -1
View File
@@ -89,7 +89,10 @@
"announcement-badge",
"trust-strip",
"size-presets",
"alignment"
"alignment",
"media-image",
"media-video",
"split-layouts"
],
"complexity": "simple",
"preact": true,
@@ -30,6 +30,13 @@ sections:
trust:
stars: 5
text: Loved by **10,000+** developers · Trusted by teams at Google, OpenAI, NVIDIA
# To switch to a split layout with a product mockup, uncomment the media block
# below and set `design.layout: split-left` (or `split-right` / `stacked`):
# media:
# type: image
# src: dashboard.png
# dark_src: dashboard-dark.png # optional dark-mode variant
# alt: Hugo Blox dashboard
design:
spacing:
padding: [0, 0, 0, 0]