2 Commits

16 changed files with 190 additions and 34 deletions
@@ -0,0 +1,61 @@
# Animazioni stile template Prowess — Design
Data: 2026-07-02 · Stato: approvato dall'operatore
## Obiettivo
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
## Evidenze raccolte dal template
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
- gli elementi appaiono allo scroll con un offset di attivazione di 100 px dal viewport (`qodefElementAppearAmount: -100`);
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
- la hero è uno Slider Revolution con testi che entrano in sequenza;
- card e foto hanno hover con zoom immagine e transizioni morbide.
## Decisioni
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
## Architettura
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
## Dettaglio per componente
| Componente | Effetto |
|---|---|
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
| Agenda | Reveal heading e tabella |
| TeamSection | Head reveal; card con stagger; hover zoom foto |
| MethodSteps | Heading reveal; step con stagger |
| PartnersStrip | Reveal fade dell'intera striscia |
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
| Header | Transizione morbida dell'ombra già presente |
| Bottoni `.btn` | Freccia trasla a destra su hover |
## Errori e casi limite
- JS assente: nessun elemento nascosto (gate su `html.js`).
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
## Verifica
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
## Fuori scope
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
+3 -3
View File
@@ -3,13 +3,13 @@ import { agenda, disciplines, days, slots } from '../data/agenda';
--- ---
<section class="section section--dark agenda"> <section class="section section--dark agenda">
<div class="container"> <div class="container">
<div class="section-heading"><h2>Agenda</h2></div> <div class="section-heading" data-reveal><h2>Agenda</h2></div>
<div class="agenda__tabs" role="tablist"> <div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
{disciplines.map((d, i) => ( {disciplines.map((d, i) => (
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button> <button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
))} ))}
</div> </div>
<div class="agenda__grid"> <div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
<div class="agenda__corner"></div> <div class="agenda__corner"></div>
{days.map((d) => <div class="agenda__day">{d}</div>)} {days.map((d) => <div class="agenda__day">{d}</div>)}
{slots.map((slot) => ( {slots.map((slot) => (
+1 -1
View File
@@ -21,7 +21,7 @@ const path = Astro.url.pathname;
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); } .hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); } .hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; } .hdr__nav { display: flex; gap: 34px; }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); } .hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); } .hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; } .hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; } .hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
+1 -1
View File
@@ -3,7 +3,7 @@ interface Props { title: string; eyebrow?: string }
const { title, eyebrow } = Astro.props; const { title, eyebrow } = Astro.props;
--- ---
<section class="phero"> <section class="phero">
<div class="container"> <div class="container" data-reveal>
{eyebrow && <p class="eyebrow">{eyebrow}</p>} {eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1> <h1>{title}</h1>
</div> </div>
+2 -2
View File
@@ -16,8 +16,8 @@ const blocks = [
<div class="container"> <div class="container">
{blocks.map((b) => ( {blocks.map((b) => (
<div class:list={['feat', { 'feat--reverse': b.reverse }]}> <div class:list={['feat', { 'feat--reverse': b.reverse }]}>
<Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" /> <Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
<div class="feat__text"> <div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
<h2>{b.title}</h2> <h2>{b.title}</h2>
<p>{b.text}</p> <p>{b.text}</p>
<a class="btn" href="/programs">Scopri</a> <a class="btn" href="/programs">Scopri</a>
+13
View File
@@ -37,6 +37,19 @@ const slides = [
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; } .hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; } .hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } } @media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
/* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } }
:global(html.js) .hero__line { opacity: 0; animation: hero-up .8s ease forwards; }
:global(html.js) .hero__line:nth-child(1) { animation-delay: .15s; }
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; }
:global(html.js) .hero__line:nth-child(3) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
@media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__line, :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
}
</style> </style>
<script> <script>
+2 -2
View File
@@ -9,8 +9,8 @@ const cols = [
]; ];
--- ---
<section class="info" aria-label="pilastri"> <section class="info" aria-label="pilastri">
{cols.map((c) => ( {cols.map((c, i) => (
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}> <a class="info__col" href="/programs" style={`background-image:url(${c.image.src});--reveal-delay:${i * 0.15}s`} data-reveal="fade">
<span class="info__title">{c.title}</span> <span class="info__title">{c.title}</span>
</a> </a>
))} ))}
+3 -3
View File
@@ -7,14 +7,14 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
--- ---
<section class="section section--alt"> <section class="section section--alt">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading" data-reveal>
<h2>Il metodo<br />InsanityLab</h2> <h2>Il metodo<br />InsanityLab</h2>
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} /> <p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
</div> </div>
<p class="method__note">{methodExtraNote}</p> <p class="method__note">{methodExtraNote}</p>
<div class="method__row"> <div class="method__row">
{methodSteps.map((s) => ( {methodSteps.map((s, i) => (
<div class:list={['method__step', { 'method__step--extra': s.extra }]}> <div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
<div class="method__circle"> <div class="method__circle">
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />} {imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
<span>{s.n}</span> <span>{s.n}</span>
+1 -1
View File
@@ -6,7 +6,7 @@ const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/pa
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
--- ---
<section class="pstrip" aria-label="partner"> <section class="pstrip" aria-label="partner">
<div class="container pstrip__in"> <div class="container pstrip__in" data-reveal="fade">
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)} {partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
</div> </div>
</section> </section>
+7 -4
View File
@@ -11,13 +11,13 @@ const icons: Record<string, string> = {
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading" data-reveal>
<h2>Programmi</h2> <h2>Programmi</h2>
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.</p> <p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.</p>
</div> </div>
<div class="pgrid"> <div class="pgrid">
{programs.map((p) => ( {programs.map((p, i) => (
<a class="pgrid__item" href={`/programs/${p.slug}`}> <a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
<h3>{p.title}</h3> <h3>{p.title}</h3>
<p>{p.subtitle}</p> <p>{p.subtitle}</p>
@@ -29,7 +29,10 @@ const icons: Record<string, string> = {
<style> <style>
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; } .pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
.pgrid__item { text-decoration: none; } .pgrid__item { text-decoration: none; transition: transform .3s ease; }
.pgrid__item:hover { transform: translateY(-4px); }
.pgrid__item:hover h3 { color: var(--c-accent-dark); }
.pgrid__item h3 { transition: color .3s ease; }
.pgrid__item svg { margin-inline: auto; } .pgrid__item svg { margin-inline: auto; }
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; } .pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; } .pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
+28 -7
View File
@@ -1,22 +1,43 @@
--- ---
import quoteBg from '../../assets/img/quote-bg.jpg'; import quoteBg from '../../assets/img/quote-bg.jpg';
const quote = 'PERFORMANCE È SPINGERE OLTRE I PROPRI LIMITI, BALANCE È SAPERLI ASCOLTARE, LONGEVITY È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.';
const quoteHtml = quote.replace(/(PERFORMANCE|BALANCE|LONGEVITY)/g, '<strong>$1</strong>');
--- ---
<section class="quote" style={`background-image:url(${quoteBg.src})`}> <section class="quote" data-parallax>
<div class="container quote__in"> <div class="quote__bg" style={`background-image:url(${quoteBg.src})`}></div>
<div class="quote__in">
<p class="quote__mark">“</p> <p class="quote__mark">“</p>
<blockquote> <blockquote>
<p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p> <div class="quote__marquee">
<p class="quote__track">
<span class="quote__seg" set:html={quoteHtml} />
<span class="quote__seg" aria-hidden="true" set:html={quoteHtml} />
</p>
</div>
<cite>Insanitylab</cite> <cite>Insanitylab</cite>
</blockquote> </blockquote>
</div> </div>
</section> </section>
<style> <style>
.quote { position: relative; background-size: cover; background-position: center; text-align: center; } .quote { position: relative; overflow: hidden; text-align: center; }
.quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); } /* Sfondo su div dedicato, esteso oltre i bordi per il margine di corsa del parallax */
.quote__in { position: relative; padding-block: 110px; max-width: 820px; } .quote__bg { position: absolute; inset: -120px 0; background-size: cover; background-position: center; transform: translateY(var(--parallax-y, 0)); will-change: transform; }
.quote__bg::after { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
.quote__in { position: relative; padding-block: 110px; }
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; } .quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
.quote blockquote { margin: 0; } .quote blockquote { margin: 0; }
.quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; } /* Marquee: testo a scorrimento infinito come il ticker del template */
.quote__marquee { overflow: hidden; white-space: nowrap; }
.quote__track { display: inline-flex; margin: 0; animation: quote-scroll 30s linear infinite; will-change: transform; }
.quote__marquee:hover .quote__track { animation-play-state: paused; }
.quote__seg { padding-right: 4em; color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
@keyframes quote-scroll { to { transform: translateX(-50%); } }
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; } .quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
@media (prefers-reduced-motion: reduce) {
.quote__track { animation: none; white-space: normal; display: block; max-width: 820px; margin-inline: auto; padding-inline: 20px; }
.quote__seg { padding-right: 0; }
.quote__seg[aria-hidden] { display: none; }
.quote__bg { transform: none; }
}
</style> </style>
+6 -5
View File
@@ -8,13 +8,13 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="tteam__head"> <div class="tteam__head" data-reveal>
<h2>Team<br />Trainer</h2> <h2>Team<br />Trainer</h2>
<a class="btn" href="/about">Scopri team</a> <a class="btn" href="/about">Scopri team</a>
</div> </div>
<div class="tteam__grid"> <div class="tteam__grid">
{members.map((m) => ( {members.map((m, i) => (
<figure class="tteam__card"> <figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" /> <Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<figcaption>{m.shortName}</figcaption> <figcaption>{m.shortName}</figcaption>
</figure> </figure>
@@ -27,8 +27,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; } .tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; } .tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; } .tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.tteam__card { position: relative; margin: 0; } .tteam__card { position: relative; margin: 0; overflow: hidden; }
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; } .tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; transition: transform .5s ease; }
.tteam__card:hover img { transform: scale(1.06); }
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); } .tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
</style> </style>
+9 -3
View File
@@ -12,13 +12,17 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
{trainings.map((t, i) => { {trainings.map((t, i) => {
const img = imgOf(t.image); const img = imgOf(t.image);
const card = ( const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]}> <div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
<h3>{t.title}</h3> <h3>{t.title}</h3>
<p>{t.caption}</p> <p>{t.caption}</p>
<a class="tcards__link" href={t.cta.href}>Scopri →</a> <a class="tcards__link" href={t.cta.href}>Scopri →</a>
</div> </div>
); );
const photo = img ? <Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" /> : null; const photo = img ? (
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
<Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
</div>
) : null;
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>; return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
})} })}
</section> </section>
@@ -37,6 +41,8 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; } .tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
.tcards__cell p { font-size: .88rem; } .tcards__cell p { font-size: .88rem; }
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; } .tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; } .tcards__photo { overflow: hidden; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
</style> </style>
+35
View File
@@ -25,10 +25,45 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} /> <meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<script is:inline>document.documentElement.classList.add('js');</script>
</head> </head>
<body> <body>
<Header /> <Header />
<main><slot /></main> <main><slot /></main>
<Footer /> <Footer />
<script>
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-inview');
io.unobserve(e.target);
// A reveal concluso l'attributo va tolto, così la transition del reveal
// non rallenta gli hover su transform del componente.
e.target.addEventListener('transitionend', () => {
e.target.removeAttribute('data-reveal');
}, { once: true });
}
}
}, { rootMargin: '0px 0px -100px 0px' });
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
let raf = 0;
const update = () => {
raf = 0;
for (const el of parallaxEls) {
const r = el.getBoundingClientRect();
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
}
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
}
</script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -24,8 +24,8 @@ export const prerender = true;
<div class="container"> <div class="container">
{trainings.map((t, i) => ( {trainings.map((t, i) => (
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}> <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}>
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" />} {imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
<div> <div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
<h2>{t.title.toLowerCase()}</h2> <h2>{t.title.toLowerCase()}</h2>
<p>{t.description}</p> <p>{t.description}</p>
<a class="btn" href={t.cta.href}>{t.cta.label}</a> <a class="btn" href={t.cta.href}>{t.cta.label}</a>
+16
View File
@@ -52,3 +52,19 @@ textarea.field { min-height: 120px; resize: vertical; }
/* honeypot */ /* honeypot */
.hp { position: absolute; left: -9999px; opacity: 0; } .hp { position: absolute; left: -9999px; opacity: 0; }
/* === Animazioni (stile template Prowess) === */
/* Lo stato nascosto vale solo con JS attivo (html.js): senza JS tutto resta visibile. */
html.js [data-reveal], html.js .section-heading { opacity: 0; transform: translateY(40px); transition: opacity .9s ease, transform .9s ease; transition-delay: var(--reveal-delay, 0s); }
html.js [data-reveal="left"] { transform: translateX(-60px); }
html.js [data-reveal="right"] { transform: translateX(60px); }
html.js [data-reveal="fade"] { transform: none; }
html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1; transform: none; }
.btn::after { transition: transform .3s ease; }
.btn:hover::after { transform: translateX(5px); }
@media (prefers-reduced-motion: reduce) {
html.js [data-reveal], html.js .section-heading { opacity: 1; transform: none; transition: none; }
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}