feat: refine portfolio showcase, services and quality lab
CI / verify (push) Failing after 5m9s

Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
This commit is contained in:
Evandro Fonseca Junior committed 2026-09-05 14:20:54 -03:00
1 parent e2b20bc699
commit 8b12e51bc5
35 files changed
+2359 -1299

No files matched your search

+8 -4
View File
@@ -66,17 +66,19 @@ Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
| `/cases/sc-plus` | Case SC Plus Aquicultura. | | `/cases/sc-plus` | Case SC Plus Aquicultura. |
| `/cases/gcc-installer` | Case MinGW GCC Installer. | | `/cases/gcc-installer` | Case MinGW GCC Installer. |
| `/cases/as-servicos` | Case AS Serviços. | | `/cases/as-servicos` | Case AS Serviços. |
| `/servicos` | Índice dos seis serviços. | | `/servicos` | Índice dos seis serviços, com explicações de uso e acesso ao detalhamento técnico. |
| `/servicos/criacao-de-sites` | Criação de sites. | | `/servicos/criacao-de-sites` | Criação de sites. |
| `/servicos/engenharia-de-software` | Engenharia de software. | | `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
| `/servicos/consultoria-nextjs` | Consultoria Next.js. | | `/servicos/consultoria-nextjs` | Consultoria Next.js. |
| `/servicos/tauri-rust` | Desenvolvimento Tauri e Rust. | | `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. | | `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
| `/servicos/arquitetura-de-software` | Arquitetura de software. | | `/servicos/arquitetura-de-software` | Arquitetura de software. |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. | | `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa. O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
As páginas de serviços mantêm escopo, exemplos ilustrativos e etapas visíveis; tecnologias e decisões de implementação ficam em um `<details>` nativo. Sobre e Contato apontam para `/#about` e `/#contact`, com ajuste de chegada após a definição do layout do notebook.
## Comportamento da aplicação ## Comportamento da aplicação
### Conteúdo e project health ### Conteúdo e project health
@@ -87,7 +89,9 @@ Em produção, a renderização só lê o snapshot versionado e não consulta de
### 3D progressivo e movimento reduzido ### 3D progressivo e movimento reduzido
O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No notebook 3D, o loop de renderização só permanece ativo enquanto a seção cruza o viewport e a página está visível. O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No desktop, tampa, tela, abas e cursor seguem o mesmo progresso de scroll; a cena renderiza sob demanda e pausa fora do viewport ou com a página oculta.
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
### Contato ### Contato
+49 -21
View File
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react"; import { type ReactNode, useEffect } from "react";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
import { Header } from "./Header"; import { Header } from "./Header";
jest.mock("motion/react", () => { jest.mock("motion/react", () => {
@@ -26,6 +27,21 @@ jest.mock("motion/react", () => {
return { motion, useReducedMotion: () => false }; return { motion, useReducedMotion: () => false };
}); });
function HomeWithHeader() {
const onLayoutReady = useHomeFragmentNavigation();
useEffect(() => {
onLayoutReady();
}, [onLayoutReady]);
return (
<>
<Header />
<section id="about" aria-label="Sobre">
<h2>Sobre</h2>
</section>
</>
);
}
describe("Header", () => { describe("Header", () => {
it("exposes the primary navigation and the existing CV asset", () => { it("exposes the primary navigation and the existing CV asset", () => {
render(<Header />); render(<Header />);
@@ -50,9 +66,14 @@ describe("Header", () => {
const cvLink = screen.getByRole("link", { name: "Download CV" }); const cvLink = screen.getByRole("link", { name: "Download CV" });
expect(cvLink).toHaveAttribute("href", "/Profile.pdf"); expect(cvLink).toHaveAttribute("href", "/Profile.pdf");
expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf"); expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
expect( expect(screen.getByRole("link", { name: "Sobre" })).toHaveAttribute(
screen.getByRole("navigation", { name: "Navegação principal" }), "href",
).toHaveClass("site-header-nav"); "/#about",
);
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
"href",
"/#contact",
);
}); });
it("announces and controls the mobile menu", () => { it("announces and controls the mobile menu", () => {
@@ -61,17 +82,12 @@ describe("Header", () => {
const menuButton = screen.getByRole("button", { name: "Abrir menu" }); const menuButton = screen.getByRole("button", { name: "Abrir menu" });
expect(menuButton).not.toHaveAttribute("aria-controls"); expect(menuButton).not.toHaveAttribute("aria-controls");
expect(menuButton).toHaveAttribute("aria-expanded", "false"); expect(menuButton).toHaveAttribute("aria-expanded", "false");
expect(menuButton).toHaveClass("site-header-menu-toggle");
expect( expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }), screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click(menuButton); fireEvent.click(menuButton);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-controls",
"site-navigation-menu",
);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute( expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded", "aria-expanded",
"true", "true",
@@ -79,29 +95,41 @@ describe("Header", () => {
const mobileNavigation = screen.getByRole("navigation", { const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile", name: "Navegação mobile",
}); });
expect(mobileNavigation).toHaveAttribute("id", "site-navigation-menu"); expect(
expect(mobileNavigation).toHaveAttribute("aria-hidden", "false"); document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
const closeButton = screen.getByRole("button", { name: "Fechar menu" }); const mobileLink = within(mobileNavigation).getByRole("link", {
fireEvent.keyDown(closeButton, { key: "Escape" }); name: "Sobre",
});
mobileLink.focus();
fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" }); const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false"); expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton); expect(document.activeElement).toBe(openButton);
}); });
it("returns focus after closing the menu from a mobile link", () => { it("closes the mobile menu without taking focus away from a home destination", () => {
render(<Header />); window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" })); fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", { const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile", name: "Navegação mobile",
}); });
fireEvent.click( const mobileLink = within(mobileNavigation).getByRole("link", {
within(mobileNavigation).getByRole("link", { name: "Home" }), name: "Sobre",
); });
mobileLink.focus();
fireEvent.click(mobileLink);
const openButton = screen.getByRole("button", { name: "Abrir menu" }); expect(screen.getByRole("button", { name: "Abrir menu" })).toHaveAttribute(
expect(openButton).toHaveAttribute("aria-expanded", "false"); "aria-expanded",
expect(document.activeElement).toBe(openButton); "false",
);
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
}); });
}); });
+6 -5
View File
@@ -15,16 +15,14 @@ export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null);
const closeMobileMenu = () => { const closeMobileMenu = () => setMobileMenuOpen(false);
setMobileMenuOpen(false);
menuButtonRef.current?.focus();
};
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => { const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
event.preventDefault(); event.preventDefault();
closeMobileMenu(); closeMobileMenu();
menuButtonRef.current?.focus();
}; };
return ( return (
@@ -119,7 +117,10 @@ export function Header() {
href={CV_URL} href={CV_URL}
download={CV_DOWNLOAD_NAME} download={CV_DOWNLOAD_NAME}
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold" className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
onClick={closeMobileMenu} onClick={() => {
closeMobileMenu();
menuButtonRef.current?.focus();
}}
strength={0.15} strength={0.15}
> >
Download CV Download CV
+47 -154
View File
@@ -1,166 +1,59 @@
import { render, screen } from "@testing-library/react"; import { act } from "@testing-library/react";
import type { ReactNode } from "react"; import { useReducedMotion } from "motion/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import Hero from "./Hero"; import Hero from "./Hero";
jest.mock("next/dynamic", () => () => { jest.mock("motion/react", () => ({
const DynamicComponent = () => <div data-testid="hero-scene" />; ...jest.requireActual("motion/react"),
DynamicComponent.displayName = "MockHeroScene"; useReducedMotion: jest.fn(),
return DynamicComponent; }));
}); jest.mock("next/dynamic", () => () => () => null);
jest.mock("./cinematic/ControlRoomGlobe", () => ({
jest.mock("motion/react", () => { ControlRoomGlobe: () => null,
const React = require("react"); }));
const MotionStateContext = React.createContext({ jest.mock("./cinematic/HeroStarfield", () => ({
initial: undefined, HeroStarfield: () => null,
animate: undefined,
});
const resolveOpacity = (
value: unknown,
variants: Record<string, { opacity?: number }> | undefined,
) => {
const state: unknown =
typeof value === "string" ? variants?.[value] : value;
if (
state &&
typeof state === "object" &&
"opacity" in state &&
typeof state.opacity === "number"
) {
return state.opacity;
}
return undefined;
};
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
initial,
animate,
variants,
transition: _transition,
whileHover: _whileHover,
whileTap: _whileTap,
style,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: unknown;
variants?: Record<string, { opacity?: number }>;
style?: Record<string, unknown>;
} & Record<string, unknown>) => {
const inherited = React.useContext(MotionStateContext);
const effectiveInitial =
initial === undefined ? inherited.initial : initial;
const effectiveAnimate =
animate === undefined ? inherited.animate : animate;
const opacity = resolveOpacity(
effectiveInitial === false ? effectiveAnimate : effectiveInitial,
variants,
);
const element = React.createElement(
tag,
{
...props,
style: opacity === undefined ? style : { ...style, opacity },
},
children,
);
return React.createElement(
MotionStateContext.Provider,
{
value: {
initial: effectiveInitial,
animate: effectiveAnimate,
},
},
element,
);
};
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return {
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion,
useInView: () => true,
useReducedMotion: () => true,
useMotionValue: (initial: number) => ({
get: () => initial,
set: jest.fn(),
}),
useSpring: (
value: { get: () => number; set: (v: number) => void },
_config: unknown,
) => value,
useTransform: (
value: { get: () => number; set: (v: number) => void },
_input: number[],
_output: number[],
) => value,
};
});
jest.mock("cobe", () => ({
__esModule: true,
default: jest.fn(() => ({
destroy: jest.fn(),
})),
})); }));
function mockMatchMedia(matches: boolean) { afterEach(() => {
Object.defineProperty(window, "matchMedia", { jest.restoreAllMocks();
writable: true, });
value: jest.fn().mockImplementation((query) => ({
matches, it.each([false, true])(
media: query, "hydrates without attribute mismatches when reduced motion resolves to %s",
async (reducedMotion) => {
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
media,
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
onchange: null, onchange: null,
addListener: jest.fn(), addListener: jest.fn(),
removeListener: jest.fn(), removeListener: jest.fn(),
addEventListener: jest.fn(), addEventListener: jest.fn(),
removeEventListener: jest.fn(), removeEventListener: jest.fn(),
dispatchEvent: jest.fn(), dispatchEvent: jest.fn(),
})), }));
}); const preference = jest.mocked(useReducedMotion);
} preference.mockReturnValue(null);
const container = document.createElement("div");
container.innerHTML = renderToString(<Hero />);
document.body.append(container);
describe("Hero reduced motion", () => { preference.mockReturnValue(reducedMotion);
beforeEach(() => { const consoleErrors = jest.spyOn(console, "error").mockImplementation();
mockMatchMedia(true); const recoverableErrors: unknown[] = [];
}); let root: Root | undefined;
try {
afterEach(() => { await act(async () => {
jest.restoreAllMocks(); root = hydrateRoot(container, <Hero />, {
}); onRecoverableError: (error) => recoverableErrors.push(error),
});
it("shows active copy and actions immediately without animation timers", () => { });
render(<Hero />); expect(recoverableErrors).toEqual([]);
expect(consoleErrors.mock.calls).toEqual([]);
const visibleContent = [ } finally {
screen.getByText(/Disponível para novas oportunidades/i), await act(async () => root?.unmount());
screen.getByRole("heading", { container.remove();
level: 1,
name: /engenharia de software para sistemas/i,
}),
screen.getByRole("button", {
name: /conversar sobre uma oportunidade/i,
}),
screen.getByRole("link", { name: /ver casos selecionados/i }),
screen.getByRole("link", { name: /download cv/i }),
];
for (const element of visibleContent) {
expect(element).toBeVisible();
} }
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(); },
}); );
});
+3 -2
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { motion, useReducedMotion, type Variants } from "motion/react"; import { motion, type Variants } from "motion/react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene"; import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { CV_URL } from "@/lib/site-navigation"; import { CV_URL } from "@/lib/site-navigation";
import { buildWhatsAppUrl } from "@/lib/whatsapp"; import { buildWhatsAppUrl } from "@/lib/whatsapp";
@@ -89,7 +90,7 @@ function getGreeting(message: string) {
export default function Hero() { export default function Hero() {
const heroRef = useRef<HTMLElement>(null); const heroRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion() ?? true; const shouldReduceMotion = useReducedMotion();
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false); const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [isHeroVisible, setIsHeroVisible] = useState(true); const [isHeroVisible, setIsHeroVisible] = useState(true);
+29
View File
@@ -0,0 +1,29 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LighthouseMetrics from "./LighthouseMetrics";
it("updates the inspection criteria while preserving keyboard navigation", async () => {
const user = userEvent.setup();
render(<LighthouseMetrics />);
const report = screen.getByRole("region", { name: "Inspeção de qualidade" });
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
const accessibility = screen.getByRole("radio", { name: /Acessibilidade/ });
await user.click(accessibility);
expect(accessibility).toHaveFocus();
expect(within(report).getByRole("list")).toHaveTextContent(/teclado/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Boas práticas/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/consentimento/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /SEO/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/Sitemap/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
});
+312 -84
View File
@@ -1,115 +1,343 @@
"use client"; "use client";
import { motion } from "motion/react"; import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { Section } from "./Section";
const METRICS = [ const METRICS = [
{ {
score: 100, id: "performance",
label: "Performance", label: "Performance",
code: "LCP / INP / CLS",
title: "O essencial chega primeiro.",
description: description:
"Cada milissegundo conta. Tempos de carregamento instantâneos retêm mais usuários, aumentam a conversão e garantem que a interface pareça nativa.", "O carregamento começa pelo que importa. Imagens, fontes e JavaScript precisam trabalhar a favor da experiência, não competir com ela.",
checks: [
"Conteúdo principal com prioridade de carregamento",
"Dimensões reservadas para evitar saltos de layout",
"JavaScript carregado onde faz diferença",
],
caption: "Em foco: o conteúdo principal e a estabilidade da página.",
}, },
{ {
score: 100, id: "accessibility",
label: "Acessibilidade", label: "Acessibilidade",
code: "TECLADO / FOCO / SEMÂNTICA",
title: "A interface inclui mais pessoas.",
description: description:
"Acesso universal. Contraste perfeito, navegação por teclado e semântica adequada garantindo o uso por todos os perfis e mitigando riscos.", "Uma boa interface não exige um jeito único de navegar. Teclado, leitores de tela e preferências de movimento fazem parte do projeto.",
checks: [
"Foco visível e navegação por teclado",
"Controles com nomes e estrutura semântica",
"Contraste legível e movimento reduzido",
],
caption:
"Em foco: controles reconhecíveis e um caminho claro pelo teclado.",
}, },
{ {
score: 100, id: "practices",
label: "Best Practices", label: "Boas práticas",
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
title: "Confiança também se constrói.",
description: description:
"Código seguro e moderno. Uso das APIs mais recentes, prevenção inteligente contra vulnerabilidades comuns e front-end limpo.", "Cuidar da entrega também é cuidar dos dados, das dependências e dos limites do navegador. Segurança não se resume a uma nota.",
checks: [
"Conexões seguras e políticas do navegador",
"Analytics condicionado ao consentimento",
"Dependências verificadas e atualizadas",
],
caption: "Em foco: a conexão e os limites de acesso aos dados.",
}, },
{ {
score: 100, id: "seo",
label: "SEO", label: "SEO",
code: "HTML / METADATA / INDEXAÇÃO",
title: "Conteúdo pronto para ser encontrado.",
description: description:
"Rankeamento orgânico superior. Estrutura impecável para indexação precisa pelos motores de busca e otimização para crawlers sociais.", "Estrutura antes de promessas. Conteúdo claro, títulos coerentes e dados estruturados ajudam pessoas e buscadores a entender cada página.",
checks: [
"Títulos, descrições e URLs canônicas",
"HTML semântico e conteúdo rastreável",
"Sitemap e dados estruturados factuais",
],
caption: "Em foco: a estrutura que dá contexto ao conteúdo.",
}, },
]; ];
const BLUEPRINT = (
<svg
className="quality-blueprint"
viewBox="0 0 640 440"
fill="none"
aria-hidden="true"
focusable="false"
>
<g className="quality-dimensions">
<path d="M64 68V48m512 20V48M64 58h512M44 86H24m20 284H24M34 86v284" />
<path d="m60 54 8 8m504-8 8 8M30 82l8 8m-8 276 8 8" />
<text x="304" y="47">
RESPONSIVE
</text>
<path d="M64 390v18m512-18v18M64 400h180m152 0h180" />
<text x="272" y="405">
WEB / BLUEPRINT
</text>
</g>
<rect
className="quality-browser-frame"
x="64"
y="86"
width="512"
height="284"
rx="8"
/>
<path className="quality-wire" d="M64 123h512" />
<g className="quality-browser-dots">
<circle cx="83" cy="105" r="3" />
<circle cx="95" cy="105" r="3" />
<circle cx="107" cy="105" r="3" />
</g>
<rect
className="quality-wire"
x="142"
y="96"
width="350"
height="18"
rx="3"
/>
<text className="quality-address" x="158" y="109">
evandro.dev.br
</text>
<path className="quality-wire" d="M539 100h15m-15 5h15m-15 5h15" />
<text className="quality-browser-heading" x="96" y="176">
Engenharia
</text>
<text className="quality-browser-heading" x="96" y="203">
que funciona.
</text>
<path className="quality-wire" d="M96 222h170M96 230h130" />
<rect
className="quality-browser-button"
x="96"
y="254"
width="142"
height="32"
rx="2"
/>
<text className="quality-browser-button-label" x="112" y="274">
EXPLORAR PROJETO
</text>
<rect
className="quality-wire"
x="330"
y="144"
width="216"
height="142"
rx="3"
/>
<g className="quality-wire">
<path d="m438 164 58 33v55l-58 33-58-33v-55zM380 197l58 33 58-33m-58 33v55m0-121v66" />
<path d="m409 181 58 33v55m-58 0v-55l58-33" />
<path d="M96 306h450M96 324h113m-113 10h80m84-10h113m-113 10h80m84-10h122m-122 10h80" />
</g>
<g className="quality-layer" data-layer="performance">
<rect
className="quality-highlight"
x="318"
y="133"
width="240"
height="165"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M558 216h43V59h-91" />
<circle cx="558" cy="216" r="4" />
<text x="393" y="63">
LCP / CONTEÚDO
</text>
<path d="M330 299v12m216-12v12m-216-6h216" />
<rect
className="quality-annotation"
x="368"
y="311"
width="154"
height="18"
/>
<text x="376" y="323">
ESPAÇO RESERVADO
</text>
</g>
<g className="quality-layer" data-layer="accessibility">
<rect
className="quality-highlight quality-focus-frame"
x="88"
y="246"
width="158"
height="48"
rx="5"
/>
<path className="quality-trace" pathLength="1" d="M88 270H50v149h20" />
<circle cx="88" cy="270" r="4" />
<text x="79" y="424">
TAB / FOCO VISÍVEL
</text>
<rect x="260" y="259" width="45" height="24" rx="3" />
<text x="270" y="275">
TAB
</text>
</g>
<g className="quality-layer" data-layer="practices">
<rect
className="quality-highlight"
x="134"
y="91"
width="366"
height="28"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M500 105h101v314h-90" />
<circle cx="500" cy="105" r="4" />
<path
className="quality-highlight"
d="m499 220 36 13v32c0 25-36 41-36 41s-36-16-36-41v-32z"
/>
<rect x="487" y="255" width="24" height="20" rx="2" />
<path d="M491 255v-7a8 8 0 0 1 16 0v7m-8 6v7" />
<text x="290" y="424">
HTTPS / CONSENTIMENTO
</text>
</g>
<g className="quality-layer" data-layer="seo">
<rect
className="quality-highlight"
x="85"
y="144"
width="224"
height="95"
rx="3"
/>
<path className="quality-trace" pathLength="1" d="M85 159H48V58h135" />
<circle cx="85" cy="159" r="4" />
<text x="74" y="49">
HTML / SEMÂNTICA
</text>
<text x="99" y="140">
&lt;h1&gt;
</text>
<path d="M331 317h213v29H331z" />
<text x="350" y="337">
&lt;article&gt; / &lt;nav&gt;
</text>
</g>
</svg>
);
export default function LighthouseMetrics() { export default function LighthouseMetrics() {
const shouldReduceMotion = useReducedMotion(); const [active, setActive] = useState(METRICS[0]);
return ( return (
<Section <section
id="lighthouse" id="lighthouse"
eyebrow="Quality & Performance" className="ds-section ds-section-dark quality-lab"
title="Engenharia de ponta a ponta: 100/100 não é vaidade." aria-labelledby="quality-title"
description="Sistemas não podem apenas parecer bons. Eles precisam ser consistentes, rápidos, acessíveis, seguros e fáceis de processar. Entregar métricas máximas significa que a fundação técnica do projeto está preparada para escalar sem fricção." data-inspection={active.id}
dark
disableChildrenAnimation={false}
> >
<div <div className="ds-section-shell">
className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4" <div className="quality-intro">
data-reel="fade" <header className="quality-copy">
> <p className="ds-eyebrow">Quality &amp; Performance</p>
{METRICS.map((metric, i) => ( <h2 id="quality-title" className="ds-section-title">
<motion.article Engenharia de ponta a ponta.
key={metric.label} <span>Qualidade em cada camada.</span>
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left" </h2>
initial={ <p>
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 } Sistemas precisam mais do que uma boa aparência. Explore as
} decisões que tornam uma interface rápida, acessível, confiável e
whileInView={ fácil de encontrar.
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 } </p>
} <a
viewport={{ once: true, amount: 0.2 }} className="ds-link ds-link-accent"
transition={{ duration: 0.6, delay: i * 0.1 }} href="/servicos/consultoria-nextjs"
> >
{/* The Ring Container */} Conversar sobre a qualidade do seu projeto
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-full bg-[rgba(0,0,0,0.3)] shadow-inner"> </a>
<svg </header>
className="absolute inset-0 h-full w-full rotate-[-90deg] drop-shadow-[0_0_12px_rgba(12,206,107,0.35)]"
viewBox="0 0 36 36"
aria-hidden="true"
>
{/* Background Track */}
<path
className="text-[rgba(255,255,255,0.1)]"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
{/* Animated Value Track */}
<motion.path
className="text-[#0cce6b]"
initial={{ strokeDasharray: "0, 100" }}
whileInView={{ strokeDasharray: `${metric.score}, 100` }}
viewport={{ once: true }}
transition={{
duration: 1.5,
ease: "easeOut",
delay: i * 0.15 + 0.2,
}}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
<div className="font-mono text-[24px] font-bold tracking-tight text-[#0cce6b]">
{metric.score}
</div>
</div>
{/* Content Details */} <figure className="quality-visual bg-grid-light">
<div className="flex-1" data-reel-stagger="bottom"> <div className="quality-visual-label">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]"> <span>Laboratório de qualidade</span>
{metric.label} <span>Modelo interativo</span>
</h3>
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
{metric.description}
</p>
</div> </div>
</motion.article> {BLUEPRINT}
))} <figcaption>
{METRICS.map((metric) => (
<span key={metric.id} aria-hidden={active.id !== metric.id}>
{metric.caption}
</span>
))}
</figcaption>
</figure>
</div>
<fieldset className="quality-controls" aria-describedby="quality-hint">
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
{METRICS.map((metric) => (
<label key={metric.id} className="quality-control">
<input
type="radio"
name="quality-pillar"
value={metric.id}
checked={active.id === metric.id}
onChange={() => setActive(metric)}
aria-controls="quality-report"
/>
<span className="quality-control-content">
<span className="quality-control-name">{metric.label}</span>
<span className="quality-control-target">
Meta <strong>100</strong>
<span>/100</span>
</span>
</span>
</label>
))}
</fieldset>
<p id="quality-hint" className="quality-hint">
Selecione um pilar para explorar. No teclado, use as setas.
</p>
<section
id="quality-report"
className="quality-report-stack"
aria-label="Inspeção de qualidade"
aria-live="polite"
aria-atomic="true"
>
{METRICS.map((metric) => (
<div
key={metric.id}
className="quality-report"
aria-hidden={active.id !== metric.id}
>
<div className="quality-report-copy">
<p className="quality-code">{metric.code}</p>
<h3>{metric.title}</h3>
<p>{metric.description}</p>
</div>
<ul>
{metric.checks.map((check) => (
<li key={check}>
<span aria-hidden="true">/</span>
{check}
</li>
))}
</ul>
</div>
))}
</section>
<p className="quality-disclaimer">
Metas de qualidade, não uma auditoria em tempo real. Resultados
Lighthouse variam conforme página, dispositivo e condições do teste.
</p>
</div> </div>
</Section> </section>
); );
} }
+13 -12
View File
@@ -199,22 +199,23 @@ export function WorkModalities() {
return ( return (
<Section <Section
id="modalities" id="modalities"
eyebrow="Working models" eyebrow="Serviços e contratação"
title="Serviços de engenharia de software e consultoria técnica." title="Sites, sistemas e aplicativos para o seu negócio."
description="Formatos de contratação para arquitetura de software, integrações críticas, produtos conectados e evolução de sistemas em produção." description="Desenvolvimento para web, mobile e desktop Windows e Linux. Da primeira ideia à melhoria de um sistema existente, o trabalho começa pelo que você precisa resolver."
disableChildrenAnimation disableChildrenAnimation
> >
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]"> <div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]"> <div className="max-w-[760px]">
<div className="ds-kicker">Páginas de serviço</div> <div className="ds-kicker">O que você pode contratar</div>
<h3 className="ds-card-title !mt-sp-3" data-reel="left"> <h3 className="ds-card-title !mt-sp-3" data-reel="left">
Especialidades organizadas por intenção de busca Uma solução para a sua rotina, não uma lista de tecnologias.
</h3> </h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right"> <p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
Se você chegou buscando uma stack, um tipo de consultoria ou um Apresentar sua empresa na internet, organizar a operação, criar um
contexto técnico específico, aqui está o caminho mais direto para aplicativo ou conectar ferramentas. Veja exemplos, etapas e
a página certa. limites de cada serviço; os detalhes técnicos estão disponíveis
nas páginas para quem quiser aprofundar.
</p> </p>
</div> </div>
<MagneticButton <MagneticButton
@@ -223,7 +224,7 @@ export function WorkModalities() {
className="ds-link ds-link-accent" className="ds-link ds-link-accent"
strength={0.2} strength={0.2}
> >
Ver hub de serviços Conhecer todos os serviços
</MagneticButton> </MagneticButton>
</div> </div>
@@ -235,12 +236,12 @@ export function WorkModalities() {
<Link <Link
key={service.href} key={service.href}
href={service.href} href={service.href}
className="rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white" className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
> >
<div className="text-[12px] font-semibold tracking-[0.02em] text-[var(--ink)]"> <div className="text-base font-semibold leading-snug text-[var(--ink)]">
{service.label} {service.label}
</div> </div>
<div className="mt-sp-2 text-[12px] leading-6 text-[var(--meta)]"> <div className="mt-sp-2 text-sm leading-6 text-[var(--ink-80)]">
{service.description} {service.description}
</div> </div>
</Link> </Link>
+94 -52
View File
@@ -1,61 +1,103 @@
import { render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import CinematicHome from "./CinematicHome"; import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
jest.mock("next/dynamic", () => { function HomeNavigation() {
const labels = [ const onLayoutReady = useHomeFragmentNavigation();
"About", return (
"Skills", <>
"Experience", <button type="button" onClick={onLayoutReady}>
"WorkModalities", Establish notebook layout
"Contact", </button>
"LighthouseMetrics", <a href="/#about">Sobre</a>
"Footer", <a href="/#contact">Contato</a>
"ScrollVelocityText", <section id="about" aria-label="Sobre">
"SectionDivider", <button type="button">Read biography</button>
]; </section>
let index = 0; <section id="contact" aria-label="Contato">
<button type="button">Send message</button>
</section>
</>
);
}
return () => { const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const label = labels[index++] ?? "DynamicSection";
return function DynamicMock() { beforeEach(() => {
return <section data-testid={`home-section-${label}`}>{label}</section>; window.history.replaceState(null, "", "/");
}; HTMLElement.prototype.scrollIntoView = jest.fn();
};
}); });
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />); afterEach(() => {
jest.mock("../Projects", () => () => ( window.history.replaceState(null, "", "/");
<section data-testid="home-section-Projects" /> if (originalScrollIntoView) {
)); HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
jest.mock("../story/NotebookStory", () => () => ( } else {
<section data-testid="home-section-NotebookStory" /> Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
)); }
});
describe("CinematicHome", () => { describe("home fragment navigation", () => {
it("renders each primary section directly in the published order", () => { it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
const { container } = render( window.history.replaceState(null, "", "/#contact");
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />, render(<HomeNavigation />);
); const contact = screen.getByRole("region", { name: "Contato" });
const main = container.querySelector("main"); expect(contact).not.toHaveFocus();
const sectionNames = [
"Hero",
"NotebookStory",
"About",
"Skills",
"Experience",
"Projects",
"LighthouseMetrics",
"WorkModalities",
"Contact",
];
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
const directSections = Array.from(main?.children ?? []).filter((element) =>
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
);
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1); fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect( expect(
directSections.map((section) => section.getAttribute("data-testid")), screen.getByRole("button", { name: "Read biography" }),
).toEqual(sectionIds); ).toHaveFocus();
});
it("abandons a pending arrival when the visitor intentionally scrolls", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(message).toHaveFocus();
});
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
fireEvent.click(link, { ctrlKey: true });
expect(link).toHaveFocus();
});
it("does not resurrect a stale fragment when the URL changes before readiness", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
act(() => {
window.history.replaceState(null, "", "/#contact");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(message).toHaveFocus();
}); });
}); });
+13 -31
View File
@@ -1,43 +1,23 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import type { import type {
PortfolioExperience, PortfolioExperience,
PortfolioProjectView, PortfolioProjectView,
PortfolioSkillCategory, PortfolioSkillCategory,
} from "@/lib/portfolio-data"; } from "@/lib/portfolio-data";
import About from "../About";
import Contact from "../Contact";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero from "../Hero"; import Hero from "../Hero";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects"; import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading"; import Skills from "../Skills";
import NotebookStory from "../story/NotebookStory"; import NotebookStory from "../story/NotebookStory";
import { WorkModalities } from "../WorkModalities";
const About = dynamic(() => import("../About"), { import ScrollVelocityText from "./ScrollVelocityText";
loading: () => <SectionLoading message="Carregando..." />, import SectionDivider from "./SectionDivider";
}); import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
const Skills = dynamic(() => import("../Skills"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Experience = dynamic(() => import("../Experience"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const WorkModalities = dynamic(
() => import("../WorkModalities").then((mod) => mod.WorkModalities),
{ loading: () => <SectionLoading message="Carregando..." /> },
);
const Contact = dynamic(() => import("../Contact"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Footer = dynamic(() => import("../Footer"));
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), {
ssr: false,
});
const SectionDivider = dynamic(() => import("./SectionDivider"), {
ssr: false,
});
export default function CinematicHome({ export default function CinematicHome({
projects, projects,
@@ -48,6 +28,8 @@ export default function CinematicHome({
experiences: PortfolioExperience[]; experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[]; skillCategories: PortfolioSkillCategory[];
}) { }) {
const onLayoutReady = useHomeFragmentNavigation();
return ( return (
<div className="flex min-h-screen flex-col overflow-x-clip"> <div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main"> <main className="ds-main">
@@ -58,7 +40,7 @@ export default function CinematicHome({
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" /> <ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
</div> </div>
<NotebookStory projects={projects} /> <NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<About /> <About />
@@ -17,14 +17,11 @@ export default function ScrollVelocityText({
}: ScrollVelocityTextProps) { }: ScrollVelocityTextProps) {
const trackId = useId(); const trackId = useId();
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState( const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => { useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)"); const media = window.matchMedia("(prefers-reduced-motion: reduce)");
setShouldReduceMotion(media.matches);
const updatePreference = (event: MediaQueryListEvent) => { const updatePreference = (event: MediaQueryListEvent) => {
setShouldReduceMotion(event.matches); setShouldReduceMotion(event.matches);
}; };
@@ -0,0 +1,125 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
function fragmentTarget(hash: string) {
if (!hash) return null;
try {
return document.getElementById(decodeURIComponent(hash.slice(1)));
} catch {
return null;
}
}
function focusFragment(target: HTMLElement) {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
}
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
export function useHomeFragmentNavigation() {
const ready = useRef(false);
const resolveArrival = useRef<(() => void) | null>(null);
useEffect(() => {
const navigation = performance.getEntriesByType?.("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
// A history restoration owns its saved position, not the URL's old fragment.
let pending =
navigation?.type === "back_forward" ? "" : window.location.hash;
const resolve = () => {
if (!ready.current || !pending) return;
const hash = pending;
pending = "";
if (hash !== window.location.hash) return;
const target = fragmentTarget(hash);
if (!target) return;
focusFragment(target);
target.scrollIntoView({ block: "start", behavior: "instant" });
};
const cancelArrival = () => {
pending = "";
};
const onKeyDown = (event: KeyboardEvent) => {
if (
[
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ",
"Tab",
].includes(event.key)
) {
cancelArrival();
}
};
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== window.location.origin ||
url.pathname !== window.location.pathname ||
url.search !== window.location.search
)
return;
const target = fragmentTarget(url.hash);
if (!target) return;
pending = ready.current ? "" : url.hash;
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
focusFragment(target);
};
const onHashChange = () => {
if (pending && pending !== window.location.hash) cancelArrival();
if (!ready.current) return;
const target = fragmentTarget(window.location.hash);
if (target) focusFragment(target);
};
resolveArrival.current = resolve;
resolve();
document.addEventListener("click", onClick, true);
window.addEventListener("hashchange", onHashChange);
window.addEventListener("wheel", cancelArrival, { passive: true });
window.addEventListener("touchmove", cancelArrival, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", cancelArrival, { passive: true });
return () => {
resolveArrival.current = null;
document.removeEventListener("click", onClick, true);
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener("wheel", cancelArrival);
window.removeEventListener("touchmove", cancelArrival);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", cancelArrival);
};
}, []);
return useCallback(() => {
ready.current = true;
resolveArrival.current?.();
}, []);
}
+107 -36
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data"; import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser"; import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
@@ -24,6 +24,15 @@ function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
); );
} }
function imageFor(container: HTMLElement, index: number) {
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
const image = Array.from(container.querySelectorAll("img")).find(
(candidate) => candidate.src === source,
);
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
return image;
}
describe("NotebookBrowser", () => { describe("NotebookBrowser", () => {
it("renders the active project as an accessible tabpanel", () => { it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 }); renderBrowser({ activeIndex: 1 });
@@ -42,12 +51,12 @@ describe("NotebookBrowser", () => {
"Projeto ativo: SC Plus Aquicultura", "Projeto ativo: SC Plus Aquicultura",
); );
}); });
it("preloads the next screenshot and keeps a short outgoing crossfade", () => { it("keeps decoded content visible and ignores a superseded decode", async () => {
const view = renderBrowser(); const view = renderBrowser();
await act(async () => {
expect( fireEvent.load(imageFor(view.container, 0));
view.container.querySelector(".notebook-screenshot-preload"), });
).toBeInTheDocument(); expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender( view.rerender(
<NotebookBrowser <NotebookBrowser
@@ -57,8 +66,91 @@ describe("NotebookBrowser", () => {
onSelectTab={jest.fn()} onSelectTab={jest.fn()}
/>, />,
); );
let finishDecode!: () => void;
imageFor(view.container, 1).decode = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishDecode = resolve;
}),
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={2}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
await act(async () => {
finishDecode();
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
});
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
fireEvent.error(imageFor(view.container, 1));
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
expect( expect(
view.container.querySelector(".notebook-screenshot-outgoing"), screen.getByText(/não foi possível carregar.*SC Plus/i),
).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
});
it("can reverse to an already-loaded outgoing image without another load event", async () => {
const view = renderBrowser();
const first = imageFor(view.container, 0);
Object.defineProperties(first, {
complete: { value: true },
naturalWidth: { value: 1280 },
});
await act(async () => {
fireEvent.load(first);
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
expect(
await screen.findByRole("img", { name: /NutriScan/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("moves between tabs with arrow keys and Home/End", async () => { it("moves between tabs with arrow keys and Home/End", async () => {
@@ -172,35 +264,11 @@ describe("NotebookBrowser", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("keeps the animated cursor decorative and never embeds an iframe", () => { it("uses the selected tab to label the panel and decoded screenshot", async () => {
const { container } = renderBrowser({ const view = renderBrowser({ activeIndex: 2 });
frame: { ...frame, cursorClicking: true }, await act(async () => {
fireEvent.load(imageFor(view.container, 2));
}); });
const cursor = container.querySelector(".notebook-cursor");
expect(cursor).toHaveAttribute("aria-hidden", "true");
expect(cursor).toHaveStyle({ left: "40%", top: "60%" });
expect(cursor?.closest(".notebook-browser")).toBe(
container.querySelector(".notebook-browser"),
);
expect(container.querySelector("iframe")).not.toBeInTheDocument();
});
it("marks only the selected tab as pressed by the choreographed cursor", () => {
const { container } = renderBrowser({
activeIndex: 1,
frame: { ...frame, cursorClicking: true },
});
const tabs = container.querySelectorAll(".notebook-tab");
expect(tabs[1]).toHaveAttribute("data-cursor-clicking", "true");
expect(tabs[0]).toHaveAttribute("data-cursor-clicking", "false");
expect(tabs[2]).toHaveAttribute("data-cursor-clicking", "false");
expect(tabs[3]).toHaveAttribute("data-cursor-clicking", "false");
});
it("uses the selected tab to label the panel and screenshot", () => {
renderBrowser({ activeIndex: 2 });
const tab = screen.getAllByRole("tab")[2]; const tab = screen.getAllByRole("tab")[2];
const panel = screen.getByRole("tabpanel"); const panel = screen.getByRole("tabpanel");
@@ -209,6 +277,9 @@ describe("NotebookBrowser", () => {
expect(tab).toHaveAttribute("aria-controls", panel.id); expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id); expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i); expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
expect(image).toHaveAttribute("src", "/assets/prints/print-sev.png"); expect(image).toHaveProperty(
"src",
new URL(projects[2].screenshotSrc, document.baseURI).href,
);
}); });
}); });
+243 -64
View File
@@ -1,17 +1,31 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useRef, useState } from "react"; import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { NotebookProject } from "./notebookProjects"; import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline"; import type { NotebookFrame } from "./notebookTimeline";
type CursorFrame = Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
export type NotebookBrowserHandle = {
updateFrame: (frame: CursorFrame) => void;
};
export type NotebookBrowserProps = { export type NotebookBrowserProps = {
projects: NotebookProject[]; projects: NotebookProject[];
activeIndex: number; activeIndex: number;
frame: Pick< frame: CursorFrame;
NotebookFrame, ref?: Ref<NotebookBrowserHandle>;
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
onSelectTab: (index: number) => void; onSelectTab: (index: number) => void;
interactive?: boolean; interactive?: boolean;
}; };
@@ -31,28 +45,167 @@ export default function NotebookBrowser({
frame, frame,
onSelectTab, onSelectTab,
interactive = true, interactive = true,
ref,
}: NotebookBrowserProps) { }: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length); const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject = const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined; selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const previousIndexRef = useRef(selectedIndex); const rootRef = useRef<HTMLDivElement>(null);
const [outgoingIndex, setOutgoingIndex] = useState<number | null>(null); const cursorRef = useRef<SVGSVGElement>(null);
const cursorFrame = useRef(frame);
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
const browserSize = useRef({ width: 0, height: 0 });
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const requestedSrc = selectedProject?.screenshotSrc;
const requestRef = useRef(requestedSrc);
const requestVersion = useRef(0);
const displayedRef = useRef(displayed);
const mounted = useRef(true);
useLayoutEffect(() => {
requestRef.current = requestedSrc;
requestVersion.current += 1;
setFailedSrc(null);
}, [requestedSrc]);
useEffect(() => { useEffect(() => {
const previousIndex = previousIndexRef.current; mounted.current = true;
if (previousIndex === selectedIndex) return undefined; return () => {
mounted.current = false;
};
}, []);
previousIndexRef.current = selectedIndex; // Only the immediate neighbours are fetched; the browser cache handles reuse.
setOutgoingIndex(previousIndex >= 0 ? previousIndex : null); useEffect(() => {
const timeout = window.setTimeout(() => setOutgoingIndex(null), 180); const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
return () => window.clearTimeout(timeout); .filter((project): project is NotebookProject => Boolean(project))
}, [selectedIndex]); .map((project) => {
const image = new window.Image();
image.decoding = "async";
image.src = project.screenshotSrc;
return image;
});
return () => {
images.forEach((image) => {
image.removeAttribute("src");
});
};
}, [projects, selectedIndex]);
const outgoingProject = const updateFrame = useCallback((next: CursorFrame) => {
outgoingIndex !== null ? projects[outgoingIndex] : undefined; cursorFrame.current = next;
const nextProject = const cursor = cursorRef.current;
selectedIndex >= 0 && projects.length > 1 const points = tabPoints.current;
? projects[(selectedIndex + 1) % projects.length] if (!cursor || points.length === 0) return;
: undefined; const position = Math.max(
0,
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
);
const left = Math.floor(position);
const right = Math.min(left + 1, points.length - 1);
const blend = position - left;
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
cursor.style.opacity = next.cursorVisible ? "1" : "0";
cursor.classList.toggle("is-clicking", next.cursorClicking);
}, []);
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const measure = () => {
browserSize.current = {
width: root.clientWidth,
height: root.clientHeight,
};
const points = [];
for (const project of projects) {
const tab: HTMLButtonElement | null =
root.querySelector<HTMLButtonElement>(
`#notebook-tab-${toDomId(project.id)}`,
);
if (!tab) continue;
let x = tab.offsetWidth / 2;
let y = tab.offsetHeight / 2;
let element: HTMLElement | null = tab;
while (element && element !== root) {
x += element.offsetLeft;
y += element.offsetTop;
element = element.offsetParent as HTMLElement | null;
}
points.push({ x, y });
}
tabPoints.current = points;
updateFrame(cursorFrame.current);
};
measure();
if (!("ResizeObserver" in window)) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [projects, updateFrame]);
useLayoutEffect(() => {
updateFrame(frame);
}, [frame, updateFrame]);
const revealImage = useCallback(
async (image: HTMLImageElement, project: NotebookProject) => {
const version = requestVersion.current;
try {
if (typeof image.decode === "function") await image.decode();
} catch {
if (
mounted.current &&
version === requestVersion.current &&
requestRef.current === project.screenshotSrc
) {
setFailedSrc(project.screenshotSrc);
}
return;
}
if (
!mounted.current ||
version !== requestVersion.current ||
requestRef.current !== project.screenshotSrc
)
return;
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
setOutgoing(
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? null
: displayedRef.current,
);
displayedRef.current = project;
setDisplayed(project);
setFailedSrc(null);
},
[],
);
useLayoutEffect(() => {
if (!selectedProject) return;
// A quick reversal can select the still-mounted outgoing image: no new load event.
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
.href;
const image = Array.from(
rootRef.current?.querySelectorAll("img") ?? [],
).find((item) => item.src === source);
if (image?.complete && image.naturalWidth > 0) {
void revealImage(image, selectedProject);
}
}, [selectedProject, revealImage]);
const imageProjects = [outgoing, displayed, selectedProject].filter(
(project, index, list): project is NotebookProject =>
Boolean(project) &&
list.findIndex(
(item) => item?.screenshotSrc === project?.screenshotSrc,
) === index,
);
const waiting = displayed?.screenshotSrc !== requestedSrc;
const interactionProps = interactive const interactionProps = interactive
? {} ? {}
: { : {
@@ -81,7 +234,7 @@ export default function NotebookBrowser({
event.preventDefault(); event.preventDefault();
event.currentTarget.parentElement event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus(); [nextIndex]?.focus({ preventScroll: true });
onSelectTab(nextIndex); onSelectTab(nextIndex);
} }
@@ -106,6 +259,7 @@ export default function NotebookBrowser({
{...interactionProps} {...interactionProps}
className="notebook-browser" className="notebook-browser"
data-testid="notebook-browser" data-testid="notebook-browser"
ref={rootRef}
> >
<div className="notebook-browser-bar" aria-hidden="true"> <div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" /> <span className="notebook-browser-dot" />
@@ -142,9 +296,6 @@ export default function NotebookBrowser({
aria-controls={panelId} aria-controls={panelId}
aria-selected={isSelected} aria-selected={isSelected}
className="notebook-tab" className="notebook-tab"
data-cursor-clicking={
isSelected && frame.cursorClicking ? "true" : "false"
}
id={tabId} id={tabId}
key={project.id} key={project.id}
onClick={() => onSelectTab(index)} onClick={() => onSelectTab(index)}
@@ -198,51 +349,79 @@ export default function NotebookBrowser({
)} )}
</div> </div>
<div className="notebook-screenshot-wrap"> <div
{outgoingProject ? ( className="notebook-screenshot-wrap"
<Image aria-busy={waiting && failedSrc !== requestedSrc}
alt="" >
aria-hidden="true" {imageProjects.map((project) => {
className="notebook-screenshot notebook-screenshot-outgoing" const current =
fill project.screenshotSrc === displayed?.screenshotSrc;
sizes="(min-width: 681px) 60vw, 100vw" const previous =
src={outgoingProject.screenshotSrc} project.screenshotSrc === outgoing?.screenshotSrc;
unoptimized return (
/> <Image
) : null} key={project.screenshotSrc}
<Image alt={current ? `Captura de tela de ${project.title}` : ""}
alt={`Captura de tela de ${selectedProject.title}`} aria-hidden={!current}
className="notebook-screenshot notebook-screenshot-current" className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
fill width={project.screenshotWidth}
sizes="(min-width: 681px) 60vw, 100vw" height={project.screenshotHeight}
src={selectedProject.screenshotSrc} loading="eager"
unoptimized sizes="(min-width: 1025px) 800px, 100vw"
/> src={project.screenshotSrc}
{nextProject ? ( unoptimized
<Image onLoad={(event) => {
alt="" void revealImage(event.currentTarget, project);
aria-hidden="true" }}
className="notebook-screenshot-preload" onError={() => {
fill if (project.screenshotSrc === requestRef.current)
sizes="1px" setFailedSrc(project.screenshotSrc);
src={nextProject.screenshotSrc} }}
unoptimized onAnimationEnd={() => {
/> if (current) setOutgoing(null);
}}
/>
);
})}
{waiting ? (
<p className="notebook-screenshot-status" aria-live="polite">
{failedSrc === requestedSrc
? `Não foi possível carregar a captura de ${selectedProject.title}.`
: `Carregando captura de ${selectedProject.title}…`}
{displayed
? ` Exibindo ${displayed.title} enquanto isso.`
: null}
</p>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
{frame.cursorVisible ? ( <svg
<span aria-hidden="true"
aria-hidden="true" className="notebook-cursor"
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`} ref={cursorRef}
data-testid="notebook-cursor" viewBox="0 0 28 36"
style={{ width="28"
left: `${frame.cursorX * 100}%`, height="36"
top: `${frame.cursorY * 100}%`, >
}} <title>Cursor decorativo</title>
<path
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
fill="#0a0a0a"
stroke="#f4f2ed"
strokeWidth="1.8"
strokeLinejoin="round"
/> />
) : null} <circle
className="notebook-cursor-pulse"
cx="2"
cy="2"
r="8"
fill="none"
stroke="#ccff00"
strokeWidth="2"
/>
</svg>
</div> </div>
); );
} }
+6 -1
View File
@@ -7,6 +7,7 @@ import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = { export type NotebookFallbackProps = {
projects: NotebookProject[]; projects: NotebookProject[];
initialIndex?: number; initialIndex?: number;
onSelectTab?: (index: number) => void;
}; };
function clampIndex(index: number, length: number): number { function clampIndex(index: number, length: number): number {
@@ -17,6 +18,7 @@ function clampIndex(index: number, length: number): number {
export default function NotebookFallback({ export default function NotebookFallback({
projects, projects,
initialIndex = 0, initialIndex = 0,
onSelectTab,
}: NotebookFallbackProps) { }: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() => const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length), clampIndex(initialIndex, projects.length),
@@ -39,7 +41,10 @@ export default function NotebookFallback({
cursorX: 0, cursorX: 0,
cursorY: 0, cursorY: 0,
}} }}
onSelectTab={setActiveIndex} onSelectTab={(index) => {
setActiveIndex(index);
onSelectTab?.(index);
}}
projects={projects} projects={projects}
/> />
</div> </div>
+46 -20
View File
@@ -4,6 +4,7 @@ import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, { import NotebookStage3D, {
getNotebookLidRotation, getNotebookLidRotation,
type NotebookStage3DProps, type NotebookStage3DProps,
updateNotebookScreenMatrix,
} from "./NotebookStage3D"; } from "./NotebookStage3D";
import { selectNotebookProjects } from "./notebookProjects"; import { selectNotebookProjects } from "./notebookProjects";
@@ -36,6 +37,7 @@ const props: NotebookStage3DProps = {
isVisible: true, isVisible: true,
onSelectTab: jest.fn(), onSelectTab: jest.fn(),
progress: { current: 0.56 }, progress: { current: 0.56 },
requestRender: { current: null },
projects, projects,
}; };
@@ -58,6 +60,50 @@ describe("getNotebookLidRotation", () => {
}); });
}); });
it("keeps browser corners on the physical LCD after opening and resizing", () => {
const matrix = new THREE.Matrix4();
const rig = new THREE.Group();
rig.position.set(0, -0.45, 0);
const hinge = new THREE.Group();
hinge.position.set(0, 0.16, -1.06);
rig.add(hinge);
const lcd = new THREE.Object3D();
lcd.position.set(0, -0.028, 1.1);
lcd.rotation.x = Math.PI / 2;
hinge.add(lcd);
const domWidth = 800;
const domHeight = (domWidth * 1.86) / 2.8;
for (const size of [
{ width: 939.390625, height: 738, lidAngle: 74 },
{ width: 1123.375, height: 629.75, lidAngle: 112 },
]) {
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
camera.position.set(0, 1.65, 5.7);
camera.rotation.x = -0.19;
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
updateNotebookScreenMatrix(matrix, lcd, camera, size);
for (const [u, v] of [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
]) {
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
.applyMatrix4(lcd.matrixWorld)
.project(camera);
const actual = new THREE.Vector3(
u * domWidth,
v * domHeight,
0,
).applyMatrix4(matrix);
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
}
}
});
describe("NotebookStage3D", () => { describe("NotebookStage3D", () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => undefined); jest.spyOn(console, "error").mockImplementation(() => undefined);
@@ -66,26 +112,6 @@ describe("NotebookStage3D", () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it("renders the procedural laptop and the browser surface", () => {
const { container } = render(<NotebookStage3D {...props} />);
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument();
expect(screen.getByTestId("mock-canvas")).toBeInTheDocument();
expect(screen.getByTestId("notebook-browser")).toBeInTheDocument();
expect(
screen.getAllByTestId("mock-roundedbox").length,
).toBeGreaterThanOrEqual(5);
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("pauses the renderer when the stage is outside the viewport", () => {
render(<NotebookStage3D {...props} isVisible={false} />);
expect(screen.getByTestId("mock-canvas")).toHaveAttribute(
"data-frameloop",
"never",
);
});
it("returns usable browser content when the stage boundary fails", () => { it("returns usable browser content when the stage boundary fails", () => {
const onStageError = jest.fn(); const onStageError = jest.fn();
+132 -62
View File
@@ -1,18 +1,18 @@
"use client"; "use client";
import { Html, RoundedBox } from "@react-three/drei"; import { Html, RoundedBox } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber"; import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { import {
Component, Component,
type ReactNode, type ReactNode,
type RefObject,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from "react"; } from "react";
import * as THREE from "three"; import * as THREE from "three";
import NotebookBrowser from "./NotebookBrowser"; import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
import NotebookFallback from "./NotebookFallback"; import NotebookFallback from "./NotebookFallback";
import type { NotebookProject } from "./notebookProjects"; import type { NotebookProject } from "./notebookProjects";
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline"; import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
@@ -21,6 +21,7 @@ type ProgressRef = { current: number };
export type NotebookStage3DProps = { export type NotebookStage3DProps = {
progress: ProgressRef; progress: ProgressRef;
requestRender: { current: (() => void) | null };
projects: NotebookProject[]; projects: NotebookProject[];
activeIndex: number; activeIndex: number;
onSelectTab: (index: number) => void; onSelectTab: (index: number) => void;
@@ -28,7 +29,7 @@ export type NotebookStage3DProps = {
onStageError?: () => void; onStageError?: () => void;
}; };
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">; type NotebookSceneProps = NotebookStage3DProps;
/** /**
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid * Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
@@ -42,11 +43,10 @@ export function getNotebookLidRotation(lidAngle: number): number {
return radians === 0 ? 0 : -radians; return radians === 0 ? 0 : -radians;
} }
// The laptop is built procedurally in world units and the rig stays at scale // The laptop and its DOM screen share the same world-space geometry.
// 1 so the DOM overlay (drei Html transform) aligns with the physical screen.
const RIG_POSITION: [number, number, number] = [0, -0.45, 0]; const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
const CAMERA_POSITION: [number, number, number] = [0, 2, 7.4]; const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
const CAMERA_ROTATION: [number, number, number] = [-0.2, 0, 0]; const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
const BASE = { width: 3.2, height: 0.16, depth: 2.2 }; const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
const HINGE: [number, number, number] = [0, 0.16, -1.06]; const HINGE: [number, number, number] = [0, 0.16, -1.06];
@@ -63,7 +63,55 @@ const KEYBOARD_Z_START = -0.9;
const KEYBOARD_Y = 0.185; const KEYBOARD_Y = 0.185;
const SPACEBAR_Z = 0.12; const SPACEBAR_Z = 0.12;
const SCREEN_DOM_DISTANCE_FACTOR = 2.2; const SCREEN_DOM_WIDTH = 800;
const SCREEN_DOM_HEIGHT =
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
.makeScale(
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
1,
)
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
const SCREEN_ORIGIN: [number, number] = [0, 0];
const getScreenOrigin = () => SCREEN_ORIGIN;
export function updateNotebookScreenMatrix(
matrix: THREE.Matrix4,
screen: THREE.Object3D,
camera: THREE.Camera,
size: { width: number; height: number },
): THREE.Matrix4 {
screen.updateWorldMatrix(true, false);
camera.updateMatrixWorld();
const halfWidth = size.width / 2;
const halfHeight = size.height / 2;
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
matrix
.set(
halfWidth,
0,
0,
halfWidth,
0,
-halfHeight,
0,
halfHeight,
0,
0,
1,
0,
0,
0,
0,
1,
)
.multiply(camera.projectionMatrix)
.multiply(camera.matrixWorldInverse)
.multiply(screen.matrixWorld)
.multiply(SCREEN_PIXELS_TO_WORLD);
return matrix.multiplyScalar(1 / matrix.elements[15]);
}
type LaptopMaterials = { type LaptopMaterials = {
chassis: THREE.MeshStandardMaterial; chassis: THREE.MeshStandardMaterial;
@@ -111,9 +159,11 @@ function createLaptopMaterials(): LaptopMaterials {
} }
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) { function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
for (const material of Object.values(materials)) { materials.chassis.opacity = opacity;
material.opacity = opacity; materials.keys.opacity = opacity;
} materials.dark.opacity = opacity;
materials.accent.opacity = opacity;
materials.screen.opacity = opacity;
} }
function disposeLaptopMaterials(materials: LaptopMaterials) { function disposeLaptopMaterials(materials: LaptopMaterials) {
@@ -217,48 +267,71 @@ function LaptopLid({ materials }: { materials: LaptopMaterials }) {
radius={0.02} radius={0.02}
smoothness={2} smoothness={2}
/> />
<mesh
material={materials.screen}
position={[0, -0.028, LID.depth / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
</mesh>
</group> </group>
); );
} }
function ScreenSurface({ function ScreenSurface({
frame, frame,
material,
projects, projects,
activeIndex, activeIndex,
onSelectTab, onSelectTab,
}: { }: {
frame: NotebookFrame; frame: RefObject<NotebookFrame>;
material: THREE.MeshStandardMaterial;
projects: NotebookProject[]; projects: NotebookProject[];
activeIndex: number; activeIndex: number;
onSelectTab: (index: number) => void; onSelectTab: (index: number) => void;
}) { }) {
const screen = useRef<THREE.Group>(null);
const surface = useRef<HTMLDivElement>(null);
const browser = useRef<NotebookBrowserHandle>(null);
const projection = useMemo(() => new THREE.Matrix4(), []);
useFrame(({ camera, size }) => {
if (surface.current && screen.current) {
updateNotebookScreenMatrix(projection, screen.current, camera, size);
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
surface.current.style.opacity = String(frame.current.screenOpacity);
surface.current.inert = frame.current.screenOpacity < 0.95;
surface.current.setAttribute(
"aria-hidden",
String(frame.current.screenOpacity < 0.95),
);
}
browser.current?.updateFrame(frame.current);
});
return ( return (
<Html <group
center ref={screen}
distanceFactor={SCREEN_DOM_DISTANCE_FACTOR} position={[0, -0.028, LID.depth / 2]}
position={[0, -0.06, LID.depth / 2]} rotation={[Math.PI / 2, 0, 0]}
zIndexRange={[20, 0]}
> >
<div <mesh material={material}>
className="notebook-screen-surface" <planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
style={{ opacity: frame.screenOpacity }} </mesh>
> <Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
<NotebookBrowser <div
activeIndex={activeIndex} ref={surface}
frame={frame} className="notebook-screen-surface"
interactive={frame.screenOpacity > 0.05} style={{
onSelectTab={onSelectTab} width: SCREEN_DOM_WIDTH,
projects={projects} height: SCREEN_DOM_HEIGHT,
/> opacity: frame.current.screenOpacity,
</div> }}
</Html> inert={frame.current.screenOpacity < 0.95}
aria-hidden={frame.current.screenOpacity < 0.95}
>
<NotebookBrowser
ref={browser}
activeIndex={activeIndex}
frame={frame.current}
onSelectTab={onSelectTab}
projects={projects}
/>
</div>
</Html>
</group>
); );
} }
@@ -267,33 +340,34 @@ function NotebookScene({
onSelectTab, onSelectTab,
progress, progress,
projects, projects,
requestRender,
isVisible,
}: NotebookSceneProps) { }: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null); const lidRig = useRef<THREE.Group>(null);
const smoothProgress = useRef(progress.current); const frame = useRef(getNotebookFrame(progress.current, projects.length));
const elapsedForDom = useRef(0);
const [frame, setFrame] = useState(() =>
getNotebookFrame(progress.current, projects.length),
);
const materials = useMemo(createLaptopMaterials, []); const materials = useMemo(createLaptopMaterials, []);
const invalidate = useThree((state) => state.invalidate);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]); useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
requestRender.current = isVisible ? invalidate : null;
if (isVisible) invalidate();
return () => {
requestRender.current = null;
};
}, [invalidate, isVisible, requestRender]);
useFrame((_, delta) => { useFrame(() => {
smoothProgress.current += const nextFrame = getNotebookFrame(
(progress.current - smoothProgress.current) * Math.min(1, delta * 8); progress.current,
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length); projects.length,
frame.current,
);
if (lidRig.current) { if (lidRig.current) {
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle); lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
} }
setLaptopOpacity(materials, nextFrame.deviceOpacity); setLaptopOpacity(materials, nextFrame.deviceOpacity);
}, -1);
elapsedForDom.current += delta;
if (elapsedForDom.current >= 1 / 24) {
elapsedForDom.current = 0;
setFrame(nextFrame);
}
});
return ( return (
<group position={RIG_POSITION}> <group position={RIG_POSITION}>
@@ -307,6 +381,7 @@ function NotebookScene({
<ScreenSurface <ScreenSurface
activeIndex={activeIndex} activeIndex={activeIndex}
frame={frame} frame={frame}
material={materials.screen}
onSelectTab={onSelectTab} onSelectTab={onSelectTab}
projects={projects} projects={projects}
/> />
@@ -348,19 +423,14 @@ class NotebookStageBoundary extends Component<
}} }}
className="notebook-story-canvas" className="notebook-story-canvas"
dpr={[1, 1.5]} dpr={[1, 1.5]}
frameloop={this.props.isVisible ? "always" : "never"} frameloop={this.props.isVisible ? "demand" : "never"}
gl={{ gl={{
alpha: true, alpha: true,
antialias: true, antialias: true,
powerPreference: "high-performance", powerPreference: "high-performance",
}} }}
> >
<NotebookScene <NotebookScene {...this.props} />
activeIndex={this.props.activeIndex}
onSelectTab={this.props.onSelectTab}
progress={this.props.progress}
projects={this.props.projects}
/>
</Canvas> </Canvas>
); );
} }
+2 -52
View File
@@ -1,10 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data"; import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, { import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress,
} from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => ( const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" /> <div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
@@ -135,15 +131,10 @@ describe("NotebookStory", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
); );
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toHaveAttribute("data-notebook-runway-vh", "520");
}); });
it("stays paused when the tab returns while the section is offscreen", async () => { it("stays paused when the tab returns while the section is offscreen", async () => {
const addEventListener = jest.spyOn(document, "addEventListener"); render(<NotebookStory projects={projects} />);
const removeEventListener = jest.spyOn(document, "removeEventListener");
const { unmount } = render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage"); const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false"); expect(stage).toHaveAttribute("data-is-visible", "false");
@@ -163,18 +154,6 @@ describe("NotebookStory", () => {
setDocumentHidden(false); setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange"))); act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false"); expect(stage).toHaveAttribute("data-is-visible", "false");
expect(
addEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
unmount();
expect(
removeEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
}); });
it("returns to the static fallback when the viewport loses desktop eligibility", async () => { it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
render(<NotebookStory projects={projects} />); render(<NotebookStory projects={projects} />);
@@ -208,12 +187,6 @@ describe("NotebookStory", () => {
); );
}); });
it("scales the runway from the number of projects", () => {
expect(getNotebookRunwayVh(1)).toBe(400);
expect(getNotebookRunwayVh(4)).toBe(520);
expect(getNotebookRunwayVh(99)).toBe(4320);
});
it("requires desktop hover input without reduced motion", () => { it("requires desktop hover input without reduced motion", () => {
const matchMedia = (query: string) => const matchMedia = (query: string) =>
({ ({
@@ -245,27 +218,4 @@ describe("NotebookStory", () => {
screen.queryByRole("tab", { name: /Twins Lab/i }), screen.queryByRole("tab", { name: /Twins Lab/i }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("maps tab seeks inside the browse window without a second scroll engine", () => {
const first = getNotebookSeekProgress(0, 4);
const middle = getNotebookSeekProgress(2, 4);
const last = getNotebookSeekProgress(3, 4);
expect(first).toBeGreaterThanOrEqual(0.44);
expect(first).toBeLessThan(0.78);
expect(middle).toBeGreaterThan(first);
expect(last).toBeLessThan(0.78);
expect(getNotebookSeekProgress(99, 4)).toBeLessThan(0.78);
});
it("does not register a global scroll listener", () => {
const addEventListener = jest.spyOn(window, "addEventListener");
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(
addEventListener.mock.calls.some(([type]) => type === "scroll"),
).toBe(false);
});
}); });
+85 -57
View File
@@ -6,19 +6,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data"; import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback"; import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects"; import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame } from "./notebookTimeline"; import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), { const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false, ssr: false,
}); });
const BROWSE_START = 0.44;
const BROWSE_END = 0.78;
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">; type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = { export type NotebookStoryProps = {
projects: PortfolioProjectView[]; projects: PortfolioProjectView[];
onLayoutReady?: () => void;
}; };
export function canUseNotebook3D(view: CapabilityWindow): boolean { export function canUseNotebook3D(view: CapabilityWindow): boolean {
@@ -36,18 +34,7 @@ export function getNotebookRunwayVh(tabCount: number): number {
1, 1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1, Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
); );
return 360 + count * 40; return 220 + count * 85;
}
export function getNotebookSeekProgress(
index: number,
tabCount: number,
): number {
const count = Math.max(0, Math.floor(tabCount));
if (count < 1) return BROWSE_START;
const safeIndex = Math.min(Math.max(Math.floor(index), 0), count - 1);
const slot = (BROWSE_END - BROWSE_START) / count;
return BROWSE_START + slot * (safeIndex + 0.5);
} }
function clampIndex(index: number, length: number): number { function clampIndex(index: number, length: number): number {
@@ -55,13 +42,22 @@ function clampIndex(index: number, length: number): number {
return Math.min(Math.max(index, 0), length - 1); return Math.min(Math.max(index, 0), length - 1);
} }
export default function NotebookStory({ projects }: NotebookStoryProps) { export default function NotebookStory({
projects,
onLayoutReady,
}: NotebookStoryProps) {
const notebookProjects = useMemo( const notebookProjects = useMemo(
() => selectNotebookProjects(projects), () => selectNotebookProjects(projects),
[projects], [projects],
); );
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
const progressRef = useRef(0); const progressRef = useRef(0);
const requestRenderRef = useRef<(() => void) | null>(null);
const seekRef = useRef<((progress: number) => void) | null>(null);
const layoutReadyRef = useRef(onLayoutReady);
const failedRef = useRef(false);
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
const [capabilityResolved, setCapabilityResolved] = useState(false);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1); const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false); const [engine, setEngine] = useState(false);
const [sectionIntersects, setSectionIntersects] = useState(false); const [sectionIntersects, setSectionIntersects] = useState(false);
@@ -69,12 +65,21 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
() => typeof document === "undefined" || !document.hidden, () => typeof document === "undefined" || !document.hidden,
); );
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current); const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
layoutReadyRef.current = onLayoutReady;
}, [onLayoutReady]);
useEffect(() => {
if (!engine && capabilityResolved) layoutReadyRef.current?.();
}, [engine, capabilityResolved]);
useEffect(() => { useEffect(() => {
const updateEngine = () => { const updateEngine = () => {
const nextEngine = const nextEngine =
notebookProjects.length > 0 && canUseNotebook3D(window); !failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false); if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine); setEngine(nextEngine);
setCapabilityResolved(true);
}; };
updateEngine(); updateEngine();
if (typeof window.matchMedia !== "function") return undefined; if (typeof window.matchMedia !== "function") return undefined;
@@ -108,7 +113,9 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
useEffect(() => { useEffect(() => {
const section = sectionRef.current; const section = sectionRef.current;
if (!engine || !section || !("IntersectionObserver" in window)) { if (!engine || !section) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined; return undefined;
} }
@@ -139,33 +146,58 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
const context = gsap.context(() => { const context = gsap.context(() => {
const driver = { value: 0 }; const driver = { value: 0 };
const applyProgress = () => {
progressRef.current = driver.value;
const nextIndex = getNotebookFrame(
driver.value,
notebookProjects.length,
frameRef.current,
).activeTabIndex;
if (nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
requestRenderRef.current?.();
};
const timeline = gsap.timeline({ const timeline = gsap.timeline({
defaults: { ease: "none" }, defaults: { ease: "none" },
onUpdate: applyProgress,
scrollTrigger: { scrollTrigger: {
trigger: sectionRoot, trigger: sectionRoot,
start: "top top", start: "top top",
end: "bottom bottom", end: "bottom bottom",
scrub: 0.85, scrub: true,
invalidateOnRefresh: true, invalidateOnRefresh: true,
onUpdate: (self) => { onRefresh: () => layoutReadyRef.current?.(),
progressRef.current = self.progress;
const nextIndex = getNotebookFrame(
self.progress,
notebookProjects.length,
).activeTabIndex;
if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
},
}, },
}); });
timeline.to(driver, { duration: 1, value: 1 }); timeline.to(driver, { duration: 1, value: 1 });
seekRef.current = (progress) => {
const trigger = timeline.scrollTrigger;
if (!trigger) return;
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
window.scrollTo({
top: trigger.start + (trigger.end - trigger.start) * progress,
behavior: "instant",
});
ScrollTrigger.update();
timeline.progress(progress);
applyProgress();
};
timeline.scrollTrigger?.refresh();
}, sectionRoot); }, sectionRoot);
cleanup = () => context.revert(); cleanup = () => {
seekRef.current = null;
context.revert();
};
} }
void setupTimeline(); void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => { return () => {
cancelled = true; cancelled = true;
cleanup(); cleanup();
@@ -173,47 +205,38 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}, [engine, notebookProjects.length]); }, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => { const handleStageError = useCallback(() => {
failedRef.current = true;
setEngine(false); setEngine(false);
}, []); }, []);
const seekToTab = useCallback(
(index: number) => {
const section = sectionRef.current;
if (!section || notebookProjects.length < 1) return;
const rect = section.getBoundingClientRect();
const sectionTop = (window.scrollY || window.pageYOffset) + rect.top;
const runwayDistance = Math.max(
section.offsetHeight - window.innerHeight,
0,
);
const target =
sectionTop +
runwayDistance *
getNotebookSeekProgress(index, notebookProjects.length);
window.scrollTo({ top: target, behavior: "smooth" });
},
[notebookProjects.length],
);
const handleSelectTab = useCallback( const handleSelectTab = useCallback(
(index: number) => { (index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length); const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return; if (safeIndex < 0) return;
activeIndexRef.current = safeIndex; activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex); setActiveIndex(safeIndex);
seekToTab(safeIndex); if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
}, },
[notebookProjects.length, seekToTab], [engine, notebookProjects.length],
); );
if (!engine) { if (!engine) {
return ( return (
<section <section
aria-label="Projetos em produção no notebook" aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-static" className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
data-testid="notebook-story" data-testid="notebook-story"
id="story" id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
> >
<div className="notebook-story-static-content"> <div className="notebook-story-static-content">
<div className="notebook-story-copy"> <div className="notebook-story-copy">
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
tecnologias em produção. tecnologias em produção.
</p> </p>
</div> </div>
<NotebookFallback projects={notebookProjects} /> <NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div> </div>
</section> </section>
); );
@@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
onSelectTab={handleSelectTab} onSelectTab={handleSelectTab}
onStageError={handleStageError} onStageError={handleStageError}
progress={progressRef} progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects} projects={notebookProjects}
/> />
</div> </div>
@@ -7,7 +7,7 @@ import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = getFeaturedProjects(null); const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => { describe("selectNotebookProjects", () => {
it("returns the four ordered projects with local screenshots", () => { it("returns featured projects in notebook order", () => {
const result = selectNotebookProjects(projectsFixture); const result = selectNotebookProjects(projectsFixture);
expect(result.map((project) => project.id)).toEqual([ expect(result.map((project) => project.id)).toEqual([
@@ -16,11 +16,6 @@ describe("selectNotebookProjects", () => {
"sev-modaintima", "sev-modaintima",
"as-servicos", "as-servicos",
]); ]);
expect(
result.every((project) =>
project.screenshotSrc.startsWith("/assets/prints/"),
),
).toBe(true);
}); });
it("omits a project when its screenshot mapping is missing", () => { it("omits a project when its screenshot mapping is missing", () => {
+8 -4
View File
@@ -9,6 +9,8 @@ export type NotebookProject = {
demoCheckedAt: PortfolioProjectView["demoCheckedAt"]; demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[]; technologies: string[];
screenshotSrc: string; screenshotSrc: string;
screenshotWidth: number;
screenshotHeight: number;
}; };
const NOTEBOOK_ORDER = [ const NOTEBOOK_ORDER = [
@@ -22,10 +24,10 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
(typeof NOTEBOOK_ORDER)[number], (typeof NOTEBOOK_ORDER)[number],
string string
> = { > = {
nutriscan: "/assets/prints/print-nutriscan.png", nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/prints/print-scplus.png", "sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/prints/print-sev.png", "sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/prints/print-asservicos.png", "as-servicos": "/assets/notebook/as-servicos.webp",
}; };
function isApprovedDemoUrl( function isApprovedDemoUrl(
@@ -58,6 +60,8 @@ export function selectNotebookProjects(
demoCheckedAt: project.demoCheckedAt, demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies], technologies: [...project.technologies],
screenshotSrc, screenshotSrc,
screenshotWidth: 1600,
screenshotHeight: 1000,
}, },
]; ];
}); });
+60 -111
View File
@@ -1,126 +1,75 @@
import { getNotebookFrame } from "./notebookTimeline"; import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
describe("getNotebookFrame", () => { describe("notebook choreography", () => {
it("clamps progress below zero and starts with a closed device", () => { it("opens and boots before browsing, then blanks the display before closing", () => {
expect(getNotebookFrame(-1, 4)).toMatchObject({ const frames = Array.from({ length: 1001 }, (_, index) =>
phase: "approach", getNotebookFrame(index / 1000, 4),
lidAngle: 0, );
activeTabIndex: -1, const phases = [...new Set(frames.map((frame) => frame.phase))];
cursorVisible: false, expect(phases).toEqual([
}); "approach",
"open",
"boot",
"cursor-in",
"browse",
"hold",
"close",
]);
for (const frame of frames) {
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
if (frame.phase === "close" && frame.lidAngle < 112)
expect(frame.screenOpacity).toBe(0);
}
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
}); });
it("clamps progress above one and ends with a closed device", () => { it("keeps the previous project until cursor arrival, including reverse scroll", () => {
expect(getNotebookFrame(2, 4)).toMatchObject({ const frames = Array.from({ length: 10001 }, (_, index) =>
phase: "close", getNotebookFrame(index / 10000, 4),
lidAngle: 0, );
activeTabIndex: -1, for (let selected = 1; selected < 4; selected += 1) {
cursorVisible: false, const arrival = frames.findIndex(
}); (frame) => frame.activeTabIndex === selected,
}); );
const before = getNotebookFrame((arrival - 1) / 10000, 4);
it("keeps the phase boundaries deterministic", () => { const atClick = frames[arrival];
expect(getNotebookFrame(0.1, 4).phase).toBe("open"); expect(before.activeTabIndex).toBe(selected - 1);
expect(getNotebookFrame(0.25, 4).phase).toBe("boot"); expect(before.cursorClicking).toBe(false);
expect(getNotebookFrame(0.34, 4).phase).toBe("cursor-in"); expect(atClick.cursorClicking).toBe(true);
expect(getNotebookFrame(0.44, 4).phase).toBe("browse"); expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
expect(getNotebookFrame(0.78, 4).phase).toBe("hold"); const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
expect(getNotebookFrame(0.87, 4).phase).toBe("close"); expect(reversed.activeTabIndex).toBe(selected - 1);
}); expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
it("opens the lid before the browser starts", () => {
const frame = getNotebookFrame(0.22, 4);
expect(frame.phase).toBe("open");
expect(frame.lidAngle).toBeGreaterThan(0);
expect(frame.screenOpacity).toBe(0);
});
it("boots the screen before the cursor enters", () => {
expect(getNotebookFrame(0.25, 4).screenOpacity).toBe(0);
expect(getNotebookFrame(0.34, 4).screenOpacity).toBe(1);
expect(getNotebookFrame(0.4, 4).cursorVisible).toBe(true);
});
it("turns the screen off before the lid closes", () => {
const screenOff = getNotebookFrame(0.9, 4);
const closing = getNotebookFrame(0.95, 4);
expect(screenOff).toMatchObject({
phase: "close",
lidAngle: 112,
screenOpacity: 0,
});
expect(closing.screenOpacity).toBe(0);
expect(closing.lidAngle).toBeGreaterThan(0);
expect(closing.lidAngle).toBeLessThan(112);
});
it("selects each tab only inside the browse window", () => {
expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1);
expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0);
expect(getNotebookFrame(0.57, 4).activeTabIndex).toBe(1);
expect(getNotebookFrame(0.67, 4).activeTabIndex).toBe(2);
expect(getNotebookFrame(0.76, 4).activeTabIndex).toBe(3);
});
it("holds the final tab until the closing phase", () => {
expect(getNotebookFrame(0.84, 4)).toMatchObject({
phase: "hold",
activeTabIndex: 3,
cursorVisible: true,
});
expect(getNotebookFrame(0.88, 4)).toMatchObject({
phase: "close",
activeTabIndex: -1,
cursorVisible: false,
});
});
it("emits a deterministic cursor click pulse for each tab", () => {
const first = getNotebookFrame(0.475, 4);
const later = getNotebookFrame(0.56, 4);
expect(first.cursorClicking).toBe(true);
expect(later.cursorClicking).toBe(true);
expect(first.cursorX).toBeGreaterThanOrEqual(0);
expect(first.cursorX).toBeLessThanOrEqual(1);
expect(first.cursorY).toBeGreaterThanOrEqual(0);
expect(first.cursorY).toBeLessThanOrEqual(1);
});
it("moves the cursor between tab targets instead of teleporting", () => {
const previous = getNotebookFrame(0.525, 4);
const moving = getNotebookFrame(0.532, 4);
const target = getNotebookFrame(0.56, 4);
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
expect(moving.cursorX).toBeLessThan(target.cursorX);
expect(moving.cursorY).toBe(target.cursorY);
});
it("lands the click pulse on the center of the selected tab", () => {
for (const progress of [0.475, 0.56, 0.65, 0.735]) {
const frame = getNotebookFrame(progress, 4);
expect(frame.cursorClicking).toBe(true);
expect(frame.cursorX).toBeCloseTo((frame.activeTabIndex + 0.5) / 4, 6);
} }
}); });
it("tracks the real number of tabs", () => { it("seeks directly into a readable hold for the requested project", () => {
expect(getNotebookFrame(0.68, 2).cursorX).toBeCloseTo(0.75, 6); for (const count of [1, 4]) {
expect(getNotebookFrame(0.6, 3).cursorX).toBeCloseTo(0.5, 6); for (let index = 0; index < count; index += 1) {
const progress = getNotebookSeekProgress(index, count);
const frame = getNotebookFrame(progress, count);
expect(frame.activeTabIndex).toBe(index);
expect(frame.screenOpacity).toBe(1);
expect(frame.cursorClicking).toBe(false);
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
index,
);
}
}
expect(
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
).toBe(3);
}); });
it("is deterministic when rewound to the same progress", () => { it("does not expose a tab or cursor for empty or invalid project counts", () => {
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
});
it("does not expose an invalid tab when there are no projects", () => {
expect(getNotebookFrame(0.6, 0)).toMatchObject({ expect(getNotebookFrame(0.6, 0)).toMatchObject({
activeTabIndex: -1, activeTabIndex: -1,
cursorVisible: false, cursorVisible: false,
cursorClicking: false, cursorClicking: false,
}); });
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
}); });
}); });
+79 -177
View File
@@ -19,197 +19,99 @@ export type NotebookFrame = {
cursorClicking: boolean; cursorClicking: boolean;
}; };
const OPEN_START = 0.06;
const BOOT_START = 0.18;
const CURSOR_START = 0.23;
const BROWSE_START = 0.3;
const BROWSE_END = 0.84;
const CLOSE_START = 0.9;
const CLOSE_LID_START = 0.93;
const OPEN_ANGLE = 112; const OPEN_ANGLE = 112;
const BROWSE_START = 0.44; const ARRIVAL = 0.18;
const BROWSE_END = 0.78;
const HOLD_END = 0.87;
const CLOSE_LID_START = 0.9;
const TAB_CLICK_Y = 0.3;
const PARK = { x: 0.5, y: 0.95 };
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
function normalizeProgress(progress: number): number { function between(value: number, start: number, end: number): number {
return clamp(Number.isFinite(progress) ? progress : 0, 0, 1); return clamp((value - start) / (end - start), 0, 1);
} }
function interpolate(from: number, to: number, progress: number): number { export function getNotebookSeekProgress(
return from + (to - from) * clamp(progress, 0, 1); index: number,
}
function getPhase(progress: number): NotebookPhase {
if (progress < 0.1) return "approach";
if (progress < 0.25) return "open";
if (progress < 0.34) return "boot";
if (progress < BROWSE_START) return "cursor-in";
if (progress < BROWSE_END) return "browse";
if (progress < HOLD_END) return "hold";
return "close";
}
function getActiveTabIndex(
progress: number,
tabCount: number, tabCount: number,
phase: NotebookPhase,
): number { ): number {
if ( const count = Number.isFinite(tabCount)
tabCount < 1 || ? Math.max(0, Math.floor(tabCount))
phase === "approach" || : 0;
phase === "open" || if (!count) return BROWSE_START;
phase === "boot" || const selected = clamp(
phase === "cursor-in" || Number.isFinite(index) ? Math.floor(index) : 0,
phase === "close" 0,
) { count - 1,
return -1; );
} return (
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
if (phase === "hold") { );
return tabCount - 1;
}
const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
return clamp(Math.floor(browseProgress * tabCount), 0, tabCount - 1);
}
function tabCenterX(index: number, tabCount: number): number {
if (tabCount < 1) return PARK.x;
return (index + 0.5) / tabCount;
}
function getCursorPoint(
progress: number,
phase: NotebookPhase,
activeTabIndex: number,
tabCount: number,
): { x: number; y: number } {
if (phase === "cursor-in") {
const cursorProgress = (progress - 0.34) / 0.1;
return {
x: interpolate(PARK.x, tabCenterX(0, tabCount), cursorProgress),
y: interpolate(PARK.y, TAB_CLICK_Y, cursorProgress),
};
}
if (activeTabIndex < 0) {
return { ...PARK };
}
const target = { x: tabCenterX(activeTabIndex, tabCount), y: TAB_CLICK_Y };
if (phase !== "browse" || activeTabIndex === 0) return target;
const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
const slotProgress = browseProgress * tabCount - activeTabIndex;
const previous = {
x: tabCenterX(activeTabIndex - 1, tabCount),
y: TAB_CLICK_Y,
};
const moveProgress = clamp(slotProgress / 0.36, 0, 1);
return {
x: interpolate(previous.x, target.x, moveProgress),
y: target.y,
};
}
function isCursorClicking(
progress: number,
phase: NotebookPhase,
tabCount: number,
): boolean {
if (
tabCount < 1 ||
phase === "approach" ||
phase === "open" ||
phase === "boot" ||
phase === "close"
) {
return false;
}
if (phase === "cursor-in") {
return progress >= 0.43;
}
if (phase === "hold") {
return false;
}
const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
const slotProgress = (browseProgress * tabCount) % 1;
return slotProgress >= 0.36 && slotProgress < 0.5;
} }
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
export function getNotebookFrame( export function getNotebookFrame(
progress: number, progress: number,
tabCount: number, tabCount: number,
target?: NotebookFrame,
): NotebookFrame { ): NotebookFrame {
const normalizedProgress = normalizeProgress(progress); const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
const normalizedTabCount = Math.max(0, Math.floor(tabCount)); const count = Number.isFinite(tabCount)
const phase = getPhase(normalizedProgress); ? Math.max(0, Math.floor(tabCount))
const activeTabIndex = getActiveTabIndex( : 0;
normalizedProgress, const phase: NotebookPhase =
normalizedTabCount, p < OPEN_START
phase, ? "approach"
); : p < BOOT_START
const isApproach = normalizedProgress < 0.1; ? "open"
const isOpening = phase === "open"; : p < CURSOR_START
const isBooting = phase === "boot"; ? "boot"
const isClosing = phase === "close"; : p < BROWSE_START
const lidAngle = isOpening ? "cursor-in"
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15) : p < BROWSE_END
: isClosing ? "browse"
? normalizedProgress < CLOSE_LID_START : p < CLOSE_START
? OPEN_ANGLE ? "hold"
: interpolate( : "close";
OPEN_ANGLE, const slot = between(p, BROWSE_START, BROWSE_END) * count;
0, const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
(normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START), const local = slot - destination;
) const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
: normalizedProgress >= 0.25 const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
? OPEN_ANGLE const travel = between(local, 0, ARRIVAL);
: 0; const cursorTab = moving
const screenOpacity = isBooting ? destination - 1 + travel * travel * (3 - 2 * travel)
? interpolate(0, 1, (normalizedProgress - 0.25) / 0.09) : destination;
: normalizedProgress >= 0.34 && !isClosing const entry = between(p, CURSOR_START, BROWSE_START);
? 1 const frame = target ?? ({} as NotebookFrame);
: isClosing frame.phase = phase;
? interpolate( frame.lidAngle =
1, OPEN_ANGLE *
0, between(p, OPEN_START, BOOT_START) *
(normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END), (1 - between(p, CLOSE_LID_START, 1));
) frame.deviceOpacity = between(p, 0, OPEN_START);
: 0; frame.screenOpacity =
const deviceOpacity = isApproach between(p, BOOT_START, CURSOR_START) *
? interpolate(0, 1, normalizedProgress / 0.1) (1 - between(p, CLOSE_START, CLOSE_LID_START));
: isClosing frame.activeTabIndex = activeTabIndex;
? interpolate(1, 0.92, (normalizedProgress - HOLD_END) / (1 - HOLD_END)) frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
: 1; frame.cursorX = count
const cursorVisible = ? phase === "cursor-in"
(phase === "cursor-in" || phase === "browse" || phase === "hold") && ? 0.5 + (0.5 / count - 0.5) * entry
normalizedTabCount > 0; : (cursorTab + 0.5) / count
const { x: cursorX, y: cursorY } = getCursorPoint( : 0.5;
normalizedProgress, frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
phase, frame.cursorClicking =
activeTabIndex, count > 0 &&
normalizedTabCount, phase === "browse" &&
); (destination === 0
? local < 0.07
return { : local >= ARRIVAL && local < ARRIVAL + 0.07);
phase, return frame;
lidAngle,
deviceOpacity,
screenOpacity,
activeTabIndex,
cursorVisible,
cursorX,
cursorY,
cursorClicking:
cursorVisible &&
isCursorClicking(normalizedProgress, phase, normalizedTabCount),
};
} }
+557 -57
View File
@@ -82,10 +82,6 @@ html {
scroll-padding-top: 68px; scroll-padding-top: 68px;
} }
html [id] {
scroll-margin-top: 68px;
}
.site-header-nav, .site-header-nav,
.site-header-cv, .site-header-cv,
.site-header-version { .site-header-version {
@@ -230,8 +226,6 @@ textarea {
.ds-section { .ds-section {
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px); padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
content-visibility: auto;
contain-intrinsic-size: auto 600px;
} }
.ds-section:nth-of-type(even):not(.ds-section-dark) { .ds-section:nth-of-type(even):not(.ds-section-dark) {
@@ -1056,7 +1050,15 @@ textarea {
.notebook-story-runway { .notebook-story-runway {
position: relative; position: relative;
min-height: var(--notebook-runway, 520vh); min-height: var(--notebook-runway, 560vh);
}
@media (min-width: 1025px) and (hover: hover) and (pointer: fine) and (
prefers-reduced-motion: no-preference
) {
.notebook-story-pending {
min-height: var(--notebook-runway, 560vh);
}
} }
.notebook-story-sticky { .notebook-story-sticky {
@@ -1066,15 +1068,15 @@ textarea {
height: 100dvh; height: 100dvh;
min-height: 100dvh; min-height: 100dvh;
place-items: center; place-items: center;
padding: clamp(40px, 7vw, 96px) clamp(20px, 6vw, 80px); padding: 32px clamp(20px, 3vw, 48px);
} }
.notebook-story-grid { .notebook-story-grid {
display: grid; display: grid;
grid-template-columns: minmax(180px, 0.44fr) minmax(0, 1fr); grid-template-columns: minmax(160px, 0.24fr) minmax(0, 1fr);
align-items: center; align-items: center;
gap: clamp(28px, 6vw, 96px); gap: clamp(20px, 3vw, 48px);
width: min(1240px, 100%); width: min(1440px, 100%);
} }
.notebook-story-copy { .notebook-story-copy {
@@ -1116,7 +1118,7 @@ textarea {
position: relative; position: relative;
display: grid; display: grid;
min-width: 0; min-width: 0;
min-height: min(62vw, 680px); height: min(82dvh, 780px);
place-items: center; place-items: center;
} }
@@ -1144,19 +1146,91 @@ textarea {
} }
.notebook-screen-surface { .notebook-screen-surface {
width: min(660px, 68vw);
pointer-events: auto; pointer-events: auto;
min-width: 280px;
overflow: hidden; overflow: hidden;
border-radius: 2px; border-radius: 2px;
transform: translateZ(0); transform-origin: 0 0;
} }
.notebook-screen-surface .notebook-browser { .notebook-screen-surface .notebook-browser {
width: 100%; width: 100%;
height: 100%;
box-shadow: none; box-shadow: none;
} }
.notebook-screen-surface .notebook-browser {
display: flex;
flex-direction: column;
}
.notebook-screen-surface .notebook-browser-bar {
min-height: 26px;
}
.notebook-screen-surface .notebook-browser-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
padding: 10px 14px;
}
.notebook-screen-surface .notebook-browser-heading {
display: none;
}
.notebook-screen-surface .notebook-tablist {
margin-top: 0;
}
.notebook-screen-surface .notebook-panel {
flex: 1;
min-height: 0;
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 10px;
}
.notebook-screen-surface .notebook-panel .notebook-screenshot-wrap {
grid-row: 1;
align-self: stretch;
width: 100%;
min-height: 0;
aspect-ratio: auto;
}
.notebook-screen-surface .notebook-panel-copy {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 12px;
align-items: center;
}
.notebook-screen-surface .notebook-panel-copy h3 {
font-size: 20px;
}
.notebook-screen-surface .notebook-panel-copy > p {
grid-column: 1;
margin-top: 4px;
font-size: 12px;
}
.notebook-screen-surface .notebook-panel-copy ul {
grid-column: 1;
margin-top: 6px;
}
.notebook-screen-surface .notebook-panel-copy > a,
.notebook-screen-surface .notebook-panel-copy > span {
grid-column: 2;
grid-row: 1 / 4;
max-width: 180px;
margin-top: 0;
font-size: 11px;
}
.notebook-fallback { .notebook-fallback {
width: min(1080px, 100%); width: min(1080px, 100%);
margin: 0 auto; margin: 0 auto;
@@ -1241,7 +1315,8 @@ textarea {
.notebook-tablist { .notebook-tablist {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 1px; gap: 1px;
margin-top: var(--sp-5); margin-top: var(--sp-5);
border-block: 1px solid rgba(244, 242, 237, 0.12); border-block: 1px solid rgba(244, 242, 237, 0.12);
@@ -1274,8 +1349,9 @@ textarea {
color: var(--white); color: var(--white);
} }
.notebook-tab[data-cursor-clicking="true"] { .notebook-tab:focus-visible {
transform: scale(0.97); outline: 2px solid var(--slash);
outline-offset: -3px;
} }
.notebook-tab-number { .notebook-tab-number {
@@ -1296,12 +1372,16 @@ textarea {
.notebook-panel { .notebook-panel {
display: grid; display: grid;
grid-template-columns: minmax(140px, 0.35fr) minmax(0, 1fr); grid-template-columns: 1fr;
align-items: center; align-items: start;
gap: clamp(20px, 3vw, 36px); gap: var(--sp-6);
margin-top: var(--sp-6); margin-top: var(--sp-6);
} }
.notebook-panel-copy {
order: 2;
}
.notebook-panel-copy h3 { .notebook-panel-copy h3 {
color: var(--white); color: var(--white);
font-family: var(--font-display); font-family: var(--font-display);
@@ -1375,38 +1455,43 @@ textarea {
.notebook-screenshot-wrap { .notebook-screenshot-wrap {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
aspect-ratio: 16 / 9; aspect-ratio: 8 / 5;
border: 1px solid rgba(244, 242, 237, 0.16); border: 1px solid rgba(244, 242, 237, 0.16);
background: var(--paper-dark); background: var(--paper-dark);
} }
.notebook-screenshot-status {
position: absolute;
z-index: 2;
inset: auto 0 0;
padding: 10px 14px;
background: rgba(10, 10, 10, 0.92);
color: var(--paper);
font-size: 12px;
line-height: 1.4;
}
.notebook-screenshot-wrap img { .notebook-screenshot-wrap img {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
} }
.notebook-screenshot-current, .notebook-screenshot {
.notebook-screenshot-outgoing {
position: absolute; position: absolute;
inset: 0; inset: 0;
} }
.notebook-screenshot-current { .notebook-screenshot-current {
z-index: 0; z-index: 1;
animation: notebook-screenshot-in 180ms ease both; animation: notebook-screenshot-in 220ms ease both;
} }
.notebook-screenshot-outgoing { .notebook-screenshot-outgoing {
z-index: 0; z-index: 0;
animation: notebook-screenshot-out 180ms ease both;
} }
.notebook-screenshot-wrap img.notebook-screenshot-preload { .notebook-screenshot-pending {
position: absolute;
z-index: -1;
width: 1px;
height: 1px;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
@@ -1420,35 +1505,26 @@ textarea {
} }
} }
@keyframes notebook-screenshot-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.notebook-cursor { .notebook-cursor {
position: absolute; position: absolute;
top: 0;
left: 0;
z-index: 3; z-index: 3;
width: 15px; width: 28px;
height: 20px; height: 36px;
border: 2px solid var(--white); overflow: visible;
background: var(--ink); opacity: 0;
box-shadow: 2px 2px 0 var(--slash);
pointer-events: none; pointer-events: none;
transform: translate(-50%, -45%) rotate(-18deg); transform-origin: 1px 1px;
transition: filter: drop-shadow(1px 2px 1px rgba(0, 0, 0, 0.4));
width 120ms ease,
height 120ms ease,
box-shadow 120ms ease;
} }
.notebook-cursor.is-clicking { .notebook-cursor-pulse {
width: 12px; opacity: 0;
height: 16px; }
box-shadow: 1px 1px 0 var(--slash);
.notebook-cursor.is-clicking .notebook-cursor-pulse {
opacity: 0.7;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -1500,6 +1576,7 @@ textarea {
} }
.notebook-tablist { .notebook-tablist {
grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1528,8 +1605,431 @@ textarea {
transition: none; transition: none;
animation: none; animation: none;
} }
}
.notebook-tab[data-cursor-clicking="true"] { /* Interactive quality laboratory — scoped to the Lighthouse section. */
transform: none; .quality-lab {
--quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);
--quality-line: color-mix(in srgb, var(--paper) 18%, transparent);
color: var(--paper);
}
.quality-intro {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.3fr);
gap: var(--sp-12);
align-items: center;
margin-bottom: var(--sp-10);
}
.quality-copy .ds-eyebrow {
color: var(--slash);
margin-bottom: var(--sp-5);
}
.quality-copy .ds-section-title {
max-width: 16ch;
}
.quality-copy h2 span {
display: block;
color: var(--slash);
}
.quality-copy > p:not(.ds-eyebrow) {
max-width: 44ch;
margin: var(--sp-6) 0;
color: var(--quality-muted);
font-size: 15px;
line-height: 1.8;
}
.quality-copy .ds-link {
white-space: normal;
line-height: 1.7;
}
.quality-visual {
min-width: 0;
border: 1px solid var(--quality-line);
}
.quality-visual-label {
display: flex;
justify-content: space-between;
gap: var(--sp-4);
border-bottom: 1px solid var(--quality-line);
padding: var(--sp-4);
color: var(--quality-muted);
font: 10px / 1.5 var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.quality-visual-label span:first-child {
color: var(--paper);
}
.quality-blueprint {
display: block;
width: 100%;
height: auto;
}
.quality-dimensions {
stroke: var(--quality-line);
}
.quality-dimensions text {
fill: var(--quality-muted);
stroke: none;
font: 9px var(--font-mono);
}
.quality-browser-frame {
fill: var(--ink);
stroke: color-mix(in srgb, var(--paper) 38%, transparent);
}
.quality-wire {
stroke: color-mix(in srgb, var(--paper) 30%, transparent);
}
.quality-browser-dots {
fill: var(--quality-muted);
}
.quality-address {
fill: var(--quality-muted);
font: 10px var(--font-mono);
}
.quality-browser-heading {
fill: var(--paper);
font: 700 24px var(--font-display);
}
.quality-browser-button {
fill: var(--slash-dim);
stroke: color-mix(in srgb, var(--slash) 50%, transparent);
}
.quality-browser-button-label {
fill: var(--paper);
font: 9px var(--font-mono);
}
.quality-layer {
stroke: var(--slash);
stroke-width: 1.2px;
opacity: 0;
transition: opacity 180ms ease-out;
}
.quality-layer text {
fill: var(--slash);
stroke: none;
font: 10px var(--font-mono);
}
.quality-layer circle {
fill: var(--slash);
}
.quality-highlight {
fill: color-mix(in srgb, var(--slash) 5%, transparent);
}
.quality-focus-frame {
stroke-width: 2px;
stroke-dasharray: 5 3;
}
.quality-lab[data-inspection="performance"] [data-layer="performance"],
.quality-lab[data-inspection="accessibility"] [data-layer="accessibility"],
.quality-lab[data-inspection="practices"] [data-layer="practices"],
.quality-lab[data-inspection="seo"] [data-layer="seo"] {
opacity: 1;
}
.quality-annotation {
fill: var(--ink);
stroke: none;
}
@media (prefers-reduced-motion: no-preference) {
.quality-lab[data-inspection="performance"]
[data-layer="performance"]
.quality-trace,
.quality-lab[data-inspection="accessibility"]
[data-layer="accessibility"]
.quality-trace,
.quality-lab[data-inspection="practices"]
[data-layer="practices"]
.quality-trace,
.quality-lab[data-inspection="seo"] [data-layer="seo"] .quality-trace {
stroke-dasharray: 1;
animation: quality-trace 650ms ease-out;
}
}
@keyframes quality-trace {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
.quality-visual figcaption {
display: grid;
min-height: 64px;
border-top: 1px solid var(--quality-line);
padding: var(--sp-4);
color: var(--quality-muted);
font-size: 12px;
line-height: 1.65;
}
/* Overlapping hidden panels reserve the tallest content without fixed heights. */
.quality-visual figcaption span,
.quality-report {
grid-area: 1 / 1;
}
.quality-visual figcaption span[aria-hidden="true"],
.quality-report[aria-hidden="true"] {
visibility: hidden;
}
.quality-report-stack {
display: grid;
}
.quality-controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-width: 0;
border: 0;
}
.quality-control {
position: relative;
min-width: 0;
cursor: pointer;
}
.quality-control input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.quality-control-content {
display: flex;
flex-direction: column;
gap: var(--sp-3);
height: 100%;
padding: var(--sp-5);
border: 1px solid var(--quality-line);
border-top-width: 2px;
transition:
background-color 180ms ease-out,
border-color 180ms ease-out;
}
.quality-control + .quality-control .quality-control-content {
border-left: 0;
}
.quality-control:hover .quality-control-content {
background: color-mix(in srgb, var(--paper) 5%, transparent);
}
.quality-control input:checked + .quality-control-content {
border-top-color: var(--slash);
background: var(--slash-dim);
}
.quality-control input:focus-visible + .quality-control-content {
outline: 2px solid var(--slash);
outline-offset: 4px;
}
.quality-control-name {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: 15px;
font-weight: 600;
}
.quality-control-name::before {
content: "";
width: 7px;
height: 7px;
flex-shrink: 0;
border: 1px solid currentColor;
border-radius: 50%;
}
.quality-control input:checked + span .quality-control-name::before {
background: var(--slash);
border-color: var(--slash);
}
.quality-control-target {
display: flex;
align-items: baseline;
gap: var(--sp-2);
color: var(--quality-muted);
font: 11px / 1.2 var(--font-mono);
}
.quality-control-target strong {
margin-left: auto;
color: var(--paper);
font-size: 36px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.quality-control input:checked + span strong {
color: var(--slash);
}
.quality-hint,
.quality-disclaimer {
color: var(--quality-muted);
font-size: 12px;
line-height: 1.8;
}
.quality-hint {
margin-top: var(--sp-4);
}
.quality-report {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: var(--sp-12);
min-height: 240px;
padding: var(--sp-8) 0;
border-bottom: 1px solid var(--quality-line);
}
.quality-code {
color: var(--slash);
font: 10px / 1.8 var(--font-mono);
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.quality-report h3 {
margin: var(--sp-2) 0 var(--sp-3);
font: 600 clamp(20px, 2.4vw, 28px) / 1.2 var(--font-display);
letter-spacing: -0.025em;
}
.quality-report-copy > p:last-child {
max-width: 60ch;
color: var(--quality-muted);
font-size: 14px;
line-height: 1.8;
}
.quality-report ul {
list-style: none;
margin: 0;
padding: 0;
}
.quality-report li {
display: flex;
gap: var(--sp-3);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--quality-line);
font-size: 13px;
line-height: 1.65;
}
.quality-report li span {
color: var(--slash);
}
.quality-disclaimer {
margin-top: var(--sp-5);
max-width: 90ch;
}
@media (max-width: 1000px) {
.quality-intro {
grid-template-columns: 1fr;
gap: var(--sp-8);
}
.quality-copy .ds-section-title,
.quality-copy > p:not(.ds-eyebrow) {
max-width: 650px;
}
.quality-visual {
width: min(100%, 720px);
}
}
@media (max-width: 680px) {
.quality-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.quality-control + .quality-control .quality-control-content {
border-left: 1px solid var(--quality-line);
}
.quality-control-content {
padding: var(--sp-4) var(--sp-3);
}
.quality-control-name {
font-size: 13px;
}
.quality-control-target strong {
font-size: 28px;
}
.quality-visual-label {
padding: var(--sp-3);
font-size: 9px;
}
.quality-report {
grid-template-columns: 1fr;
gap: var(--sp-4);
min-height: 350px;
}
}
@media (max-width: 360px) {
.quality-control-content {
padding-inline: var(--sp-2);
}
.quality-control-name {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.quality-layer,
.quality-control-content {
transition: none;
} }
} }
+2 -2
View File
@@ -40,8 +40,8 @@ export default async function Image({
return new ImageResponse( return new ImageResponse(
<OgCard <OgCard
eyebrow={service.eyebrow} eyebrow={service.eyebrow}
title={service.headline} title={service.navLabel}
description={service.answer} description={service.intro}
/>, />,
OG_SIZE, OG_SIZE,
); );
+14 -9
View File
@@ -91,10 +91,18 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
}); });
const { container } = render(ui); const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(service.answer); const answer = screen.getByText(service.answer);
expect(answer).toBeVisible(); expect(answer).toBeVisible();
expect(heading.nextElementSibling).toBe(answer); for (const item of [
...service.outcomes,
...service.examples,
...service.process,
]) {
expect(screen.getByText(item)).toBeVisible();
}
for (const detail of service.technicalDetails) {
expect(screen.getByText(detail)).not.toBeVisible();
}
const primaryCtas = screen.getAllByRole("link", { const primaryCtas = screen.getAllByRole("link", {
name: service.ctaLabel, name: service.ctaLabel,
@@ -201,11 +209,6 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
it("keeps hub cards and structured items aligned", () => { it("keeps hub cards and structured items aligned", () => {
const { container } = render(<ServicesIndexPage />); const { container } = render(<ServicesIndexPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(
/desenvolvimento.+consultoria.+engenharia de software/i,
);
const visibleBreadcrumb = screen.getByRole("navigation", { const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb", name: "Breadcrumb",
}); });
@@ -225,7 +228,7 @@ it("keeps hub cards and structured items aligned", () => {
"BreadcrumbList", "BreadcrumbList",
]); ]);
const itemList = graph[1] as { const itemList = graph[1] as {
itemListElement: Array<{ position: number; url: string }>; itemListElement: Array<{ position: number; url: string; name: string }>;
}; };
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([ expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6,
@@ -236,7 +239,9 @@ it("keeps hub cards and structured items aligned", () => {
expect([...structuredUrls].sort()).toEqual( expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(), publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
); );
const visibleCards = screen.getAllByRole("link", { name: "Abrir página" }); const visibleCards = itemList.itemListElement.map(({ name }) =>
screen.getByRole("link", { name }),
);
const visibleHrefs = visibleCards.map((link) => link.getAttribute("href")); const visibleHrefs = visibleCards.map((link) => link.getAttribute("href"));
expect(visibleHrefs).toHaveLength(6); expect(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6); expect(new Set(visibleHrefs).size).toBe(6);
+56 -49
View File
@@ -147,11 +147,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-section-shell"> <div className="ds-section-shell">
<div className="ds-grid-2"> <div className="ds-grid-2">
<article className="ds-card"> <article className="ds-card">
<div className="ds-kicker">Para quem faz sentido</div> <h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
<h2 className="ds-card-title"> <ul className="ds-list">
Contextos em que esse serviço encaixa melhor
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{service.audience.map((item) => ( {service.audience.map((item) => (
<li key={item}>{item}</li> <li key={item}>{item}</li>
))} ))}
@@ -159,11 +156,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
</article> </article>
<article className="ds-card"> <article className="ds-card">
<div className="ds-kicker">O que muda</div> <h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
<h2 className="ds-card-title"> <ul className="ds-list">
Resultados que eu busco destravar
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{service.outcomes.map((item) => ( {service.outcomes.map((item) => (
<li key={item}>{item}</li> <li key={item}>{item}</li>
))} ))}
@@ -176,48 +170,59 @@ export default async function ServicePage({ params }: ServicePageProps) {
<section className="ds-section"> <section className="ds-section">
<div className="ds-section-shell"> <div className="ds-section-shell">
<div className="ds-grid-2"> <div className="ds-grid-2">
<article className="ds-card"> <article className="min-w-0">
<div className="ds-kicker">Como eu estruturo</div> <div className="ds-kicker">Da conversa à entrega</div>
<h2 className="ds-card-title">Processo de trabalho</h2> <h2 className="ds-card-title">Como trabalhamos</h2>
<ol className="list-none mt-sp-5 space-y-sp-4 text-[14px] leading-8 text-[var(--ink-80)]"> <ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-sm leading-8 text-[var(--ink-80)]">
{service.process.map((step, index) => ( {service.process.map((step) => (
<li <li key={step} className="pl-sp-2">
key={step}
className="border-t border-[var(--border)] pt-sp-4 first:border-t-0 first:pt-0"
>
<span className="mr-sp-3 inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-[var(--border)] text-[11px] font-semibold text-[var(--meta)]">
{index + 1}
</span>
{step} {step}
</li> </li>
))} ))}
</ol> </ol>
</article> </article>
<article className="ds-card"> <article className="min-w-0">
<div className="ds-kicker">Stack e repertório</div> <div className="ds-kicker">Possibilidades de uso</div>
<h2 className="ds-card-title"> <h2 className="ds-card-title">Imagine na sua rotina</h2>
Tecnologias e capacidades envolvidas
</h2>
<p className="ds-copy"> <p className="ds-copy">
A stack entra como meio para resolver o problema certo, com Exemplos ilustrativos para ajudar a explicar o serviço, não
critério de manutenção, observabilidade e evolução do produto. projetos já entregues. O seu escopo será definido na conversa.
</p> </p>
<ul className="ds-tags"> <ul className="ds-list">
{service.stack.map((item) => ( {service.examples.map((example) => (
<MagneticButton <li key={example}>{example}</li>
key={item}
as="li"
className="ds-tag"
strength={0.15}
>
{item}
</MagneticButton>
))} ))}
</ul> </ul>
</article> </article>
</div> </div>
</div> </div>
<div className="ds-section-shell mt-sp-8">
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos e tecnologias
</summary>
<div className="max-w-[80ch]">
<ul className="ds-list">
{service.technicalDetails.map((detail) => (
<li key={detail}>{detail}</li>
))}
</ul>
<p className="ds-copy">
Tecnologias consideradas neste serviço. A seleção depende do
problema e do ambiente; não é uma lista obrigatória para todo
projeto.
</p>
<ul className="ds-tags" aria-label="Tecnologias do serviço">
{service.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
</div>
</details>
</div>
</section> </section>
<section className="ds-section"> <section className="ds-section">
@@ -226,11 +231,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-section-meta"> <div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p> <p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title"> <h2 className="ds-section-title">
Projetos que reforçam esse tipo de entrega Projetos para conhecer meu trabalho
</h2> </h2>
<p className="ds-section-description"> <p className="ds-section-description">
Alguns exemplos de contexto técnico em que esse serviço se Referências de problemas e soluções relacionados. Cada projeto
conecta a entregas reais. usa as tecnologias indicadas no próprio case; não representa
todas as plataformas ou tecnologias oferecidas neste serviço.
</p> </p>
</div> </div>
</div> </div>
@@ -246,7 +252,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</p> </p>
<ul className="ds-tags mt-sp-6"> <ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => ( {caseStudy.stack.map((item) => (
<li key={item} className="ds-tag"> <li key={item} className="ds-tag !whitespace-normal">
{item} {item}
</li> </li>
))} ))}
@@ -265,7 +271,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Abrir projeto Abrir projeto: {caseStudy.title}
</a> </a>
) : null} ) : null}
{caseStudy.githubUrl ? ( {caseStudy.githubUrl ? (
@@ -275,7 +281,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Ver código Ver código: {caseStudy.title}
</a> </a>
) : null} ) : null}
</div> </div>
@@ -290,12 +296,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]"> <div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
<div className="ds-kicker">Próximo passo</div> <div className="ds-kicker">Próximo passo</div>
<h2 className="ds-card-title text-white"> <h2 className="ds-card-title text-white">
Se esse serviço parece aderente ao seu contexto, a conversa Conte o que você precisa resolver.
inicial já pode ser objetiva.
</h2> </h2>
<p className="ds-copy !text-[rgba(255,255,255,0.76)]"> <p className="ds-copy !text-[rgba(255,255,255,0.76)]">
Traga o sistema, o gargalo e o objetivo de negócio. Eu estruturo a Pode ser uma ideia, uma tarefa que toma tempo ou um sistema que
leitura técnica e indico o caminho mais seguro para avançar. precisa melhorar. Não é preciso chegar com uma solução técnica.
Atendo a partir de Garuva, no Norte de Santa Catarina, e
remotamente.
</p> </p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3"> <div className="mt-sp-6 flex flex-wrap gap-sp-3">
<MagneticButton <MagneticButton
+3 -3
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt = export const alt =
"Serviços de engenharia de software e consultoria técnica — Evandro Fonseca Junior"; "Sites, sistemas web, aplicativos mobile e desktop — Evandro Fonseca Junior";
export const size = OG_SIZE; export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE; export const contentType = OG_CONTENT_TYPE;
@@ -10,8 +10,8 @@ export default async function Image() {
return new ImageResponse( return new ImageResponse(
<OgCard <OgCard
eyebrow="Serviços" eyebrow="Serviços"
title="Engenharia de software e consultoria técnica" title="Sites, sistemas e aplicativos"
description="Base em Garuva, Norte de Santa Catarina e remoto." description="Web, mobile e desktop para Windows e Linux. Desenvolvimento, integrações e melhorias para o seu negócio."
/>, />,
OG_SIZE, OG_SIZE,
); );
+49 -23
View File
@@ -1,15 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground"; import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo"; import { buildBreadcrumbList } from "@/lib/seo";
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content"; import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/servicos`; const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Serviços de Engenharia de Software e Consultoria Técnica"; const pageTitle = "Sites, Sistemas Web e Aplicativos Mobile e Desktop";
const pageDescription = const pageDescription =
"Desenvolvimento e consultoria em engenharia de software, Next.js, Tauri, Rust, arquitetura e integração hardware + software para produtos e sistemas críticos."; "Desenvolvimento de sites, sistemas web, aplicativos mobile e desktop para Windows e Linux. Integrações, melhorias e consultoria para o seu negócio.";
export const metadata: Metadata = { export const metadata: Metadata = {
title: pageTitle, title: pageTitle,
@@ -80,13 +79,15 @@ export default function ServicesIndexPage() {
</li> </li>
</ol> </ol>
</nav> </nav>
<p className="ds-eyebrow">Hub de serviços</p> <p className="ds-eyebrow">Serviços de desenvolvimento</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white"> <h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Serviços de engenharia de software para contextos técnicos de alta Da presença na internet ao sistema que move seu negócio.
exigência.
</h1> </h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]"> <p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageDescription} Sites e sistemas web, aplicativos mobile e aplicativos desktop
para Windows e Linux. Também integro ferramentas e melhoro
software que já está em uso. Começamos pelo que você precisa
resolver, não pela tecnologia.
</p> </p>
</div> </div>
</div> </div>
@@ -94,25 +95,50 @@ export default function ServicesIndexPage() {
<section className="ds-section"> <section className="ds-section">
<div className="ds-section-shell"> <div className="ds-section-shell">
<div className="ds-grid-2"> <div className="max-w-[72ch]">
{serviceHubLinks.map((service) => ( <h2 className="ds-card-title !mt-0">O que você precisa fazer?</h2>
<article key={service.href} className="ds-card"> <p className="ds-copy">
<div className="ds-kicker">Serviço especializado</div> Conheça as possibilidades abaixo. Cada serviço explica para quem
<h2 className="ds-card-title">{service.label}</h2> faz sentido, exemplos de uso, escopo e etapas de trabalho. Os
<p className="ds-copy">{service.description}</p> detalhes técnicos ficam disponíveis para aprofundar a conversa.
<div className="mt-sp-6"> </p>
<MagneticButton </div>
as="a" <div className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
href={service.href} {serviceHubLinks.map((service, index) => (
className="ds-link ds-link-accent" <article
strength={0.25} key={service.href}
> className="grid gap-sp-4 py-sp-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-sp-8"
Abrir página >
</MagneticButton> <div className="min-w-0">
<p className="ds-kicker" aria-hidden="true">
{String(index + 1).padStart(2, "0")}
</p>
<h3 className="ds-card-title">
<Link
href={service.href}
className="underline decoration-[var(--slash)] decoration-2 underline-offset-4 hover:decoration-[var(--ink)]"
>
{service.label}
</Link>
</h3>
</div> </div>
<p className="ds-copy !mt-0">{service.description}</p>
</article> </article>
))} ))}
</div> </div>
<div className="mt-sp-8 max-w-[72ch]">
<h2 className="ds-card-title">
Ainda não sabe qual caminho seguir?
</h2>
<p className="ds-copy">
Conte como funciona sua rotina e o que gostaria de mudar.
Definimos juntos a primeira entrega, as plataformas atendidas e os
limites do projeto antes de fechar a proposta.
</p>
<Link className="ds-link ds-link-accent mt-sp-5" href="/#contact">
Conversar sobre minha ideia
</Link>
</div>
</div> </div>
</section> </section>
</main> </main>
-8
View File
@@ -80,14 +80,6 @@ it("derives the exact related service set from the existing relationship", () =>
); );
}); });
it("gives every service a direct answer and consulting CTA", () => {
for (const service of servicePages) {
expect(service.answer.trim().length).toBeGreaterThan(40);
expect(service.ctaIntent).toBe("consulting");
expect(service.ctaHref).toBe("/#contact");
}
});
it("resolves every declared related case ID", () => { it("resolves every declared related case ID", () => {
const caseIds = new Set(caseStudies.map(({ id }) => id)); const caseIds = new Set(caseStudies.map(({ id }) => id));
const unresolved = servicePages.flatMap(({ slug, relatedCaseIds }) => const unresolved = servicePages.flatMap(({ slug, relatedCaseIds }) =>
+208 -188
View File
@@ -56,6 +56,8 @@ export interface ServicePageContent {
audience: string[]; audience: string[];
outcomes: string[]; outcomes: string[];
process: string[]; process: string[];
examples: string[];
technicalDetails: string[];
stack: string[]; stack: string[];
ctaIntent: "consulting"; ctaIntent: "consulting";
ctaLabel: string; ctaLabel: string;
@@ -79,271 +81,291 @@ export interface CaseStudyContent {
export const servicePages: ServicePageContent[] = [ export const servicePages: ServicePageContent[] = [
{ {
slug: "criacao-de-sites", slug: "criacao-de-sites",
navLabel: "Criação de Sites", navLabel: "Sites profissionais",
eyebrow: "Presença digital", eyebrow: "Presença digital",
seoTitle: "Criação de Sites e Sistemas Web com Next.js", seoTitle: "Criação de Sites Profissionais e Institucionais",
metaDescription: metaDescription:
"Criação de sites institucionais e sistemas web responsivos, com acessibilidade, performance e SEO técnico considerados desde a implementação.", "Sites profissionais para apresentar serviços, produtos e canais de contato. Desenvolvimento responsivo com acessibilidade e SEO técnico, em escopo definido.",
headline: "Criação de sites institucionais e sistemas web com Next.js.", headline: "Um site que explica seu negócio.",
answer: answer:
"Crio sites institucionais e sistemas web em Next.js, com escopo definido por objetivos, páginas, integrações e requisitos de acessibilidade, performance e SEO técnico. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
intro: intro:
"Desenvolvo sites e sistemas web responsivos com estrutura técnica para indexação e evolução do produto, sempre dentro do escopo acordado para conteúdo e integrações.", "Apresente seu negócio na internet com um site profissional: serviços, produtos, portfólio e caminhos claros para entrar em contato.",
audience: [ audience: [
"Empresas e profissionais que ainda não têm site ou precisam substituir uma experiência lenta e desatualizada.", "Empresas e profissionais que ainda não têm site ou precisam substituir uma presença desatualizada.",
"Negócios que precisam organizar conteúdo e fundamentos técnicos para busca orgânica sem criar páginas locais artificiais.", "Negócios que querem explicar seus serviços sem depender apenas das redes sociais.",
"Marcas que precisam de um site institucional rápido, com boa performance e pronto para escalar para um sistema web completo.", "Equipes que precisam organizar páginas, conteúdo e contato em um endereço próprio.",
], ],
outcomes: [ outcomes: [
"Site institucional rápido, responsivo e com boa nota em Core Web Vitals.", "Páginas com uma sequência clara: o que você faz, para quem e como entrar em contato.",
"Estrutura de SEO técnico (title, metadata, dados estruturados e sitemap) desde o lançamento.", "Layout adaptado a celular e computador, com atenção à leitura e à navegação.",
"Presença digital pronta para captar clientes que pesquisam por serviços na sua cidade.", "Publicação e orientação de uso dentro do escopo; domínio, hospedagem, produção de conteúdo e manutenção são combinados na proposta.",
"Base de código organizada, fácil de evoluir para loja, sistema ou painel administrativo.", ],
examples: [
"Um site institucional para uma prestadora de serviços, com apresentação, áreas de atuação e contato.",
"Um catálogo para mostrar produtos e receber consultas, sem assumir uma loja com pagamento on-line.",
"Uma página de campanha para explicar uma oferta e orientar o próximo passo.",
], ],
process: [ process: [
"Levantamento do objetivo do site, público local e páginas necessárias.", "Conversamos sobre o negócio, o público e o que o visitante precisa conseguir fazer.",
"Definição de estrutura, conteúdo e palavras-chave locais relevantes.", "Combinamos páginas, conteúdo, referências visuais, responsabilidades e orçamento.",
"Implementação em Next.js com foco em performance, acessibilidade e SEO.", "Você acompanha a construção e revisa conteúdo e navegação em etapas.",
"Publicação, configuração de domínio e orientação sobre indexação no Google.", "Publicamos após a revisão e definimos o que entra em suporte ou evolução posterior.",
],
technicalDetails: [
"Implementação com Next.js e TypeScript, escolhendo renderização e carregamento conforme o conteúdo e as integrações.",
"HTML semântico, navegação por teclado, imagens dimensionadas e avaliação de carregamento orientam acessibilidade e performance.",
"Metadados, URLs canônicas, sitemap e dados estruturados quando aplicáveis ajudam a interpretação do site pelos buscadores; não garantem posição no Google.",
], ],
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"], stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto", ctaLabel: "Conversar sobre meu site",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["sc-plus", "nutriscan"], relatedCaseIds: ["sc-plus", "nutriscan"],
faq: [ faq: [
{ {
question: "Quanto custa um site profissional?", question: "Quanto custa e o que preciso ter pronto?",
answer: answer:
"O custo de um site profissional depende do escopo: número de páginas, funcionalidades, integrações e exigências de SEO técnico e performance. Um site institucional enxuto custa menos do que um sistema web completo. Eu prefiro definir o orçamento depois de entender o objetivo do projeto, em uma conversa inicial sem compromisso.", "O orçamento depende das páginas, conteúdo, funcionalidades e integrações. Podemos começar com uma conversa sobre seu negócio; nela definimos quem fornece textos, imagens e acessos, além dos custos de domínio e hospedagem.",
}, },
{ {
question: "Site sob medida ou WordPress: qual escolher?", question: "O site também funciona no celular?",
answer: answer:
"Escolha WordPress quando o conteúdo muda toda semana e alguém da equipe precisa publicar sem ajuda técnica; escolha um site sob medida quando performance, SEO técnico e evolução futura pesam mais. Sites feitos sob medida em Next.js carregam mais rápido, têm menos superfície de manutenção e escalam para sistema web sem migração. Para muitos negócios locais, o sob medida compensa pela velocidade e pela longevidade.", "Sim, a proposta considera um site responsivo, acessado pelo navegador no celular e no computador. Isso não equivale a um aplicativo instalado; se você precisa de um app, avaliamos esse escopo separadamente.",
}, },
{ {
question: "Você faz manutenção de site depois do lançamento?", question: "Posso contratar melhorias depois da publicação?",
answer: answer:
"Sim. Além do lançamento, eu estruturo projetos pensando na manutenção do site: atualizações de dependências, ajustes de conteúdo, monitoramento de performance e evolução incremental de funcionalidades. Um site profissional não termina na publicação; ele precisa continuar rápido, seguro e bem posicionado. Por isso a entrega inclui orientação sobre indexação no Google e uma base de código organizada, fácil de evoluir.", "Sim. Ajustes de conteúdo, novas páginas, manutenção e novas funcionalidades podem ser combinados em continuidade. O que está incluído na entrega inicial e no suporte fica definido na proposta.",
},
{
question: "Como funciona o atendimento local e remoto?",
answer:
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota. O trabalho segue conversa inicial, levantamento de escopo e entregas acompanhadas de perto; qualquer estratégia local é definida a partir da atuação real de cada negócio.",
}, },
], ],
}, },
{ {
slug: "engenharia-de-software", slug: "engenharia-de-software",
navLabel: "Engenharia de Software", navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Serviço principal", eyebrow: "Engenharia de software",
seoTitle: "Engenharia de Software para Produtos e Sistemas Críticos", seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
metaDescription: metaDescription:
"Engenharia de software para evolução de produtos, integrações complexas e sistemas críticos. Entrega ponta a ponta com foco em arquitetura clara, estabilidade e velocidade de execução.", "Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.",
headline: headline: "Sistemas e apps para o seu trabalho.",
"Engenharia de software para evoluir produto sem aumentar o caos técnico.",
answer: answer:
"Atuo no diagnóstico, arquitetura, implementação e sustentação de produtos e sistemas, com entregáveis e limites definidos pelo risco técnico e pelo recorte acordado. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
intro: intro:
"Atuo em discovery, arquitetura, implementação e sustentação de software para equipes que precisam entregar com previsibilidade, sem sacrificar clareza técnica nem operação.", "Organize a operação com um sistema web ou aplicativo mobile sob medida. Conecte ferramentas, reduza tarefas repetidas ou melhore um sistema que já existe.",
audience: [ audience: [
"Times que já tem produto em operação e precisam voltar a ganhar velocidade.", "Empresas que controlam processos em planilhas, mensagens e ferramentas que não conversam entre si.",
"Empresas que sofrem com integrações, retrabalho e backlog técnico acumulado.", "Negócios que precisam de um sistema interno, área de clientes ou aplicativo para uso no celular.",
"Operações em que confiabilidade e continuidade pesam mais do que velocidade teatral.", "Equipes com software em operação que precisam corrigir problemas e continuar evoluindo.",
], ],
outcomes: [ outcomes: [
"Arquitetura mais legível para manter e escalar.", "Uma primeira versão com as funções prioritárias, em vez de tentar resolver toda a operação de uma vez.",
"Entrega incremental com menos retrabalho entre produto e engenharia.", "Fluxos para consultar, registrar e compartilhar informações, com acessos definidos por perfil.",
"Integrações mais robustas entre sistemas, APIs e fluxos internos.", "Escopo explícito para web ou mobile: dispositivos, sistemas operacionais, uso sem internet e distribuição são avaliados na proposta, não presumidos.",
"Base técnica preparada para crescer sem depender de improviso constante.", ],
examples: [
"Um sistema web para acompanhar pedidos e atendimentos em uma única tela.",
"Um aplicativo mobile para uma equipe consultar tarefas e registrar visitas pelo celular.",
"Uma integração para transferir dados entre o sistema de vendas e outra ferramenta, sem digitação duplicada.",
], ],
process: [ process: [
"Diagnóstico do contexto técnico, do risco e do gargalo principal.", "Entendemos a rotina atual, quem usa o sistema e onde estão as dificuldades.",
"Definição do recorte de entrega com prioridades e trade-offs claros.", "Escolhemos o primeiro fluxo a entregar e combinamos plataformas, integrações e critérios de aceitação.",
"Implementação com rastreabilidade, code review e critério de produção.", "Construímos em etapas curtas, com demonstrações e revisão de quem vai usar.",
"Handoff ou continuidade com documentação e próximo ciclo recomendado.", "Validamos no ambiente combinado e entregamos orientações para operação, manutenção e próximos passos.",
],
technicalDetails: [
"A arquitetura parte das regras do negócio, dos limites entre módulos e dos contratos de API. Migrações e integrações consideram os dados e o sistema já em uso.",
"Autorização por perfil, validação no servidor, tratamento de falhas e testes dos fluxos críticos entram conforme os riscos do projeto.",
"A experiência profissional declarada inclui Web e Mobile. A tecnologia mobile é definida após avaliar dispositivos e recursos necessários; os cases abaixo não comprovam um framework mobile específico.",
], ],
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"], stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto", ctaLabel: "Conversar sobre meu sistema ou app",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"], relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
faq: [ faq: [
{ {
question: question: "Preciso saber qual tecnologia usar?",
"Como contratar um desenvolvedor para integração de sistemas?",
answer: answer:
"Procure um desenvolvedor que comece pelo diagnóstico: entender os dois sistemas, os dados envolvidos e o impacto de falha antes de escrever código. Peça clareza sobre contratos de API, tratamento de erros e observabilidade, não apenas prazo. Na integração de sistemas, o critério principal é reduzir risco operacional; um profissional que explica trade-offs com honestidade costuma entregar com mais previsibilidade.", "Não. Começamos pelo que as pessoas precisam fazer. Um sistema web pode resolver o uso pelo navegador; um aplicativo mobile pode fazer sentido quando a rotina exige recursos do celular. A escolha vem depois de entender o contexto.",
}, },
{ {
question: "Quanto custa desenvolver um sistema sob medida?", question: "Você também trabalha com sistemas que já existem?",
answer: answer:
"O investimento em um sistema sob medida acompanha a complexidade: regras de negócio, integrações, volume de usuários e requisitos de confiabilidade. Antes de qualquer proposta, eu faço um diagnóstico do contexto para dimensionar o recorte certo e evitar pagar por complexidade desnecessária. Assim o orçamento reflete o problema real, e não uma estimativa genérica por hora ou por tela.", "Sim. Posso diagnosticar problemas, integrar ferramentas, corrigir fluxos e implementar novas funções. Primeiro avaliamos a base atual e os riscos para definir uma mudança que não dependa de reescrever tudo.",
}, },
{ {
question: "Você trabalha com sistemas legados?", question: "O aplicativo inclui publicação em lojas?",
answer: answer:
"Sim. Parte do trabalho de engenharia de software é justamente evoluir sistemas legados sem parar a operação: mapear acoplamentos, estabilizar pontos críticos e migrar aos poucos, com testes e rastreabilidade. Nem todo legado precisa ser reescrito; muitas vezes a decisão certa é isolar o que funciona e modernizar o que trava a entrega. O diagnóstico define esse recorte antes de qualquer refatoração.", "A forma de distribuição precisa ser avaliada no projeto. Plataformas, contas, requisitos de terceiros e eventual publicação são definidos antes da contratação; não fazem parte de uma promessa genérica de entrega.",
},
{
question: "Como funciona o início do trabalho com você?",
answer:
"Tudo começa com uma conversa objetiva sobre o contexto: o produto, o gargalo e o risco. Depois disso eu proponho um recorte de entrega com prioridades e trade-offs claros, e a implementação segue com code review e critério de produção. Você acompanha o progresso em ciclos curtos, com documentação e handoff estruturado ao final de cada etapa.",
}, },
], ],
}, },
{ {
slug: "consultoria-nextjs", slug: "consultoria-nextjs",
navLabel: "Consultoria Next.js", navLabel: "Melhorias em sites e sistemas Next.js",
eyebrow: "Stack especializada", eyebrow: "Consultoria Next.js",
seoTitle: "Consultoria Next.js para Produtos, SEO e Performance", seoTitle: "Consultoria Next.js para Sites e Sistemas",
metaDescription: metaDescription:
"Consultoria Next.js para App Router, SEO técnico, performance, arquitetura e evolução de produto. Ideal para projetos que precisam escalar sem perder clareza e Core Web Vitals.", "Diagnóstico e melhorias em sites e sistemas Next.js: carregamento, navegação, SEO técnico e manutenção. Prioridades explicadas e implementação em escopo definido.",
headline: headline: "Seu projeto Next.js pode funcionar melhor.",
"Consultoria Next.js para produto, performance e SEO técnico sem gambiarra.",
answer: answer:
"A consultoria Next.js cobre App Router, separação entre server e client, metadata, performance e arquitetura, com diagnóstico e implementação limitados ao escopo combinado. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.",
intro: intro:
"Ajudo equipes a usar Next.js com mais critério arquitetural, melhor separação entre server e client, e foco real em performance, indexação e experiência de produto.", "Seu site ou sistema Next.js está lento, apresenta falhas ou ficou difícil de manter? Identifique a causa e organize as melhorias antes de investir em mais mudanças.",
audience: [ audience: [
"Produtos em Next.js que perderam clareza com o crescimento da base.", "Empresas com um site ou sistema em Next.js que não está atendendo bem seus usuários.",
"Sites e plataformas que dependem de SEO técnico e performance para converter.", "Equipes que precisam de apoio para investigar falhas, carregamento ou indexação.",
"Times que precisam migrar, estabilizar ou revisar arquitetura App Router.", "Responsáveis por um produto que precisam entender o que corrigir primeiro e por quê.",
], ],
outcomes: [ outcomes: [
"Estrutura mais consistente para App Router, Server Components e dados.", "Diagnóstico com problemas observados, impacto para o usuário e prioridades.",
"Melhoras práticas em title, metadata, schema, semântica e links internos.", "Plano de correção que distingue ajustes pontuais de mudanças maiores.",
"Redução de gargalos em renderização, bundle e fetch.", "Implementação ou acompanhamento do time, conforme a contratação; metas de performance dependem da medição e do contexto, sem promessa de ranking.",
"Base mais pronta para crescer sem regressão de SEO ou UX.", ],
examples: [
"Investigar por que uma página demora a abrir no celular.",
"Corrigir navegação e informações de páginas que não são interpretadas corretamente pelos buscadores.",
"Ajudar uma equipe a incluir novas funções sem tornar o sistema mais difícil de manter.",
], ],
process: [ process: [
"Auditoria de arquitetura, rotas, metadata e performance.", "Você apresenta os sintomas, o objetivo e os acessos necessários para a análise.",
"Priorização das correções de maior impacto comercial e técnico.", "Investigo o projeto e explico as causas encontradas e suas consequências.",
"Implementação ou acompanhamento do time interno nas partes críticas.", "Combinamos prioridades e o formato: diagnóstico, correções ou apoio ao time.",
"Validação final com build, semântica e consistência entre páginas.", "Reavaliamos os fluxos alterados e registramos as orientações de continuidade.",
], ],
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO técnico"], technicalDetails: [
"Revisão de App Router, fronteiras entre Server e Client Components, estratégias de renderização, cache e busca de dados.",
"Investigação de Core Web Vitals, tamanho do JavaScript, imagens e requisições; correções são comparadas com medições do cenário afetado.",
"Auditoria de metadados, canônicas, sitemap, dados estruturados e links internos, preservando URLs e requisitos de indexação nas mudanças.",
],
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Solicitar consultoria", ctaLabel: "Conversar sobre meu projeto Next.js",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"], relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
faq: [ faq: [
{ {
question: "Next.js vale a pena para site institucional?", question: "Não sei se meu site usa Next.js. Posso conversar?",
answer: answer:
"Vale, principalmente quando o site institucional precisa competir por busca orgânica. Next.js entrega renderização no servidor, Core Web Vitals bons e controle fino de metadata e dados estruturados, fatores que pesam no ranking. Para um site simples demais, pode ser mais stack do que o necessário; para quem quer performance, SEO técnico e caminho para evoluir a plataforma, é uma escolha sólida.", "Sim. Traga o endereço do site e o problema percebido. Identificamos o contexto e se essa consultoria é adequada antes de combinar uma análise mais profunda.",
}, },
{ {
question: "Quando faz sentido contratar uma consultoria de Next.js?", question: "A consultoria inclui corrigir o código?",
answer: answer:
"Faz sentido quando o projeto cresceu e o time perdeu clareza: rotas confusas, mistura excessiva entre client e server, queda de performance ou regressão de SEO. Uma consultoria Next.js audita arquitetura e metadata, prioriza as correções de maior impacto e implementa junto com o time interno nas partes críticas. O retorno aparece em build mais previsível, indexação estável e menos retrabalho.", "Pode incluir. Um diagnóstico pontual, a implementação das correções e o acompanhamento de um time são formatos diferentes. A proposta deixa claro quais deles entram e como a entrega será avaliada.",
}, },
{ {
question: "Quanto custa uma consultoria de Next.js?", question: "Precisa substituir o site inteiro?",
answer: answer:
"Depende do formato de trabalho: uma auditoria pontual de arquitetura e performance tem peso diferente de um acompanhamento contínuo ou da implementação completa das correções. O que define o investimento é o tamanho da base, o número de rotas críticas e a urgência dos gargalos. Depois de uma leitura inicial do projeto, eu consigo propor um escopo com expectativa clara de esforço.", "Não necessariamente. A análise busca a causa do problema e o menor conjunto de mudanças que atende ao objetivo. Uma migração só é recomendada quando os benefícios e os riscos justificam o trabalho.",
},
{
question: "Como funciona a consultoria remota?",
answer:
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota. A consultoria à distância combina auditoria documentada, sessões de alinhamento com o time e implementação guiada nas partes críticas do escopo.",
}, },
], ],
}, },
{ {
slug: "tauri-rust", slug: "tauri-rust",
navLabel: "Tauri + Rust", navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Desktop e performance", eyebrow: "Aplicativos desktop",
seoTitle: "Desenvolvimento Tauri e Rust para Apps Desktop", seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
metaDescription: metaDescription:
"Desenvolvimento com Tauri e Rust para apps desktop mais leves, seguros e eficientes. Consultoria técnica para produtos que precisam de performance, integração local e distribuição confiável.", "Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
headline: headline: "Aplicativos para Windows e Linux.",
"Tauri e Rust para apps desktop mais leves, seguros e prontos para operar.",
answer: answer:
"Desenvolvo e avalio aplicativos desktop com Tauri, Rust e interface web quando integração local, distribuição e controle de recursos justificam essa arquitetura. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são plataformas oferecidas; as versões, distribuições e funções compatíveis são definidas e validadas no escopo de cada projeto.",
intro: intro:
"Uso Tauri e Rust quando o produto precisa de executável desktop com footprint menor, melhor controle do ambiente local e mais previsibilidade do que stacks mais pesadas.", "Crie uma ferramenta para Windows ou Linux: organize arquivos, execute rotinas locais ou conecte o computador aos sistemas da empresa.",
audience: [ audience: [
"Produtos desktop que precisam integrar recursos do sistema operacional.", "Empresas que precisam de uma ferramenta instalada, em vez de depender apenas do navegador.",
"Ferramentas internas com demanda por distribuição leve e manutenção simples.", "Equipes que trabalham com arquivos, processamento local ou recursos do computador.",
"Times avaliando migração de stack desktop ou construindo um MVP técnico robusto.", "Produtos que precisam avaliar uma aplicação desktop nova ou a evolução de uma ferramenta existente.",
], ],
outcomes: [ outcomes: [
"Aplicações desktop com melhor eficiência de recursos.", "Uma interface para executar as tarefas locais prioritárias e entender o resultado de cada operação.",
"Fluxos locais e integrações nativas com critério técnico mais alto.", "Compatibilidade acordada para Windows e/ou Linux, incluindo versões, distribuições e dependências necessárias.",
"Arquitetura preparada para distribuição, atualização e manutenção.", "Instalação, atualizações, permissões e uso sem internet tratados no escopo; suportar uma plataforma não significa compatibilidade automática com todas as outras.",
"Menos atrito entre interface moderna e motor de execução confiável.", ],
examples: [
"Um aplicativo para conferir e organizar arquivos antes de enviá-los a outro sistema.",
"Uma ferramenta interna que executa uma rotina no computador e mostra seu andamento.",
"Uma interface local para acessar dados ou recursos que não estão disponíveis em um site comum.",
], ],
process: [ process: [
"Definição da necessidade real de desktop e do papel de Rust no produto.", "Entendemos a tarefa e confirmamos se um aplicativo instalado faz mais sentido que um sistema web.",
"Desenho da arquitetura entre frontend, camada nativa e distribuição.", "Definimos Windows e/ou Linux, ambientes de uso, acesso a arquivos e integrações necessárias.",
"Implementação dos fluxos críticos com foco em confiabilidade e ergonomia.", "Construímos e demonstramos os fluxos principais nos ambientes combinados.",
"Empacotamento, validação e estratégia de evolução do aplicativo.", "Validamos instalação e operação e entregamos as instruções e a estratégia de atualização acordadas.",
],
technicalDetails: [
"Tauri conecta uma interface web a comandos nativos em Rust. Permissões e acesso a arquivos devem ser limitados ao necessário, com validação na fronteira entre interface e sistema.",
"Empacotamento e dependências do WebView variam por sistema. Integrações nativas precisam ser verificadas por alvo; o mesmo código não elimina trabalho específico de Windows ou Linux.",
"O case GCC Installer abaixo é uma referência de automação desktop em Python/Tkinter e Win32 API no Windows. Não é uma implementação em Tauri/Rust nem comprovação de implantação em Linux.",
], ],
stack: [ stack: [
"Tauri", "Tauri",
"Rust", "Rust",
"TypeScript", "TypeScript",
"Desktop apps", "Desktop Apps",
"System integration", "System Integration",
], ],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto", ctaLabel: "Conversar sobre meu aplicativo desktop",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["gcc-installer"], relatedCaseIds: ["gcc-installer"],
faq: [ faq: [
{ {
question: "Vale a pena criar app desktop com Tauri e Rust?", question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
answer: answer:
"Vale quando o aplicativo precisa ser leve, seguro e próximo do sistema operacional. Apps desktop em Tauri usam o WebView do próprio sistema em vez de embutir um navegador inteiro, o que reduz bastante o tamanho do executável e o consumo de memória. Rust entra onde há processamento local crítico. Se o produto é só um formulário em janela, talvez uma web app resolva.", "Pode ser projetado para os dois, mas isso precisa fazer parte do escopo. Versões, distribuições Linux, recursos locais e dependências são definidos e testados separadamente. Não presumo que uma entrega para Windows já esteja pronta para Linux.",
}, },
{ {
question: "Tauri ou Electron: qual escolher?", question: "O aplicativo funciona sem internet?",
answer: answer:
"Prefira Tauri quando distribuição leve, segurança e consumo de recursos importam: o executável final é muito menor porque reaproveita o WebView do sistema. Electron ainda faz sentido quando o time já domina Node.js e precisa de ecossistema maduro. Em projetos novos que exigem integração nativa e desempenho, eu costumo recomendar Tauri com lógica crítica em Rust e interface web moderna.", "Funções locais podem ser projetadas para isso. Login remoto, sincronização e serviços externos podem continuar dependendo de conexão. Identificamos cada necessidade antes de prometer operação sem internet.",
}, },
{ {
question: "Rust é difícil de manter para times pequenos?", question: "Você já tem um case desktop público?",
answer: answer:
"Rust tem curva de aprendizado real, mas a manutenção tende a ficar mais fácil no longo prazo: o compilador elimina categorias inteiras de bugs antes da produção. Para times pequenos, a estratégia é usar Rust apenas na camada que justifica o investimento, como integração com o sistema, performance e segurança, e manter a interface em TypeScript. Assim a complexidade fica onde traz retorno.", "Sim: GCC Installer, uma ferramenta Windows feita em Python, Tkinter e Win32 API. Ela demonstra trabalho com rotinas locais e instalação, não uma entrega anterior em Tauri, Rust ou Linux.",
},
{
question: "Como funciona o atendimento para aplicativos desktop?",
answer:
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota. O fluxo cobre arquitetura, definição do papel de Rust, implementação dos fluxos críticos e estratégia de distribuição e atualização do executável.",
}, },
], ],
}, },
{ {
slug: "integracao-hardware-software-iot", slug: "integracao-hardware-software-iot",
navLabel: "Hardware + Software + IoT", navLabel: "Integração de equipamentos e sistemas",
eyebrow: "Produtos conectados", eyebrow: "Hardware, software e IoT",
seoTitle: "Integração Hardware + Software e IoT", seoTitle: "Integração de Equipamentos, Sistemas e IoT",
metaDescription: metaDescription:
"Integração hardware + software, IoT e fluxos conectados para produtos e operações críticas. Da coleta de dados ao painel, com APIs, telemetria e arquitetura de software confiável.", "Integração entre equipamentos e software para consultar dados e acompanhar operações. Viabilidade, interfaces e tratamento de falhas definidos por projeto.",
headline: headline: "Dados dos equipamentos no seu sistema.",
"Integração hardware + software para produtos conectados que precisam operar de verdade.",
answer: answer:
"Estruturo integrações entre dispositivos, backend, banco e interfaces, com contratos, tratamento de falhas e documentação definidos para o fluxo necessário. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro avaliamos o que cada dispositivo permite acessar; a integração depende dessas interfaces e dos requisitos de segurança e funcionamento.",
intro: intro:
"Atuo na camada que costuma quebrar primeiro: a fronteira entre dispositivo, backend, painel e operação. Estruturo esses fluxos para que dados, comando e observabilidade funcionem como produto, não como remendo.", "Leve informações de equipamentos para um sistema ou painel. Acompanhe leituras e eventos sem depender apenas de registros manuais.",
audience: [ audience: [
"Empresas com dispositivos, sensores ou equipamentos conectados ao software.", "Operações que registram manualmente informações disponíveis em sensores ou equipamentos.",
"Operações que dependem de telemetria, dashboards e troca de dados em tempo real.", "Empresas que precisam reunir dados físicos e informações de seus sistemas.",
"Times que precisam ligar produto digital a processos físicos com confiabilidade.", "Equipes que querem avaliar a viabilidade de um produto conectado antes de ampliar o projeto.",
], ],
outcomes: [ outcomes: [
"Fluxos mais robustos entre dispositivo, API, banco e interface.", "Um caminho definido entre a informação do equipamento e a tela de quem precisa usá-la.",
"Menos perda de contexto entre operação física e produto digital.", "Tratamento combinado para falta de conexão, leituras inválidas e outras falhas relevantes.",
"Arquitetura com observabilidade e manutenção mais previsível.", "Escopo condicionado a protocolos, documentação e acesso ao equipamento; compra de hardware, instalação física e certificações não são presumidas.",
"Base melhor para evoluir IA, automação ou analytics em cima dos dados.", ],
examples: [
"Um painel para consultar leituras de sensores compatíveis.",
"Um registro de eventos de uma máquina para apoiar o acompanhamento da operação.",
"Uma prova de conceito para verificar se um equipamento consegue trocar dados com o sistema existente.",
], ],
process: [ process: [
"Mapeamento de sinal, eventos, integrações e pontos de falha.", "Mapeamos equipamentos, dados disponíveis e a decisão que essas informações precisam apoiar.",
"Definição de contratos entre hardware, backend e interfaces.", "Verificamos documentação, formas de conexão e limites do ambiente.",
"Implementação do fluxo com testes e tratamento de exceções relevantes.", "Implementamos um fluxo delimitado e avaliamos as falhas relevantes com os acessos combinados.",
"Entrega com documentação de operação, limites e próximo passo.", "Entregamos instruções de operação, limites conhecidos e próximos passos.",
],
technicalDetails: [
"A descoberta identifica APIs, arquivos, portas seriais ou protocolos expostos pelo equipamento; a escolha depende da interface realmente disponível.",
"Contratos de dados, validação, persistência, reconexão e rastreamento de falhas organizam o fluxo entre dispositivo, backend e painel. WebSockets entram apenas quando há necessidade de atualização contínua.",
"Twins Lab é um simulador/protótipo documentado. Ele ilustra uma interface para processo físico, mas não comprova integração com equipamento real, WebSockets ou resultado industrial.",
], ],
stack: [ stack: [
"IoT", "IoT",
@@ -353,61 +375,64 @@ export const servicePages: ServicePageContent[] = [
"Integração de sistemas", "Integração de sistemas",
], ],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto", ctaLabel: "Avaliar minha integração",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["twins-lab"], relatedCaseIds: ["twins-lab"],
faq: [ faq: [
{ {
question: "Como integrar equipamentos e sistemas legados?", question: "Qualquer equipamento pode ser conectado?",
answer: answer:
"Comece mapeando o que o equipamento realmente expõe: portas seriais, sinais digitais, arquivos, protocolos industriais ou APIs antigas. A partir desse inventário, eu desenho uma camada de integração com contratos claros entre hardware, backend e interfaces, com tratamento de falha e observabilidade desde o primeiro dia. Sistemas legados raramente precisam ser substituídos; quase sempre precisam ser conectados com segurança.", "Não é possível garantir sem avaliar. Precisamos saber quais dados e interfaces ele disponibiliza, se há documentação e quais restrições existem no ambiente. Essa avaliação pode concluir que a integração não é viável no escopo pretendido.",
}, },
{ {
question: "O que é integração hardware e software na prática?", question: "É necessário trocar os sistemas existentes?",
answer: answer:
"É fazer o dado físico virar informação útil: o sensor registra, o backend valida e armazena, o painel mostra e o comando volta para o equipamento quando necessário. Na prática, isso envolve telemetria, WebSockets ou filas para tempo real, contratos de API e tratamento dos casos em que o dispositivo cai. Quando essa fronteira é bem desenhada, a operação física e o produto digital falam a mesma língua.", "Nem sempre. Podemos avaliar uma camada de integração que aproveita o que já funciona. O caminho depende das interfaces disponíveis, da confiabilidade exigida e dos riscos para a operação.",
}, },
{ {
question: "Vale a pena investir em IoT na indústria?", question: "É possível começar pequeno?",
answer: answer:
"Vale quando existe dado físico hoje desperdiçado: leitura manual, planilha isolada ou decisão por estimativa. Projetos de IoT podem transformar esse dado em telemetria e painéis, mas o investimento só compensa quando a integração é tratada como produto, com observabilidade e manutenção previstas.", "Sim. Um equipamento, uma leitura ou um fluxo pode servir para avaliar viabilidade antes de ampliar o projeto. Os critérios dessa avaliação são combinados para não confundir um protótipo com uma solução pronta para toda a operação.",
},
{
question: "Como funciona um projeto de integração com você?",
answer:
"O projeto começa com o mapeamento de sinal, eventos, integrações existentes e pontos de falha. Em seguida eu defino os contratos entre hardware, backend e interfaces, implemento o fluxo com testes e tratamento das exceções relevantes, e entrego com documentação de operação, limites conhecidos e próximo passo recomendado. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.",
}, },
], ],
}, },
{ {
slug: "arquitetura-de-software", slug: "arquitetura-de-software",
navLabel: "Arquitetura de Software", navLabel: "Diagnóstico e arquitetura de software",
eyebrow: "Diagnóstico e decisão", eyebrow: "Diagnóstico e decisão",
seoTitle: "Arquitetura de Software e Consultoria Técnica", seoTitle: "Diagnóstico e Arquitetura de Software",
metaDescription: metaDescription:
"Arquitetura de software para reduzir complexidade, destravar evolução e organizar integrações críticas. Consultoria técnica para produtos que precisam de direção antes de mais código.", "Diagnóstico de software para entender falhas, organizar prioridades e planejar evolução. Arquitetura com riscos, alternativas e plano de mudança explicado.",
headline: headline: "Entenda o problema antes de mudar tudo.",
"Arquitetura de software para times que precisam de clareza antes de acelerar.",
answer: answer:
"Realizo diagnóstico de arquitetura com leitura da base, fluxos e integrações, seguido por riscos, trade-offs e um plano de transição executável para o escopo analisado. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.", "Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.",
intro: intro:
"Quando o problema não é falta de código, mas excesso de acoplamento, retrabalho e decisão mal distribuída, a arquitetura certa devolve velocidade, leitura e confiança ao time.", "Quando cada mudança no sistema vira um problema, obtenha um diagnóstico e um plano para evoluir sem decidir no escuro ou reescrever tudo por impulso.",
audience: [ audience: [
"Produtos que cresceram rápido e perderam legibilidade técnica.", "Empresas que dependem de um sistema e não conseguem entender o custo ou o risco das mudanças.",
"Times travados entre refatorar tudo ou seguir acumulando dívida.", "Equipes que enfrentam falhas recorrentes, integrações frágeis ou dificuldade para entregar novas funções.",
"Operações com muitas dependências, integrações ou regras sensíveis.", "Responsáveis por um produto que precisam comparar caminhos antes de investir numa migração.",
], ],
outcomes: [ outcomes: [
"Mapa claro dos gargalos e do risco técnico real.", "Um mapa dos problemas encontrados e de como afetam o produto e a operação.",
"Prioridades de arquitetura ligadas a impacto de negócio.", "Alternativas com benefícios, limitações e riscos explicados para apoiar a decisão.",
"Plano executável de refatoração, estabilização ou reestruturação.", "Plano de execução por prioridades; implementar as mudanças ou acompanhar o time depende do formato contratado.",
"Mais qualidade de decisão entre produto, engenharia e operação.", ],
examples: [
"Investigar por que alterar uma função provoca falhas em outras partes do sistema.",
"Comparar manter, modernizar aos poucos ou substituir uma ferramenta antiga.",
"Organizar um plano para conectar novos sistemas sem aumentar a confusão da operação.",
], ],
process: [ process: [
"Leitura da base, fluxos, integrações e dores do time.", "Conversamos com quem usa e mantém o sistema e reunimos os acessos e materiais necessários.",
"Diagnóstico escrito com pontos de risco, oportunidades e trade-offs.", "Analiso a base e os fluxos relevantes para o problema, sem ampliar a investigação sem motivo.",
"Proposta de arquitetura alvo e plano de transição realista.", "Apresento o diagnóstico e comparo as alternativas com o time e os responsáveis pelo negócio.",
"Acompanhamento da execução ou handoff estruturado para o time interno.", "Entrego o plano priorizado e, se combinado, acompanho sua execução.",
],
technicalDetails: [
"Análise de dependências, fronteiras entre módulos, contratos de API, fluxos de dados e pontos de falha para localizar acoplamento e riscos.",
"A arquitetura proposta considera carga, consistência de dados, operação, segurança e capacidade real do time, evitando distribuir um sistema sem necessidade.",
"Migrações são divididas em etapas verificáveis, com estratégia de transição e reversão quando aplicável. As decisões registram os trade-offs, não apenas um diagrama final.",
], ],
stack: [ stack: [
"Arquitetura de software", "Arquitetura de software",
@@ -417,29 +442,24 @@ export const servicePages: ServicePageContent[] = [
"Sistemas críticos", "Sistemas críticos",
], ],
ctaIntent: "consulting", ctaIntent: "consulting",
ctaLabel: "Solicitar diagnóstico", ctaLabel: "Conversar sobre um diagnóstico",
ctaHref: "/#contact", ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"], relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
faq: [ faq: [
{ {
question: "Quando investir em arquitetura de software?", question: "Preciso ter uma equipe técnica para contratar?",
answer: answer:
"Invista quando o custo de mudança começou a crescer mais rápido que o valor entregue: toda feature demora, todo deploy assusta e a dívida técnica vira pauta constante. Arquitetura de software vale o investimento nesse ponto, porque organiza acoplamento, integrações e responsabilidades antes que o problema multiplique. Antes do primeiro produto validado, geralmente basta uma base simples e bem separada.", "Não, mas a análise precisa de acesso ao sistema e às pessoas ou materiais que expliquem seu funcionamento. O resultado é apresentado com consequências para o negócio e detalhes que uma equipe técnica possa usar.",
}, },
{ {
question: "O que é dívida técnica e como resolver?", question: "O diagnóstico sempre recomenda reescrever o sistema?",
answer: answer:
"Dívida técnica é o acúmulo de decisões rápidas que hoje custam velocidade: código duplicado, acoplamento excessivo, falta de testes e integrações frágeis. Ela se resolve com diagnóstico antes de refatoração: mapear os gargalos reais, priorizar o que trava entrega e operação, e evoluir em incrementos seguros. Reescrever tudo raramente é a resposta; a maioria dos sistemas melhora com transição planejada.", "Não. Preservar o que funciona e mudar pontos específicos pode ser o caminho mais adequado. As alternativas são comparadas com base nos problemas observados, nos riscos e na capacidade de executar a mudança.",
}, },
{ {
question: "Como funciona um diagnóstico de arquitetura?", question: "O que é entregue ao final?",
answer: answer:
"Eu leio a base de código, os fluxos, as integrações e as dores do time, e devolvo um diagnóstico escrito com pontos de risco, oportunidades e trade-offs explícitos. A partir dele vem a proposta de arquitetura alvo e um plano de transição realista, priorizado por impacto de negócio. O formato funciona tanto para times internos quanto para fundadores técnicos conduzindo a própria evolução.", "Um diagnóstico documentado com problemas, prioridades, alternativas e plano de mudança para o escopo analisado. Profundidade, reuniões e eventual acompanhamento da implementação são definidos na proposta.",
},
{
question: "Como funciona a consultoria de arquitetura remota?",
answer:
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota. O diagnóstico e o acompanhamento funcionam à distância com leitura técnica, alinhamentos e handoff documentado para o time responsável.",
}, },
], ],
}, },
@@ -699,7 +719,7 @@ export const caseStudies: CaseStudyContent[] = [
export const serviceHubLinks = servicePages.map((service) => ({ export const serviceHubLinks = servicePages.map((service) => ({
href: `/servicos/${service.slug}`, href: `/servicos/${service.slug}`,
label: service.navLabel, label: service.navLabel,
description: service.metaDescription, description: service.intro,
})); }));
export function getServicePage(slug: string) { export function getServicePage(slug: string) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB