feat(equipa): o cartão de detalhe nasce da foto, estica e segue o rato

O cartão abre por cima da própria foto (mesmo sítio, tamanho e inclinação),
endireita-se e estica para o lado — para a esquerda nas fotos da ponta
direita — com o texto a entrar por trás. Aberto, inclina-se em 3D e desliza
uns píxeis na direção do rato; ao sair volta a dobrar-se para dentro da foto.
No telemóvel voa da foto para o centro (FLIP) e volta ao fechar.

- clip-path em px calculados no JS: o Chrome não interpola inset() com
  calc(100% - …) e o recorte saltava em vez de esticar.
- O cartão é posicionado sem transição antes de abrir; senão a transição do
  estado fechado arrancava primeiro e a abertura partia de valores antigos.
- Tira o «Saber mais» do cartão da equipa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-24 21:28:32 +01:00
1 parent 1614bf327b
commit 90ee2a93bc
3 files changed
+237 -84

No files matched your search

+103 -48
View File
@@ -2962,7 +2962,7 @@ section[id] {
/* The name used to be the tile's last child and got its size from the /* The name used to be the tile's last child and got its size from the
`.trainer-item>*:last-child` rule; pin it so it stays the same whether or `.trainer-item>*:last-child` rule; pin it so it stays the same whether or
not a title / "Saber mais" follows it. */ not a title follows it. */
.trainers-section .trainer-item h4 { .trainers-section .trainer-item h4 {
font-size: .9rem; font-size: .9rem;
} }
@@ -2987,80 +2987,105 @@ section[id] {
outline: none; outline: none;
} }
.trainer-more { /* On tiles with a card, the card IS the hover effect: keep the photo in its
display: inline-flex; resting tilt (the card starts from exactly that pose) and hide it while the
align-items: center; card covers it, bringing it back only once the card has folded back in. */
gap: 6px; #powerRotation>div:nth-child(odd) .trainer-item.has-detail .trainer-pic:hover { transform: rotate(-3deg); }
margin-top: .55rem; #powerRotation>div:nth-child(even) .trainer-item.has-detail .trainer-pic:hover { transform: rotate(3deg); }
font-size: .72rem;
font-weight: 700; .trainer-item.has-detail .trainer-pic {
letter-spacing: .12em; transition: transform .4s cubic-bezier(.34, 1.56, .64, 1), box-shadow var(--fitme-transition), visibility 0s linear .38s;
text-transform: uppercase;
color: var(--fitme-ash);
transition: color var(--fitme-transition);
} }
.trainer-more i { font-size: .65rem; } .trainer-item.has-detail.is-open .trainer-pic {
visibility: hidden;
.trainer-item.has-detail:hover .trainer-more, transition-delay: 0s;
.trainer-item.has-detail:focus-visible .trainer-more,
.trainer-item.has-detail.is-open .trainer-more {
color: var(--fitme-accent);
} }
@media (max-width: 767px), (hover: none) {
/* Touch: the card flies to the centre, so leave the tile photo in place. */
.trainer-item.has-detail.is-open .trainer-pic { visibility: visible; }
}
/* ── Detail card: grows out of the tile's own photo ─────────────────────────
main.js places the card so its photo column sits exactly on the tile photo
(--td-x/--td-y, size --td-pw/--td-ph) with the tile's tilt (--td-rot). Closed,
the clip-path shows only that photo square, so it reads as the tile itself;
opening straightens it and unclips it sideways — the card "stretches" out of
the photo and the text fades in behind the edge. .td-flip mirrors it for
right-hand tiles (photo on the right, card stretching left). While open, the
mouse position feeds a small 3D tilt + drift (--td-rx/--td-ry/--td-mx/--td-my).
Both transform lists have the same functions so they interpolate. */
.trainer-detail { .trainer-detail {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
top: -10px; left: var(--td-x, 0px);
left: 50%; top: var(--td-y, 0px);
width: min(560px, calc(100vw - 32px)); width: var(--td-w, 560px);
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(8px); min-height: var(--td-ph, 200px);
display: flex; display: flex;
text-align: left; text-align: left;
background: #fff; background: #fff;
color: var(--fitme-text); color: var(--fitme-text);
border-radius: 6px; border-radius: 6px;
border-left: 4px solid var(--fitme-accent);
box-shadow: 0 24px 60px rgba(0, 0, 0, .45); box-shadow: 0 24px 60px rgba(0, 0, 0, .45);
overflow: hidden; overflow: hidden;
/* px from main.js: Chrome won't interpolate inset() with calc(100% - …). */
clip-path: inset(0px var(--td-cx, 360px) var(--td-cy, 0px) 0px round 2px);
transform-origin: calc(var(--td-pw, 200px) / 2) calc(var(--td-ph, 200px) / 2);
transform: perspective(900px) rotateX(0deg) rotateY(0deg) translate(0px, 0px) rotate(var(--td-rot, 0deg));
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s; transition:
clip-path .38s cubic-bezier(.4, 0, .2, 1),
transform .38s cubic-bezier(.4, 0, .2, 1),
opacity .12s ease .26s,
visibility 0s linear .38s;
}
.trainer-detail.td-flip {
flex-direction: row-reverse;
clip-path: inset(0px 0px var(--td-cy, 0px) var(--td-cx, 360px) round 2px);
transform-origin: calc(100% - var(--td-pw, 200px) / 2) calc(var(--td-ph, 200px) / 2);
} }
.trainer-item.is-open .trainer-detail { .trainer-item.is-open .trainer-detail {
clip-path: inset(0px 0px 0px 0px round 6px);
transform: perspective(900px) rotateX(var(--td-rx, 0deg)) rotateY(var(--td-ry, 0deg)) translate(var(--td-mx, 0px), var(--td-my, 0px)) rotate(0deg);
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0); transition:
transition: opacity .2s ease, transform .2s ease, visibility 0s; clip-path .55s cubic-bezier(.2, .9, .25, 1),
transform .6s cubic-bezier(.34, 1.35, .64, 1),
opacity 0s,
visibility 0s;
} }
@media (hover: hover) and (pointer: fine) { /* After the opening move, follow the mouse with a short, soft transition. */
.trainer-item.has-detail:hover .trainer-detail, .trainer-item.is-open .trainer-detail.td-tracking {
.trainer-item.has-detail:focus-within .trainer-detail { transition:
opacity: 1; clip-path .55s cubic-bezier(.2, .9, .25, 1),
visibility: visible; transform .18s ease-out,
pointer-events: auto; opacity 0s,
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0); visibility 0s;
transition: opacity .2s ease .12s, transform .2s ease .12s, visibility 0s .12s;
}
.trainer-item.has-detail:hover { z-index: 31; }
} }
.trainer-item.is-open { z-index: 31; } .trainer-item.is-open { z-index: 31; }
.td-photo { .td-photo {
flex: 0 0 38%; flex: 0 0 var(--td-pw, 200px);
max-width: 210px; box-sizing: border-box;
background: var(--fitme-dark-2); border: 4px solid var(--fitme-accent);
background: var(--fitme-surface-2);
align-self: stretch;
} }
.td-photo img { .td-photo img {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 220px; min-height: calc(var(--td-ph, 200px) - 8px);
object-fit: cover; object-fit: cover;
object-position: top; object-position: top;
display: block; display: block;
@@ -3071,6 +3096,17 @@ section[id] {
padding: 18px 22px 18px 20px; padding: 18px 22px 18px 20px;
max-height: 340px; max-height: 340px;
overflow-y: auto; overflow-y: auto;
opacity: 0;
transform: translateX(-10px);
transition: opacity .12s ease, transform .12s ease;
}
.td-flip .td-body { transform: translateX(10px); }
.trainer-item.is-open .td-body {
opacity: 1;
transform: none;
transition: opacity .3s ease .18s, transform .35s ease .18s;
} }
.td-name { .td-name {
@@ -3148,20 +3184,32 @@ section[id] {
display: none; display: none;
} }
/* Touch / small screens: a centred fixed card over a dimmed page. */ /* Touch / small screens: a centred fixed card over a dimmed page. The "grows
out of the tile" move is done in main.js with a FLIP animation instead of
the clip-path, since the card no longer sits on top of the tile. */
@media (max-width: 767px), (hover: none) { @media (max-width: 767px), (hover: none) {
.trainer-detail { .trainer-detail,
.trainer-detail.td-flip {
position: fixed; position: fixed;
top: 50%; top: var(--td-top, 20vh);
left: 16px; left: 16px;
right: 16px; right: 16px;
width: auto; width: auto;
max-width: 560px; max-width: 560px;
min-height: 0;
margin: 0 auto; margin: 0 auto;
transform: translateY(-46%);
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
flex-direction: row;
clip-path: none;
transform: none;
transform-origin: top left;
transition: opacity .15s ease, visibility 0s linear .15s;
}
.trainer-item.is-open .trainer-detail {
clip-path: none;
transform: none;
transition: opacity 0s, visibility 0s;
} }
.trainer-item.is-open .trainer-detail { transform: translateY(-50%); }
.trainer-item.is-open:after { .trainer-item.is-open:after {
content: ''; content: '';
position: fixed; position: fixed;
@@ -3170,17 +3218,24 @@ section[id] {
background: rgba(0, 0, 0, .55); background: rgba(0, 0, 0, .55);
} }
.td-close { display: block; } .td-close { display: block; }
.td-photo { flex-basis: 34%; } .td-photo { flex-basis: 34%; border-width: 3px; }
.td-photo img { min-height: 180px; } .td-photo img { min-height: 180px; }
.td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; } .td-body,
.td-flip .td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; transform: none; }
} }
@media (max-width: 339px) { @media (max-width: 339px) {
.trainer-detail { flex-direction: column; } .trainer-detail,
.trainer-detail.td-flip { flex-direction: column; }
.td-photo { max-width: none; flex-basis: auto; } .td-photo { max-width: none; flex-basis: auto; }
.td-photo img { min-height: 0; height: 160px; } .td-photo img { min-height: 0; height: 160px; }
} }
@media (prefers-reduced-motion: reduce) {
.trainer-detail,
.trainer-item.is-open .trainer-detail { transition-duration: .01ms !important; }
}
/* ══════════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════════
Mensalidades — cartões de preço Mensalidades — cartões de preço
══════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════ */
+134 -35
View File
@@ -224,36 +224,105 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
/* ── Equipa: horizontal detail card ───────────────────────────────────────── /* ── Equipa: detail card that grows out of the tile ─────────────────────────
Desktop (fine pointer) opens it on hover purely in CSS; this handles tap / Desktop: hover (or keyboard focus) opens. The card is laid exactly over the
click / keyboard (.is-open, one at a time; Esc, the × and outside clicks tile photo, tilted like it, and the CSS unclips/straightens it sideways —
close) and nudges the absolutely-positioned card sideways (--td-shift) so an to the right, or to the left (.td-flip) when there is no room. While open,
edge column's card stays inside the viewport. On small / touch screens the the mouse drives a small 3D tilt + drift so it handles like a real card.
CSS turns it into a centred fixed card, so no shift is applied there. */ Touch / narrow screens: tap opens a centred fixed card; a FLIP animation
makes it fly out of (and back into) the tile photo. Esc, ×, outside tap close. */
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var items = document.querySelectorAll('.trainer-item.has-detail'); var items = document.querySelectorAll('.trainer-item.has-detail');
if (!items.length) return; if (!items.length) return;
var floating = window.matchMedia('(max-width: 767px), (hover: none)'); var floating = window.matchMedia('(max-width: 767px), (hover: none)');
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
var section = document.getElementById('equipa');
var CARD_W = 560, GUTTER = 16;
function tileRot(item) {
// #powerRotation tilts odd tiles -3deg and even ones +3deg (style.css).
var col = item.parentElement, i = 0;
while (col && (col = col.previousElementSibling)) i++;
return (i % 2 === 1) ? 3 : -3;
}
// Desktop placement: photo column on top of the tile photo.
function place(item) { function place(item) {
var card = item.querySelector('.trainer-detail'); var card = item.querySelector('.trainer-detail');
if (!card) return; var pic = item.querySelector('.trainer-pic');
card.style.setProperty('--td-shift', '0px'); if (!card || !pic || floating.matches) return;
if (floating.matches) return; var vw = document.documentElement.clientWidth;
var r = card.getBoundingClientRect(); var w = Math.min(CARD_W, vw - GUTTER * 2);
var itemR = item.getBoundingClientRect(); var pw = pic.offsetWidth, ph = pic.offsetHeight;
// Where the card would sit centred on the item (ignoring its current transform). var picLeft = item.getBoundingClientRect().left + pic.offsetLeft; // untransformed
var left = itemR.left + itemR.width / 2 - r.width / 2; var flip = picLeft + w > vw - GUTTER; // no room on the right
var right = left + r.width; var x = flip ? pic.offsetLeft + pw - w : pic.offsetLeft;
var gutter = 16, vw = document.documentElement.clientWidth, shift = 0; card.classList.toggle('td-flip', flip);
if (left < gutter) shift = gutter - left; card.style.setProperty('--td-w', w + 'px');
else if (right > vw - gutter) shift = (vw - gutter) - right; card.style.setProperty('--td-pw', pw + 'px');
card.style.setProperty('--td-shift', Math.round(shift) + 'px'); card.style.setProperty('--td-ph', ph + 'px');
card.style.setProperty('--td-x', x + 'px');
card.style.setProperty('--td-y', pic.offsetTop + 'px');
card.style.setProperty('--td-rot', tileRot(item) + 'deg');
// Closed clip = just the photo square, in px (see style.css).
card.style.setProperty('--td-cx', Math.max(0, w - pw) + 'px');
card.style.setProperty('--td-cy', Math.max(0, card.offsetHeight - ph) + 'px');
} }
var section = document.getElementById('equipa');
function resetTilt(card) {
['--td-rx', '--td-ry', '--td-mx', '--td-my'].forEach(function (p) { card.style.removeProperty(p); });
card.classList.remove('td-tracking');
}
// Touch: fly the fixed card out of / back into the tile photo.
function flip(item, opening) {
var card = item.querySelector('.trainer-detail');
var pic = item.querySelector('.trainer-pic');
if (!card || !pic || !card.animate || reduced.matches) return null;
var p = pic.getBoundingClientRect(), c = card.getBoundingClientRect();
if (!c.width || !c.height) return null;
var from = 'translate(' + (p.left - c.left) + 'px,' + (p.top - c.top) + 'px) scale(' +
(p.width / c.width) + ',' + (p.height / c.height) + ') rotate(' + tileRot(item) + 'deg)';
var frames = [{ transform: from, opacity: .4 }, { transform: 'none', opacity: 1 }];
return card.animate(opening ? frames : frames.reverse(), {
duration: opening ? 420 : 280,
easing: opening ? 'cubic-bezier(.2,.9,.25,1)' : 'cubic-bezier(.4,0,1,1)'
});
}
function setOpen(item, open) { function setOpen(item, open) {
item.classList.toggle('is-open', open); var card = item.querySelector('.trainer-detail');
var wasOpen = item.classList.contains('is-open');
if (open === wasOpen) return;
clearTimeout(item._tdTrack);
if (open) {
if (floating.matches) {
item.classList.add('is-open');
card.style.setProperty('--td-top', Math.max(24, (window.innerHeight - card.offsetHeight) / 2) + 'px');
flip(item, true);
} else {
// Jump (no transition) to the closed pose on top of the tile, commit
// it, then open — otherwise the closed state's own transition would
// start moving from the previous pose and the opening would begin
// from that half-way value instead of from the photo.
card.style.transition = 'none';
place(item);
void card.offsetWidth;
card.style.transition = '';
void card.offsetWidth;
item.classList.add('is-open');
item._tdTrack = setTimeout(function () { card.classList.add('td-tracking'); }, 600);
}
} else {
resetTilt(card);
var anim = floating.matches ? flip(item, false) : null;
if (anim) anim.onfinish = function () { item.classList.remove('is-open'); syncSection(); };
else item.classList.remove('is-open');
}
item.setAttribute('aria-expanded', open ? 'true' : 'false'); item.setAttribute('aria-expanded', open ? 'true' : 'false');
syncSection();
}
function syncSection() {
if (section) section.classList.toggle('has-open-detail', !!section.querySelector('.trainer-item.is-open')); if (section) section.classList.toggle('has-open-detail', !!section.querySelector('.trainer-item.is-open'));
} }
function closeAll(except) { function closeAll(except) {
@@ -261,35 +330,65 @@ document.addEventListener('DOMContentLoaded', function () {
} }
items.forEach(function (item) { items.forEach(function (item) {
item.addEventListener('mouseenter', function () { place(item); }); var card = item.querySelector('.trainer-detail');
item.addEventListener('focus', function () { place(item); });
// Desktop hover: short intent delay so sweeping across the grid
// doesn't fire every card.
item.addEventListener('mouseenter', function () {
if (floating.matches) return;
clearTimeout(item._tdHover);
item._tdHover = setTimeout(function () { closeAll(item); setOpen(item, true); }, 90);
});
item.addEventListener('mouseleave', function () {
if (floating.matches) return;
clearTimeout(item._tdHover);
setOpen(item, false);
});
// Tilt + drift toward the pointer while open (desktop only).
item.addEventListener('mousemove', function (e) {
if (floating.matches || reduced.matches || !item.classList.contains('is-open')) return;
var r = card.getBoundingClientRect();
var dx = Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (r.width / 2)));
var dy = Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (r.height / 2)));
card.style.setProperty('--td-ry', (dx * 5).toFixed(2) + 'deg');
card.style.setProperty('--td-rx', (-dy * 5).toFixed(2) + 'deg');
card.style.setProperty('--td-mx', (dx * 6).toFixed(1) + 'px');
card.style.setProperty('--td-my', (dy * 6).toFixed(1) + 'px');
});
item.addEventListener('focus', function () {
if (!floating.matches) { closeAll(item); setOpen(item, true); }
});
item.addEventListener('blur', function (e) {
if (!floating.matches && !item.contains(e.relatedTarget)) setOpen(item, false);
});
// Tap / click (touch, or a desktop click on a closed tile).
item.addEventListener('click', function (e) { item.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
if (e.target.closest('.td-close')) { setOpen(item, false); return; } if (e.target.closest('.td-close')) { setOpen(item, false); return; }
// Clicks inside an open card (e.g. scrolling / selecting text) keep it open. var inCard = !!e.target.closest('.trainer-detail');
if (e.target.closest('.trainer-detail') && item.classList.contains('is-open')) return; if (item.classList.contains('is-open')) {
// Tapping the dimmed backdrop (the item's ::after) closes it. // Tapping the dimmed backdrop (the item's ::after) closes it;
if (floating.matches && item.classList.contains('is-open') && !e.target.closest('.trainer-detail')) { // taps inside the card (scroll, select) keep it open.
setOpen(item, false); return; if (floating.matches && !inCard) setOpen(item, false);
return;
} }
closeAll(item); closeAll(item);
place(item); setOpen(item, true);
setOpen(item, !item.classList.contains('is-open'));
}); });
item.addEventListener('keydown', function (e) { item.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { if (e.key === 'Escape') { setOpen(item, false); item.blur(); }
if (e.target !== item) return; else if ((e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') && e.target === item) {
e.preventDefault(); e.preventDefault();
closeAll(item); place(item);
setOpen(item, !item.classList.contains('is-open')); setOpen(item, !item.classList.contains('is-open'));
} else if (e.key === 'Escape') {
setOpen(item, false);
item.blur();
} }
}); });
}); });
document.addEventListener('click', function () { closeAll(null); }); document.addEventListener('click', function () { closeAll(null); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); });
window.addEventListener('resize', function () { closeAll(null); }, { passive: true });
}); });
/* ── Mensalidades: pre-select the clicked card in the sign-up modal ───────── */ /* ── Mensalidades: pre-select the clicked card in the sign-up modal ───────── */
-1
View File
@@ -140,7 +140,6 @@ if ($team):
<h4><?= htmlspecialchars((string) $d['name']) ?></h4> <h4><?= htmlspecialchars((string) $d['name']) ?></h4>
<?php if ($t_title !== ''): ?><p class="trainer-title"><?= htmlspecialchars($t_title) ?></p><?php endif; ?> <?php if ($t_title !== ''): ?><p class="trainer-title"><?= htmlspecialchars($t_title) ?></p><?php endif; ?>
<?php if ($t_detail): ?> <?php if ($t_detail): ?>
<span class="trainer-more" aria-hidden="true">Saber mais <i class="fas fa-plus"></i></span>
<div class="trainer-detail" id="<?= $t_id ?>"> <div class="trainer-detail" id="<?= $t_id ?>">
<button type="button" class="td-close" aria-label="Fechar">&times;</button> <button type="button" class="td-close" aria-label="Fechar">&times;</button>
<div class="td-photo"><img loading="lazy" src="<?= $t_img ?>" alt=""></div> <div class="td-photo"><img loading="lazy" src="<?= $t_img ?>" alt=""></div>