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 CTAButtons from '../CTAButtons.astro';
|
||||||
import TopographicBg from '../TopographicBg.astro';
|
import TopographicBg from '../TopographicBg.astro';
|
||||||
|
import Lightbox from '../Lightbox.astro';
|
||||||
---
|
---
|
||||||
<section class="relative overflow-hidden border-b border-border">
|
<section class="relative overflow-hidden border-b border-border">
|
||||||
<TopographicBg opacity={0.22} lines={11} />
|
<TopographicBg opacity={0.22} lines={11} />
|
||||||
@@ -37,14 +38,12 @@ import TopographicBg from '../TopographicBg.astro';
|
|||||||
</div>
|
</div>
|
||||||
<div class="lg:col-span-7 relative">
|
<div class="lg:col-span-7 relative">
|
||||||
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||||
<img
|
<Lightbox
|
||||||
src="/product/exchange-detail.png"
|
src="/product/exchange-detail.png"
|
||||||
alt="Cameleer Mission Control — route execution detail with processor-level trace"
|
alt="Cameleer Mission Control — route execution detail with processor-level trace"
|
||||||
width="1920"
|
width={1920}
|
||||||
height="945"
|
height={945}
|
||||||
loading="eager"
|
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 class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import TopographicBg from '../TopographicBg.astro';
|
import TopographicBg from '../TopographicBg.astro';
|
||||||
|
import Lightbox from '../Lightbox.astro';
|
||||||
|
|
||||||
interface Callout {
|
interface Callout {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,14 +38,12 @@ const callouts: Callout[] = [
|
|||||||
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
||||||
<figure class="lg:col-span-8 relative">
|
<figure class="lg:col-span-8 relative">
|
||||||
<div class="showcase-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
<div class="showcase-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||||
<img
|
<Lightbox
|
||||||
src="/product/error-detail.png"
|
src="/product/error-detail.png"
|
||||||
alt="Cameleer Mission Control — complex fulfillment route with circuit breaker, fallback, correlated audit route, and full error context"
|
alt="Cameleer Mission Control — complex fulfillment route with circuit breaker, fallback, correlated audit route, and full error context"
|
||||||
width="1920"
|
width={1920}
|
||||||
height="945"
|
height={945}
|
||||||
loading="lazy"
|
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 class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user