Mídia que expande na rolagem
Hero com vídeo ou imagem num cartão que cresce até quase a tela inteira conforme a rolagem; o título se parte e sai pelas laterais, e o conteúdo abaixo só aparece quando a mídia termina de abrir.
usa o ROL-02
O código
/* ===== expandir.css ================================================================ */
/* ROL-02 · Mídia que expande na rolagem — reescrita do scroll-expansion-hero do 21st.dev sem React,
Next nem Framer Motion. Um cartão de mídia cresce até quase a tela inteira enquanto a seção é
rolada; o título se parte em dois e sai pelas laterais, o fundo some e o conteúdo abaixo aparece.
O que mudou em relação ao original, e por quê:
- **a rolagem é a da página, não sequestrada.** O original escuta wheel/touchmove no window,
chama preventDefault e força scrollTo(0, 0) até a mídia abrir: teclado, barra de rolagem e
leitor de tela ficam presos no topo, e ele só funciona como primeira seção. Aqui um trilho
alto com palco sticky converte a rolagem nativa em progresso — qualquer meio de rolar serve,
em qualquer posição da página.
- **o curso é o do original.** Lá, 1 px de roda vale 0,0009 de progresso: ~1100 px para abrir.
O trilho padrão é exatamente isso a mais que a tela.
- **um título, não dois.** O original tem dois <h2> para as duas metades; aqui é um <h2> com
duas partes, que o leitor de tela lê como uma frase.
- **cor em token**, não `text-blue-200` cravado.
- **sem iframe do YouTube embutido.** A mídia é o que você puser dentro de __midia (img, video
ou iframe) — o componente não monta URL de terceiro.
`--expande` vai de 0 (cartão pequeno) a 1 (aberto). **O padrão é 1**: sem JS ou com "reduzir
movimento", o trilho some e a mídia aparece aberta com o título por cima — a falha mostra. */
.c-expandir {
--c-expandir-trilho: calc(100vh + 1100px); /* altura total rolável da seção */
--c-expandir-larg-ini: 300px;
--c-expandir-larg-ganho: 1250px;
--c-expandir-alt-ini: 400px;
--c-expandir-alt-ganho: 400px;
--c-expandir-afasta: 150vw; /* quanto cada metade do título anda até sair */
--c-expandir-raio: 16px;
--c-expandir-texto: #ebebeb;
--c-expandir-veu: #000;
--c-expandir-sombra: 0 0 50px rgba(0, 0, 0, .3);
--expande: 1;
position: relative;
color: var(--c-expandir-texto);
}
@media (max-width: 767.98px) {
.c-expandir {
--c-expandir-larg-ganho: 650px;
--c-expandir-alt-ganho: 200px;
--c-expandir-afasta: 180vw;
}
}
.c-expandir__palco {
position: relative;
height: 100vh;
height: 100dvh;
overflow: hidden; /* as metades do título saem da tela; sem isto a página ganha rolagem lateral */
display: grid;
place-items: center;
}
.c-expandir.is-vivo .c-expandir__trilho { height: var(--c-expandir-trilho); }
.c-expandir.is-vivo .c-expandir__palco { position: sticky; top: 0; }
.c-expandir__fundo {
position: absolute;
inset: 0;
opacity: calc(1 - var(--expande));
}
.c-expandir__fundo > img,
.c-expandir__fundo > video { display: block; width: 100%; height: 100%; object-fit: cover; }
.c-expandir__fundo::after { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, .1); }
.c-expandir__midia {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(95vw, calc(var(--c-expandir-larg-ini) + var(--c-expandir-larg-ganho) * var(--expande)));
height: min(85vh, calc(var(--c-expandir-alt-ini) + var(--c-expandir-alt-ganho) * var(--expande)));
border-radius: var(--c-expandir-raio);
box-shadow: var(--c-expandir-sombra);
}
.c-expandir__midia > img,
.c-expandir__midia > video,
.c-expandir__midia > iframe {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
border-radius: inherit;
}
.c-expandir__veu {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--c-expandir-veu);
opacity: calc(.5 - .3 * var(--expande));
pointer-events: none;
}
.c-expandir__legenda {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.c-expandir__data { margin: 0; font-size: 1.5rem; }
.c-expandir__dica { margin: 0; }
.c-expandir__titulo {
position: relative;
z-index: 1;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
font-size: clamp(2.25rem, 1.4rem + 2.6vw, 3.75rem);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.c-expandir__parte { display: block; }
.c-expandir[data-mistura="diferenca"] .c-expandir__titulo { mix-blend-mode: difference; }
.c-expandir.is-vivo .c-expandir__parte--a,
.c-expandir.is-vivo .c-expandir__data { transform: translateX(calc(-1 * var(--c-expandir-afasta) * var(--expande))); }
.c-expandir.is-vivo .c-expandir__parte--b,
.c-expandir.is-vivo .c-expandir__dica { transform: translateX(calc(var(--c-expandir-afasta) * var(--expande))); }
.c-expandir__conteudo { padding: 40px 32px; }
@media (min-width: 768px) { .c-expandir__conteudo { padding: 80px 64px; } }
.c-expandir.is-vivo .c-expandir__conteudo { opacity: 0; }
/* a transição só arma depois do primeiro quadro: armada junto com .is-vivo, o conteúdo
faria um fade de saída no carregamento */
.c-expandir.is-armado .c-expandir__conteudo { transition: opacity .7s ease; }
.c-expandir.is-vivo.is-aberto .c-expandir__conteudo { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.c-expandir { --expande: 1 !important; }
.c-expandir .c-expandir__trilho { height: auto !important; }
.c-expandir .c-expandir__palco { position: relative !important; }
.c-expandir .c-expandir__parte,
.c-expandir .c-expandir__data,
.c-expandir .c-expandir__dica { transform: none !important; }
.c-expandir .c-expandir__conteudo { opacity: 1 !important; transition: none; }
}
/* ===== expandir.js ================================================================= */
/* ROL-02 · Mídia que expande na rolagem. Liga a seção (.is-vivo, que dá altura ao trilho e prende o
palco) e escreve --expande de 0 a 1 conforme o trilho passa pela tela. Marca .is-aberto quando a
mídia termina de abrir e só desmarca abaixo de 0,75 — a mesma folga do original, para o conteúdo
não piscar na fronteira.
Com "reduzir movimento" a seção não liga: o CSS mostra a mídia aberta, e o vídeo em autoplay
é pausado. .is-armado liga a transição do conteúdo só depois do primeiro quadro.
Mede DE NOVO no primeiro quadro, pelo mesmo motivo do ROL-01: com folha de estilo pendente o
WebKit roda o script antes do layout final, e a primeira leitura do trilho sairia errada. */
(function () {
'use strict';
function iniciar() {
var raizes = Array.prototype.slice.call(document.querySelectorAll('.c-expandir'));
if (!raizes.length || !window.matchMedia) { return; }
var reduzir = window.matchMedia('(prefers-reduced-motion: reduce)');
var pedido = false;
function aplicar() {
pedido = false;
var vh = window.innerHeight;
raizes.forEach(function (raiz) {
if (reduzir.matches) {
raiz.classList.remove('is-vivo', 'is-aberto', 'is-armado');
raiz.style.removeProperty('--expande');
Array.prototype.forEach.call(raiz.querySelectorAll('video[autoplay]'), function (v) { v.pause(); });
return;
}
raiz.classList.add('is-vivo');
var trilho = raiz.querySelector('.c-expandir__trilho');
if (!trilho) { return; }
var r = trilho.getBoundingClientRect();
var curso = r.height - vh;
var p = curso > 0 ? Math.min(1, Math.max(0, -r.top / curso)) : 1;
raiz.style.setProperty('--expande', p.toFixed(4));
if (p >= 1) { raiz.classList.add('is-aberto'); } else if (p < 0.75) { raiz.classList.remove('is-aberto'); }
});
}
function agendar() { if (!pedido) { pedido = true; window.requestAnimationFrame(aplicar); } }
aplicar();
window.requestAnimationFrame(function () {
agendar();
raizes.forEach(function (raiz) { if (!reduzir.matches) { raiz.classList.add('is-armado'); } });
});
window.addEventListener('scroll', agendar, { passive: true });
window.addEventListener('resize', agendar);
if (reduzir.addEventListener) { reduzir.addEventListener('change', agendar); }
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- ROL-02 · Mídia que expande na rolagem. Funciona em qualquer posição da página: o trilho prende
o palco enquanto a mídia abre, e depois a página segue. O fundo é decorativo (alt vazio).
data-mistura="diferenca" liga o mix-blend-mode do título (o textBlend do original). -->
<section class="c-expandir">
<div class="c-expandir__trilho">
<div class="c-expandir__palco">
<div class="c-expandir__fundo"><img src="fundo.jpg" alt=""></div>
<div class="c-expandir__midia">
<video src="video.mp4" poster="poster.jpg" autoplay muted loop playsinline preload="auto"
disablepictureinpicture disableremoteplayback aria-hidden="true"></video>
<span class="c-expandir__veu"></span>
<div class="c-expandir__legenda">
<p class="c-expandir__data">Outubro de 2026</p>
<p class="c-expandir__dica">Role para abrir</p>
</div>
</div>
<h2 class="c-expandir__titulo">
<span class="c-expandir__parte c-expandir__parte--a">Estúdio</span>
<span class="c-expandir__parte c-expandir__parte--b">por dentro</span>
</h2>
</div>
</div>
<div class="c-expandir__conteudo">
<p>O que vem depois da mídia aberta.</p>
</div>
</section>
/* ===== scroll-expansion-hero.tsx =================================================== */
"use client";
import {
useEffect,
useRef,
useState,
ReactNode,
TouchEvent,
WheelEvent,
} from "react";
import Image from "next/image";
import { motion } from "framer-motion";
interface ScrollExpandMediaProps {
mediaType?: "video" | "image";
mediaSrc: string;
posterSrc?: string;
bgImageSrc: string;
title?: string;
date?: string;
scrollToExpand?: string;
textBlend?: boolean;
children?: ReactNode;
}
const ScrollExpandMedia = ({
mediaType = "video",
mediaSrc,
posterSrc,
bgImageSrc,
title,
date,
scrollToExpand,
textBlend,
children,
}: ScrollExpandMediaProps) => {
const [scrollProgress, setScrollProgress] = useState<number>(0);
const [showContent, setShowContent] = useState<boolean>(false);
const [mediaFullyExpanded, setMediaFullyExpanded] = useState<boolean>(false);
const [touchStartY, setTouchStartY] = useState<number>(0);
const [isMobileState, setIsMobileState] = useState<boolean>(false);
const sectionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setScrollProgress(0);
setShowContent(false);
setMediaFullyExpanded(false);
}, [mediaType]);
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (mediaFullyExpanded && e.deltaY < 0 && window.scrollY <= 5) {
setMediaFullyExpanded(false);
e.preventDefault();
} else if (!mediaFullyExpanded) {
e.preventDefault();
const scrollDelta = e.deltaY * 0.0009;
const newProgress = Math.min(
Math.max(scrollProgress + scrollDelta, 0),
1,
);
setScrollProgress(newProgress);
if (newProgress >= 1) {
setMediaFullyExpanded(true);
setShowContent(true);
} else if (newProgress < 0.75) {
setShowContent(false);
}
}
};
const handleTouchStart = (e: TouchEvent) => {
setTouchStartY(e.touches[0].clientY);
};
const handleTouchMove = (e: TouchEvent) => {
if (!touchStartY) return;
const touchY = e.touches[0].clientY;
const deltaY = touchStartY - touchY;
if (mediaFullyExpanded && deltaY < -20 && window.scrollY <= 5) {
setMediaFullyExpanded(false);
e.preventDefault();
} else if (!mediaFullyExpanded) {
e.preventDefault();
// Increase sensitivity for mobile, especially when scrolling back
const scrollFactor = deltaY < 0 ? 0.008 : 0.005; // Higher sensitivity for scrolling back
const scrollDelta = deltaY * scrollFactor;
const newProgress = Math.min(
Math.max(scrollProgress + scrollDelta, 0),
1,
);
setScrollProgress(newProgress);
if (newProgress >= 1) {
setMediaFullyExpanded(true);
setShowContent(true);
} else if (newProgress < 0.75) {
setShowContent(false);
}
setTouchStartY(touchY);
}
};
const handleTouchEnd = (): void => {
setTouchStartY(0);
};
const handleScroll = (): void => {
if (!mediaFullyExpanded) {
window.scrollTo(0, 0);
}
};
window.addEventListener("wheel", handleWheel as unknown as EventListener, {
passive: false,
});
window.addEventListener("scroll", handleScroll as EventListener);
window.addEventListener(
"touchstart",
handleTouchStart as unknown as EventListener,
{ passive: false },
);
window.addEventListener(
"touchmove",
handleTouchMove as unknown as EventListener,
{ passive: false },
);
window.addEventListener("touchend", handleTouchEnd as EventListener);
return () => {
window.removeEventListener(
"wheel",
handleWheel as unknown as EventListener,
);
window.removeEventListener("scroll", handleScroll as EventListener);
window.removeEventListener(
"touchstart",
handleTouchStart as unknown as EventListener,
);
window.removeEventListener(
"touchmove",
handleTouchMove as unknown as EventListener,
);
window.removeEventListener("touchend", handleTouchEnd as EventListener);
};
}, [scrollProgress, mediaFullyExpanded, touchStartY]);
useEffect(() => {
const checkIfMobile = (): void => {
setIsMobileState(window.innerWidth < 768);
};
checkIfMobile();
window.addEventListener("resize", checkIfMobile);
return () => window.removeEventListener("resize", checkIfMobile);
}, []);
const mediaWidth = 300 + scrollProgress * (isMobileState ? 650 : 1250);
const mediaHeight = 400 + scrollProgress * (isMobileState ? 200 : 400);
const textTranslateX = scrollProgress * (isMobileState ? 180 : 150);
const firstWord = title ? title.split(" ")[0] : "";
const restOfTitle = title ? title.split(" ").slice(1).join(" ") : "";
return (
<div
ref={sectionRef}
className="transition-colors duration-700 ease-in-out overflow-x-hidden"
>
<section className="relative flex flex-col items-center justify-start min-h-[100dvh]">
<div className="relative w-full flex flex-col items-center min-h-[100dvh]">
<motion.div
className="absolute inset-0 z-0 h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 - scrollProgress }}
transition={{ duration: 0.1 }}
>
<Image
src={bgImageSrc}
alt="Background"
width={1920}
height={1080}
className="w-screen h-screen"
style={{
objectFit: "cover",
objectPosition: "center",
}}
priority
/>
<div className="absolute inset-0 bg-black/10" />
</motion.div>
<div className="container mx-auto flex flex-col items-center justify-start relative z-10">
<div className="flex flex-col items-center justify-center w-full h-[100dvh] relative">
<div
className="absolute z-0 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 transition-none rounded-2xl"
style={{
width: `${mediaWidth}px`,
height: `${mediaHeight}px`,
maxWidth: "95vw",
maxHeight: "85vh",
boxShadow: "0px 0px 50px rgba(0, 0, 0, 0.3)",
}}
>
{mediaType === "video" ? (
mediaSrc.includes("youtube.com") ? (
<div className="relative w-full h-full pointer-events-none">
<iframe
width="100%"
height="100%"
src={
mediaSrc.includes("embed")
? mediaSrc +
(mediaSrc.includes("?") ? "&" : "?") +
"autoplay=1&mute=1&loop=1&controls=0&showinfo=0&rel=0&disablekb=1&modestbranding=1"
: mediaSrc.replace("watch?v=", "embed/") +
"?autoplay=1&mute=1&loop=1&controls=0&showinfo=0&rel=0&disablekb=1&modestbranding=1&playlist=" +
mediaSrc.split("v=")[1]
}
className="w-full h-full rounded-xl"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
<div
className="absolute inset-0 z-10"
style={{ pointerEvents: "none" }}
></div>
<motion.div
className="absolute inset-0 bg-black/30 rounded-xl"
initial={{ opacity: 0.7 }}
animate={{ opacity: 0.5 - scrollProgress * 0.3 }}
transition={{ duration: 0.2 }}
/>
</div>
) : (
<div className="relative w-full h-full pointer-events-none">
<video
src={mediaSrc}
poster={posterSrc}
autoPlay
muted
loop
playsInline
preload="auto"
className="w-full h-full object-cover rounded-xl"
controls={false}
disablePictureInPicture
disableRemotePlayback
/>
<div
className="absolute inset-0 z-10"
style={{ pointerEvents: "none" }}
></div>
<motion.div
className="absolute inset-0 bg-black/30 rounded-xl"
initial={{ opacity: 0.7 }}
animate={{ opacity: 0.5 - scrollProgress * 0.3 }}
transition={{ duration: 0.2 }}
/>
</div>
)
) : (
<div className="relative w-full h-full">
<Image
src={mediaSrc}
alt={title || "Media content"}
width={1280}
height={720}
className="w-full h-full object-cover rounded-xl"
/>
<motion.div
className="absolute inset-0 bg-black/50 rounded-xl"
initial={{ opacity: 0.7 }}
animate={{ opacity: 0.7 - scrollProgress * 0.3 }}
transition={{ duration: 0.2 }}
/>
</div>
)}
<div className="flex flex-col items-center text-center relative z-10 mt-4 transition-none">
{date && (
<p
className="text-2xl text-blue-200"
style={{ transform: `translateX(-${textTranslateX}vw)` }}
>
{date}
</p>
)}
{scrollToExpand && (
<p
className="text-blue-200 font-medium text-center"
style={{ transform: `translateX(${textTranslateX}vw)` }}
>
{scrollToExpand}
</p>
)}
</div>
</div>
<div
className={`flex items-center justify-center text-center gap-4 w-full relative z-10 transition-none flex-col ${
textBlend ? "mix-blend-difference" : "mix-blend-normal"
}`}
>
<motion.h2
className="text-4xl md:text-5xl lg:text-6xl font-bold text-blue-200 transition-none"
style={{ transform: `translateX(-${textTranslateX}vw)` }}
>
{firstWord}
</motion.h2>
<motion.h2
className="text-4xl md:text-5xl lg:text-6xl font-bold text-center text-blue-200 transition-none"
style={{ transform: `translateX(${textTranslateX}vw)` }}
>
{restOfTitle}
</motion.h2>
</div>
</div>
<motion.section
className="flex flex-col w-full px-8 py-10 md:px-16 lg:py-20"
initial={{ opacity: 0 }}
animate={{ opacity: showContent ? 1 : 0 }}
transition={{ duration: 0.7 }}
>
{children}
</motion.section>
</div>
</div>
</section>
</div>
);
};
export default ScrollExpandMedia;
Já rodou em
Ainda não rodou em peça publicada.
Exige
- react (useEffect, useRef, useState)
- next/image — não roda fora do Next
- framer-motion (motion.div, motion.h2, motion.section)
- Tailwind
Como se configura
--c-expandir-trilho--c-expandir-larg-ini--c-expandir-larg-ganho--c-expandir-alt-ini--c-expandir-alt-ganho--c-expandir-afasta--c-expandir-raio--c-expandir-texto--c-expandir-veu--c-expandir-sombra
A ficha
O que o 21st.dev entregou
Um arquivo só, ScrollExpandMedia, sem demo. Nome do arquivo e autor não vieram com o código: o nome scroll-expansion-hero.tsx é o desta ficha, não o do 21st.
Dependências _(observado nos import)_: React (useEffect, useRef, useState), next/image (só roda dentro do Next), framer-motion (motion.div, motion.h2, motion.section) e Tailwind.
Props _(observado)_: mediaType ("video" | "image", padrão vídeo), mediaSrc, posterSrc, bgImageSrc, title, date, scrollToExpand (o texto da dica), textBlend (liga mix-blend-difference no título) e children (o conteúdo que aparece depois).
Como funciona _(observado no código)_:
- O progresso (0–1) não vem da rolagem da página: vem da roda e do toque, capturados no
windowcompreventDefault. A roda somadeltaY × 0,0009(≈ 1100 px de roda para abrir). No toque,0,005para descer e0,008para voltar, mais sensível na volta, de propósito. - Enquanto a mídia não abre, um ouvinte de
scrollforçawindow.scrollTo(0, 0): a página fica presa no topo. Aberta, ela solta, e a rolagem para cima no topo (scrollY ≤ 5) a fecha de novo. - Cartão de mídia: de 300 × 400 px a 1550 × 800 no desktop e 950 × 600 abaixo de 768 px, limitado a 95vw × 85vh. Raio
rounded-2xl(16 px) por fora,rounded-xl(12 px) na mídia, sombra0 0 50px rgba(0,0,0,.3). - Fundo (
bgImageSrc) em tela cheia, com véu preto a 10 %, some comopacity: 1 − progresso. - Véu na mídia: preto a 30 % multiplicado por
0,5 − 0,3·pno vídeo e0,7 − 0,3·pna imagem. - Título: a primeira palavra num
<h2>e o resto noutro, que andamp × 150vw(180vw no celular) em sentidos opostos até sair da tela. A data e a dica, abaixo do cartão, fazem o mesmo. - Conteúdo (
children) entra em fade de 0,7 s quandopchega a 1 e sai abaixo de 0,75. - Vídeo:
autoPlay muted loop playsInline, sem controles,pointer-events: none. Se a URL tiveryoutube.com, monta um<iframe>de embed concatenando parâmetros na URL. - Cor do texto
text-blue-200cravada. Título 36/48/60 px, peso 700.
Visual em repouso _(inferido do código, não visto rodando no original)_: um cartão retrato pequeno no centro da tela, sobre a imagem de fundo, com o título em duas linhas por cima.
O que a adaptação da OCTA mudou
- A rolagem é a da página. Um trilho de
100vh + 1100px(o mesmo curso do original) com o palcostickydentro: o JS só lê a posição do trilho e escreve--expande. Setas, espaço, PageDown, barra de rolagem e leitor de tela movem o efeito, e a seção funciona em qualquer posição da página, não só como primeira dobra. prefers-reduced-motion: sem trilho, mídia aberta direto, e o vídeo em autoplay é pausado.- A falha mostra: sem JS, o
--expandepadrão é 1 e o conteúdo fica visível. - Um
<h2>com duas partes (__parte--a,__parte--b) no lugar de dois. - Cor e geometria em token (
--c-expandir-*). OtextBlendviroudata-mistura="diferenca". - Sem montagem de URL do YouTube.
__midiaaceitaimg,videoouiframeque você puser. - O fundo tem
alt="", porque é decorativo; o vídeo de exemplo levaaria-hidden. - Custo que ficou: largura e altura do cartão mudam a cada quadro (é layout, não transform), como no original. Com um elemento só, não pesou no teste; não foi medido em aparelho fraco.
Lacunas do original
- Sequestra a rolagem do
windowinteiro. Teclado e barra de rolagem não abrem a mídia, e a página fica presa no topo até a roda ou o dedo abrirem. Falha de acessibilidade grave, e o componente só funciona como primeira seção. - Não respeita
prefers-reduced-motion. - Dois
<h2>para um título só.altfixo em inglês ("Background","Media content"), e o fundo decorativo é anunciado. - URL do YouTube montada por concatenação:
split("v=")[1]leva junto qualquer parâmetro que venha depois do id, e oplaylist=sai errado. - O
useEffectdos ouvintes depende descrollProgress: os cinco ouvintes são removidos e recriados a cada passo da animação. text-blue-200cravado; nenhuma cor em token.- Sem demo: o 21st entregou só o componente.
Acessibilidade não apurada a fundo; o que está acima foi lido no código, não medido.
⚠️ Lista incompleta: este componente não passou por apuração de acessibilidade. O que está acima é o que se observou de passagem, não um laudo.