feat(design): click-to-enlarge on product screenshots
Some checks failed
ci / build-test (push) Failing after 3m41s
Some checks failed
ci / build-test (push) Failing after 3m41s
Lightbox.astro — reusable native HTMLDialogElement wrapper: - Trigger: <button> wrapping the <img>, cursor: zoom-in, amber zoom-pill badge fades in on hover/focus. - Dialog: showModal() opens a full-viewport modal (≤1800x1200 cap) with blurred amber-tinted backdrop. - Close paths: native form[method=dialog] submit (Escape + close button), click on backdrop, click on the image itself. - Accessibility: aria-labelledby + visually-hidden heading avoids both aria-label-misuse and no-redundant-role validator conflicts. Focus returns to trigger on close (native HTMLDialogElement behavior). - Motion: 220ms fade+scale open, disabled under prefers-reduced-motion. - CSP: <script> is Astro-bundled to an external file — script-src 'self' respected. Hero and ProductShowcase now use <Lightbox> instead of a raw <img> for the product screenshots. The existing frame styling (border, glow, ring overlay) is untouched — the lightbox trigger is a block-level button that fills the frame.
This commit is contained in:
233
src/components/Lightbox.astro
Normal file
233
src/components/Lightbox.astro
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
loading?: 'eager' | 'lazy';
|
||||
caption?: string;
|
||||
triggerClass?: string;
|
||||
imgClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
loading = 'lazy',
|
||||
caption,
|
||||
triggerClass = '',
|
||||
imgClass = 'block w-full h-auto',
|
||||
} = Astro.props;
|
||||
|
||||
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
||||
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
---
|
||||
<button
|
||||
type="button"
|
||||
class={`lightbox-trigger group ${triggerClass}`.trim()}
|
||||
data-lightbox-open={dialogId}
|
||||
aria-label={`Enlarge: ${alt}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
class={imgClass}
|
||||
/>
|
||||
<span aria-hidden="true" class="lightbox-zoom-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7.5"/>
|
||||
<path d="m20 20-3.5-3.5"/>
|
||||
<line x1="11" y1="8" x2="11" y2="14"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<dialog id={dialogId} class="lightbox-dialog" aria-modal="true" aria-labelledby={`${dialogId}-title`}>
|
||||
<h2 id={`${dialogId}-title`} class="sr-only">{alt}</h2>
|
||||
<form method="dialog" class="lightbox-close-form">
|
||||
<button type="submit" class="lightbox-close" aria-label="Close">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<img src={src} alt={alt} class="lightbox-image" />
|
||||
{caption && <p class="lightbox-caption">{caption}</p>}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
/* Trigger: reset native button chrome, keep block layout matching the old <img>. */
|
||||
.lightbox-trigger {
|
||||
all: unset;
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
width: 100%;
|
||||
}
|
||||
.lightbox-trigger:focus-visible {
|
||||
outline: 2px solid #f0b429;
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Zoom pill (fades in on hover / keyboard focus) */
|
||||
.lightbox-zoom-badge {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(240, 180, 41, 0.92);
|
||||
color: #060a13;
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
transition: opacity 180ms ease-out, transform 180ms ease-out;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.lightbox-trigger:hover .lightbox-zoom-badge,
|
||||
.lightbox-trigger:focus-visible .lightbox-zoom-badge {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
.lightbox-dialog {
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(98vw, 1800px);
|
||||
height: min(96vh, 1200px);
|
||||
background: #060a13;
|
||||
color: #e8eaed;
|
||||
border: 1px solid rgba(240, 180, 41, 0.25);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 40px 100px -30px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.lightbox-dialog::backdrop {
|
||||
background: rgba(6, 10, 19, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.lightbox-close-form {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.lightbox-close {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
color: #f0b429;
|
||||
background: rgba(12, 17, 26, 0.92);
|
||||
border: 1px solid rgba(240, 180, 41, 0.35);
|
||||
transition: background 160ms ease-out, transform 160ms ease-out, border-color 160ms ease-out;
|
||||
}
|
||||
.lightbox-close:hover {
|
||||
background: rgba(240, 180, 41, 0.18);
|
||||
border-color: rgba(240, 180, 41, 0.7);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.lightbox-close:focus-visible {
|
||||
outline: 2px solid #f0b429;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #060a13;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.lightbox-caption {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.5rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #9aa3b2;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Open animation */
|
||||
@keyframes lightbox-in {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes lightbox-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.lightbox-dialog[open] {
|
||||
animation: lightbox-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.lightbox-dialog[open]::backdrop {
|
||||
animation: lightbox-backdrop-in 220ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lightbox-zoom-badge {
|
||||
transition: none;
|
||||
}
|
||||
.lightbox-dialog[open],
|
||||
.lightbox-dialog[open]::backdrop {
|
||||
animation: none;
|
||||
}
|
||||
.lightbox-close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Open lightbox on trigger click. Close on click of the image itself or
|
||||
// the backdrop. Bundled by Astro — CSP script-src 'self'.
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>('[data-lightbox-open]');
|
||||
triggers.forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const id = trigger.getAttribute('data-lightbox-open');
|
||||
if (!id) return;
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.showModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.lightbox-dialog');
|
||||
dialogs.forEach((dialog) => {
|
||||
dialog.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
// Click on the dialog element itself (the backdrop area) or the image
|
||||
// closes. Clicks on the close button are already handled by the native
|
||||
// form[method=dialog].
|
||||
if (target === dialog || target.classList.contains('lightbox-image')) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import CTAButtons from '../CTAButtons.astro';
|
||||
import TopographicBg from '../TopographicBg.astro';
|
||||
import Lightbox from '../Lightbox.astro';
|
||||
---
|
||||
<section class="relative overflow-hidden border-b border-border">
|
||||
<TopographicBg opacity={0.22} lines={11} />
|
||||
@@ -37,14 +38,12 @@ import TopographicBg from '../TopographicBg.astro';
|
||||
</div>
|
||||
<div class="lg:col-span-7 relative">
|
||||
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||
<img
|
||||
<Lightbox
|
||||
src="/product/exchange-detail.png"
|
||||
alt="Cameleer Mission Control — route execution detail with processor-level trace"
|
||||
width="1920"
|
||||
height="945"
|
||||
width={1920}
|
||||
height={945}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="block w-full h-auto"
|
||||
/>
|
||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import TopographicBg from '../TopographicBg.astro';
|
||||
import Lightbox from '../Lightbox.astro';
|
||||
|
||||
interface Callout {
|
||||
title: string;
|
||||
@@ -37,14 +38,12 @@ const callouts: Callout[] = [
|
||||
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
||||
<figure class="lg:col-span-8 relative">
|
||||
<div class="showcase-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||
<img
|
||||
<Lightbox
|
||||
src="/product/error-detail.png"
|
||||
alt="Cameleer Mission Control — complex fulfillment route with circuit breaker, fallback, correlated audit route, and full error context"
|
||||
width="1920"
|
||||
height="945"
|
||||
width={1920}
|
||||
height={945}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="block w-full h-auto"
|
||||
/>
|
||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user