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/gcc-installer` | Case MinGW GCC Installer. |
| `/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/engenharia-de-software` | Engenharia de software. |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
| `/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/arquitetura-de-software` | Arquitetura de software. |
| `/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.
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
### 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
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
+49 -21
View File
@@ -1,5 +1,6 @@
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";
jest.mock("motion/react", () => {
@@ -26,6 +27,21 @@ jest.mock("motion/react", () => {
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", () => {
it("exposes the primary navigation and the existing CV asset", () => {
render(<Header />);
@@ -50,9 +66,14 @@ describe("Header", () => {
const cvLink = screen.getByRole("link", { name: "Download CV" });
expect(cvLink).toHaveAttribute("href", "/Profile.pdf");
expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
expect(
screen.getByRole("navigation", { name: "Navegação principal" }),
).toHaveClass("site-header-nav");
expect(screen.getByRole("link", { name: "Sobre" })).toHaveAttribute(
"href",
"/#about",
);
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
"href",
"/#contact",
);
});
it("announces and controls the mobile menu", () => {
@@ -61,17 +82,12 @@ describe("Header", () => {
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
expect(menuButton).not.toHaveAttribute("aria-controls");
expect(menuButton).toHaveAttribute("aria-expanded", "false");
expect(menuButton).toHaveClass("site-header-menu-toggle");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
fireEvent.click(menuButton);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-controls",
"site-navigation-menu",
);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
@@ -79,29 +95,41 @@ describe("Header", () => {
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
expect(mobileNavigation).toHaveAttribute("id", "site-navigation-menu");
expect(mobileNavigation).toHaveAttribute("aria-hidden", "false");
expect(
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
const closeButton = screen.getByRole("button", { name: "Fechar menu" });
fireEvent.keyDown(closeButton, { key: "Escape" });
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
mobileLink.focus();
fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton);
});
it("returns focus after closing the menu from a mobile link", () => {
render(<Header />);
it("closes the mobile menu without taking focus away from a home destination", () => {
window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
fireEvent.click(
within(mobileNavigation).getByRole("link", { name: "Home" }),
);
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
mobileLink.focus();
fireEvent.click(mobileLink);
const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton);
expect(screen.getByRole("button", { name: "Abrir menu" })).toHaveAttribute(
"aria-expanded",
"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 menuButtonRef = useRef<HTMLButtonElement>(null);
const closeMobileMenu = () => {
setMobileMenuOpen(false);
menuButtonRef.current?.focus();
};
const closeMobileMenu = () => setMobileMenuOpen(false);
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMobileMenu();
menuButtonRef.current?.focus();
};
return (
@@ -119,7 +117,10 @@ export function Header() {
href={CV_URL}
download={CV_DOWNLOAD_NAME}
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}
>
Download CV
+47 -154
View File
@@ -1,166 +1,59 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { act } from "@testing-library/react";
import { useReducedMotion } from "motion/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import Hero from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = () => <div data-testid="hero-scene" />;
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
jest.mock("motion/react", () => {
const React = require("react");
const MotionStateContext = React.createContext({
initial: undefined,
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(),
})),
jest.mock("motion/react", () => ({
...jest.requireActual("motion/react"),
useReducedMotion: jest.fn(),
}));
jest.mock("next/dynamic", () => () => () => null);
jest.mock("./cinematic/ControlRoomGlobe", () => ({
ControlRoomGlobe: () => null,
}));
jest.mock("./cinematic/HeroStarfield", () => ({
HeroStarfield: () => null,
}));
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches,
media: query,
afterEach(() => {
jest.restoreAllMocks();
});
it.each([false, true])(
"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,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: 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", () => {
beforeEach(() => {
mockMatchMedia(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("shows active copy and actions immediately without animation timers", () => {
render(<Hero />);
const visibleContent = [
screen.getByText(/Disponível para novas oportunidades/i),
screen.getByRole("heading", {
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();
preference.mockReturnValue(reducedMotion);
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
const recoverableErrors: unknown[] = [];
let root: Root | undefined;
try {
await act(async () => {
root = hydrateRoot(container, <Hero />, {
onRecoverableError: (error) => recoverableErrors.push(error),
});
});
expect(recoverableErrors).toEqual([]);
expect(consoleErrors.mock.calls).toEqual([]);
} finally {
await act(async () => root?.unmount());
container.remove();
}
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
});
},
);
+3 -2
View File
@@ -1,8 +1,9 @@
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { motion, type Variants } from "motion/react";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { CV_URL } from "@/lib/site-navigation";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
@@ -89,7 +90,7 @@ function getGreeting(message: string) {
export default function Hero() {
const heroRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion() ?? true;
const shouldReduceMotion = useReducedMotion();
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
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";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { Section } from "./Section";
import { useState } from "react";
const METRICS = [
{
score: 100,
id: "performance",
label: "Performance",
code: "LCP / INP / CLS",
title: "O essencial chega primeiro.",
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",
code: "TECLADO / FOCO / SEMÂNTICA",
title: "A interface inclui mais pessoas.",
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,
label: "Best Practices",
id: "practices",
label: "Boas práticas",
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
title: "Confiança também se constrói.",
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",
code: "HTML / METADATA / INDEXAÇÃO",
title: "Conteúdo pronto para ser encontrado.",
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() {
const shouldReduceMotion = useReducedMotion();
const [active, setActive] = useState(METRICS[0]);
return (
<Section
<section
id="lighthouse"
eyebrow="Quality & Performance"
title="Engenharia de ponta a ponta: 100/100 não é vaidade."
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."
dark
disableChildrenAnimation={false}
className="ds-section ds-section-dark quality-lab"
aria-labelledby="quality-title"
data-inspection={active.id}
>
<div
className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4"
data-reel="fade"
>
{METRICS.map((metric, i) => (
<motion.article
key={metric.label}
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
initial={
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }
}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: i * 0.1 }}
>
{/* The Ring Container */}
<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">
<svg
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>
<div className="ds-section-shell">
<div className="quality-intro">
<header className="quality-copy">
<p className="ds-eyebrow">Quality &amp; Performance</p>
<h2 id="quality-title" className="ds-section-title">
Engenharia de ponta a ponta.
<span>Qualidade em cada camada.</span>
</h2>
<p>
Sistemas precisam mais do que uma boa aparência. Explore as
decisões que tornam uma interface rápida, acessível, confiável e
fácil de encontrar.
</p>
<a
className="ds-link ds-link-accent"
href="/servicos/consultoria-nextjs"
>
Conversar sobre a qualidade do seu projeto
</a>
</header>
{/* Content Details */}
<div className="flex-1" data-reel-stagger="bottom">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
{metric.label}
</h3>
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
{metric.description}
</p>
<figure className="quality-visual bg-grid-light">
<div className="quality-visual-label">
<span>Laboratório de qualidade</span>
<span>Modelo interativo</span>
</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>
</Section>
</section>
);
}
+13 -12
View File
@@ -199,22 +199,23 @@ export function WorkModalities() {
return (
<Section
id="modalities"
eyebrow="Working models"
title="Serviços de engenharia de software e consultoria técnica."
description="Formatos de contratação para arquitetura de software, integrações críticas, produtos conectados e evolução de sistemas em produção."
eyebrow="Serviços e contratação"
title="Sites, sistemas e aplicativos para o seu negócio."
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
>
<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="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">
Especialidades organizadas por intenção de busca
Uma solução para a sua rotina, não uma lista de tecnologias.
</h3>
<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
contexto técnico específico, aqui está o caminho mais direto para
a página certa.
Apresentar sua empresa na internet, organizar a operação, criar um
aplicativo ou conectar ferramentas. Veja exemplos, etapas e
limites de cada serviço; os detalhes técnicos estão disponíveis
nas páginas para quem quiser aprofundar.
</p>
</div>
<MagneticButton
@@ -223,7 +224,7 @@ export function WorkModalities() {
className="ds-link ds-link-accent"
strength={0.2}
>
Ver hub de serviços
Conhecer todos os serviços
</MagneticButton>
</div>
@@ -235,12 +236,12 @@ export function WorkModalities() {
<Link
key={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}
</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}
</div>
</Link>
+94 -52
View File
@@ -1,61 +1,103 @@
import { render, screen } from "@testing-library/react";
import CinematicHome from "./CinematicHome";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
jest.mock("next/dynamic", () => {
const labels = [
"About",
"Skills",
"Experience",
"WorkModalities",
"Contact",
"LighthouseMetrics",
"Footer",
"ScrollVelocityText",
"SectionDivider",
];
let index = 0;
function HomeNavigation() {
const onLayoutReady = useHomeFragmentNavigation();
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish notebook layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
<section id="about" aria-label="Sobre">
<button type="button">Read biography</button>
</section>
<section id="contact" aria-label="Contato">
<button type="button">Send message</button>
</section>
</>
);
}
return () => {
const label = labels[index++] ?? "DynamicSection";
return function DynamicMock() {
return <section data-testid={`home-section-${label}`}>{label}</section>;
};
};
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
window.history.replaceState(null, "", "/");
HTMLElement.prototype.scrollIntoView = jest.fn();
});
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
jest.mock("../Projects", () => () => (
<section data-testid="home-section-Projects" />
));
jest.mock("../story/NotebookStory", () => () => (
<section data-testid="home-section-NotebookStory" />
));
afterEach(() => {
window.history.replaceState(null, "", "/");
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});
describe("CinematicHome", () => {
it("renders each primary section directly in the published order", () => {
const { container } = render(
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
);
const main = container.querySelector("main");
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") ?? ""),
);
describe("home fragment navigation", () => {
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
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(
directSections.map((section) => section.getAttribute("data-testid")),
).toEqual(sectionIds);
screen.getByRole("button", { name: "Read biography" }),
).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";
import dynamic from "next/dynamic";
import type {
PortfolioExperience,
PortfolioProjectView,
PortfolioSkillCategory,
} 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 LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import Skills from "../Skills";
import NotebookStory from "../story/NotebookStory";
const About = dynamic(() => import("../About"), {
loading: () => <SectionLoading message="Carregando..." />,
});
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,
});
import { WorkModalities } from "../WorkModalities";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
export default function CinematicHome({
projects,
@@ -48,6 +28,8 @@ export default function CinematicHome({
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
const onLayoutReady = useHomeFragmentNavigation();
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
@@ -58,7 +40,7 @@ export default function CinematicHome({
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
</div>
<NotebookStory projects={projects} />
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<About />
@@ -17,14 +17,11 @@ export default function ScrollVelocityText({
}: ScrollVelocityTextProps) {
const trackId = useId();
const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
setShouldReduceMotion(media.matches);
const updatePreference = (event: MediaQueryListEvent) => {
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 { getFeaturedProjects } from "@/lib/portfolio-data";
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", () => {
it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 });
@@ -42,12 +51,12 @@ describe("NotebookBrowser", () => {
"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();
expect(
view.container.querySelector(".notebook-screenshot-preload"),
).toBeInTheDocument();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
@@ -57,8 +66,91 @@ describe("NotebookBrowser", () => {
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(
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();
});
it("moves between tabs with arrow keys and Home/End", async () => {
@@ -172,35 +264,11 @@ describe("NotebookBrowser", () => {
).not.toBeInTheDocument();
});
it("keeps the animated cursor decorative and never embeds an iframe", () => {
const { container } = renderBrowser({
frame: { ...frame, cursorClicking: true },
it("uses the selected tab to label the panel and decoded screenshot", async () => {
const view = renderBrowser({ activeIndex: 2 });
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 panel = screen.getByRole("tabpanel");
@@ -209,6 +277,9 @@ describe("NotebookBrowser", () => {
expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
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";
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 { NotebookFrame } from "./notebookTimeline";
type CursorFrame = Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
export type NotebookBrowserHandle = {
updateFrame: (frame: CursorFrame) => void;
};
export type NotebookBrowserProps = {
projects: NotebookProject[];
activeIndex: number;
frame: Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
frame: CursorFrame;
ref?: Ref<NotebookBrowserHandle>;
onSelectTab: (index: number) => void;
interactive?: boolean;
};
@@ -31,28 +45,167 @@ export default function NotebookBrowser({
frame,
onSelectTab,
interactive = true,
ref,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const previousIndexRef = useRef(selectedIndex);
const [outgoingIndex, setOutgoingIndex] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(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(() => {
const previousIndex = previousIndexRef.current;
if (previousIndex === selectedIndex) return undefined;
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
previousIndexRef.current = selectedIndex;
setOutgoingIndex(previousIndex >= 0 ? previousIndex : null);
const timeout = window.setTimeout(() => setOutgoingIndex(null), 180);
return () => window.clearTimeout(timeout);
}, [selectedIndex]);
// Only the immediate neighbours are fetched; the browser cache handles reuse.
useEffect(() => {
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
.filter((project): project is NotebookProject => Boolean(project))
.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 =
outgoingIndex !== null ? projects[outgoingIndex] : undefined;
const nextProject =
selectedIndex >= 0 && projects.length > 1
? projects[(selectedIndex + 1) % projects.length]
: undefined;
const updateFrame = useCallback((next: CursorFrame) => {
cursorFrame.current = next;
const cursor = cursorRef.current;
const points = tabPoints.current;
if (!cursor || points.length === 0) return;
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
? {}
: {
@@ -81,7 +234,7 @@ export default function NotebookBrowser({
event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus();
[nextIndex]?.focus({ preventScroll: true });
onSelectTab(nextIndex);
}
@@ -106,6 +259,7 @@ export default function NotebookBrowser({
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
ref={rootRef}
>
<div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" />
@@ -142,9 +296,6 @@ export default function NotebookBrowser({
aria-controls={panelId}
aria-selected={isSelected}
className="notebook-tab"
data-cursor-clicking={
isSelected && frame.cursorClicking ? "true" : "false"
}
id={tabId}
key={project.id}
onClick={() => onSelectTab(index)}
@@ -198,51 +349,79 @@ export default function NotebookBrowser({
)}
</div>
<div className="notebook-screenshot-wrap">
{outgoingProject ? (
<Image
alt=""
aria-hidden="true"
className="notebook-screenshot notebook-screenshot-outgoing"
fill
sizes="(min-width: 681px) 60vw, 100vw"
src={outgoingProject.screenshotSrc}
unoptimized
/>
) : null}
<Image
alt={`Captura de tela de ${selectedProject.title}`}
className="notebook-screenshot notebook-screenshot-current"
fill
sizes="(min-width: 681px) 60vw, 100vw"
src={selectedProject.screenshotSrc}
unoptimized
/>
{nextProject ? (
<Image
alt=""
aria-hidden="true"
className="notebook-screenshot-preload"
fill
sizes="1px"
src={nextProject.screenshotSrc}
unoptimized
/>
<div
className="notebook-screenshot-wrap"
aria-busy={waiting && failedSrc !== requestedSrc}
>
{imageProjects.map((project) => {
const current =
project.screenshotSrc === displayed?.screenshotSrc;
const previous =
project.screenshotSrc === outgoing?.screenshotSrc;
return (
<Image
key={project.screenshotSrc}
alt={current ? `Captura de tela de ${project.title}` : ""}
aria-hidden={!current}
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
width={project.screenshotWidth}
height={project.screenshotHeight}
loading="eager"
sizes="(min-width: 1025px) 800px, 100vw"
src={project.screenshotSrc}
unoptimized
onLoad={(event) => {
void revealImage(event.currentTarget, project);
}}
onError={() => {
if (project.screenshotSrc === requestRef.current)
setFailedSrc(project.screenshotSrc);
}}
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}
</div>
</div>
</div>
{frame.cursorVisible ? (
<span
aria-hidden="true"
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`}
data-testid="notebook-cursor"
style={{
left: `${frame.cursorX * 100}%`,
top: `${frame.cursorY * 100}%`,
}}
<svg
aria-hidden="true"
className="notebook-cursor"
ref={cursorRef}
viewBox="0 0 28 36"
width="28"
height="36"
>
<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>
);
}
+6 -1
View File
@@ -7,6 +7,7 @@ import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = {
projects: NotebookProject[];
initialIndex?: number;
onSelectTab?: (index: number) => void;
};
function clampIndex(index: number, length: number): number {
@@ -17,6 +18,7 @@ function clampIndex(index: number, length: number): number {
export default function NotebookFallback({
projects,
initialIndex = 0,
onSelectTab,
}: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length),
@@ -39,7 +41,10 @@ export default function NotebookFallback({
cursorX: 0,
cursorY: 0,
}}
onSelectTab={setActiveIndex}
onSelectTab={(index) => {
setActiveIndex(index);
onSelectTab?.(index);
}}
projects={projects}
/>
</div>
+46 -20
View File
@@ -4,6 +4,7 @@ import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
updateNotebookScreenMatrix,
} from "./NotebookStage3D";
import { selectNotebookProjects } from "./notebookProjects";
@@ -36,6 +37,7 @@ const props: NotebookStage3DProps = {
isVisible: true,
onSelectTab: jest.fn(),
progress: { current: 0.56 },
requestRender: { current: null },
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", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
@@ -66,26 +112,6 @@ describe("NotebookStage3D", () => {
afterEach(() => {
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", () => {
const onStageError = jest.fn();
+132 -62
View File
@@ -1,18 +1,18 @@
"use client";
import { Html, RoundedBox } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import * as THREE from "three";
import NotebookBrowser from "./NotebookBrowser";
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
import NotebookFallback from "./NotebookFallback";
import type { NotebookProject } from "./notebookProjects";
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
@@ -21,6 +21,7 @@ type ProgressRef = { current: number };
export type NotebookStage3DProps = {
progress: ProgressRef;
requestRender: { current: (() => void) | null };
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
@@ -28,7 +29,7 @@ export type NotebookStage3DProps = {
onStageError?: () => void;
};
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
type NotebookSceneProps = NotebookStage3DProps;
/**
* 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;
}
// The laptop is built procedurally in world units and the rig stays at scale
// 1 so the DOM overlay (drei Html transform) aligns with the physical screen.
// The laptop and its DOM screen share the same world-space geometry.
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
const CAMERA_POSITION: [number, number, number] = [0, 2, 7.4];
const CAMERA_ROTATION: [number, number, number] = [-0.2, 0, 0];
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
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 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 = {
chassis: THREE.MeshStandardMaterial;
@@ -111,9 +159,11 @@ function createLaptopMaterials(): LaptopMaterials {
}
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
for (const material of Object.values(materials)) {
material.opacity = opacity;
}
materials.chassis.opacity = opacity;
materials.keys.opacity = opacity;
materials.dark.opacity = opacity;
materials.accent.opacity = opacity;
materials.screen.opacity = opacity;
}
function disposeLaptopMaterials(materials: LaptopMaterials) {
@@ -217,48 +267,71 @@ function LaptopLid({ materials }: { materials: LaptopMaterials }) {
radius={0.02}
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>
);
}
function ScreenSurface({
frame,
material,
projects,
activeIndex,
onSelectTab,
}: {
frame: NotebookFrame;
frame: RefObject<NotebookFrame>;
material: THREE.MeshStandardMaterial;
projects: NotebookProject[];
activeIndex: number;
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 (
<Html
center
distanceFactor={SCREEN_DOM_DISTANCE_FACTOR}
position={[0, -0.06, LID.depth / 2]}
zIndexRange={[20, 0]}
<group
ref={screen}
position={[0, -0.028, LID.depth / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<div
className="notebook-screen-surface"
style={{ opacity: frame.screenOpacity }}
>
<NotebookBrowser
activeIndex={activeIndex}
frame={frame}
interactive={frame.screenOpacity > 0.05}
onSelectTab={onSelectTab}
projects={projects}
/>
</div>
</Html>
<mesh material={material}>
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
</mesh>
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
<div
ref={surface}
className="notebook-screen-surface"
style={{
width: SCREEN_DOM_WIDTH,
height: SCREEN_DOM_HEIGHT,
opacity: frame.current.screenOpacity,
}}
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,
progress,
projects,
requestRender,
isVisible,
}: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null);
const smoothProgress = useRef(progress.current);
const elapsedForDom = useRef(0);
const [frame, setFrame] = useState(() =>
getNotebookFrame(progress.current, projects.length),
);
const frame = useRef(getNotebookFrame(progress.current, projects.length));
const materials = useMemo(createLaptopMaterials, []);
const invalidate = useThree((state) => state.invalidate);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
requestRender.current = isVisible ? invalidate : null;
if (isVisible) invalidate();
return () => {
requestRender.current = null;
};
}, [invalidate, isVisible, requestRender]);
useFrame((_, delta) => {
smoothProgress.current +=
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length);
useFrame(() => {
const nextFrame = getNotebookFrame(
progress.current,
projects.length,
frame.current,
);
if (lidRig.current) {
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
}
setLaptopOpacity(materials, nextFrame.deviceOpacity);
elapsedForDom.current += delta;
if (elapsedForDom.current >= 1 / 24) {
elapsedForDom.current = 0;
setFrame(nextFrame);
}
});
}, -1);
return (
<group position={RIG_POSITION}>
@@ -307,6 +381,7 @@ function NotebookScene({
<ScreenSurface
activeIndex={activeIndex}
frame={frame}
material={materials.screen}
onSelectTab={onSelectTab}
projects={projects}
/>
@@ -348,19 +423,14 @@ class NotebookStageBoundary extends Component<
}}
className="notebook-story-canvas"
dpr={[1, 1.5]}
frameloop={this.props.isVisible ? "always" : "never"}
frameloop={this.props.isVisible ? "demand" : "never"}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
>
<NotebookScene
activeIndex={this.props.activeIndex}
onSelectTab={this.props.onSelectTab}
progress={this.props.progress}
projects={this.props.projects}
/>
<NotebookScene {...this.props} />
</Canvas>
);
}
+2 -52
View File
@@ -1,10 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, {
canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress,
} from "./NotebookStory";
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
@@ -135,15 +131,10 @@ describe("NotebookStory", () => {
await waitFor(() =>
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 () => {
const addEventListener = jest.spyOn(document, "addEventListener");
const removeEventListener = jest.spyOn(document, "removeEventListener");
const { unmount } = render(<NotebookStory projects={projects} />);
render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false");
@@ -163,18 +154,6 @@ describe("NotebookStory", () => {
setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
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 () => {
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", () => {
const matchMedia = (query: string) =>
({
@@ -245,27 +218,4 @@ describe("NotebookStory", () => {
screen.queryByRole("tab", { name: /Twins Lab/i }),
).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 NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame } from "./notebookTimeline";
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
const BROWSE_START = 0.44;
const BROWSE_END = 0.78;
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
onLayoutReady?: () => void;
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
@@ -36,18 +34,7 @@ export function getNotebookRunwayVh(tabCount: number): number {
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 360 + count * 40;
}
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);
return 220 + count * 85;
}
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);
}
export default function NotebookStory({ projects }: NotebookStoryProps) {
export default function NotebookStory({
projects,
onLayoutReady,
}: NotebookStoryProps) {
const notebookProjects = useMemo(
() => selectNotebookProjects(projects),
[projects],
);
const sectionRef = useRef<HTMLElement>(null);
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 [engine, setEngine] = useState(false);
const [sectionIntersects, setSectionIntersects] = useState(false);
@@ -69,12 +65,21 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
layoutReadyRef.current = onLayoutReady;
}, [onLayoutReady]);
useEffect(() => {
if (!engine && capabilityResolved) layoutReadyRef.current?.();
}, [engine, capabilityResolved]);
useEffect(() => {
const updateEngine = () => {
const nextEngine =
notebookProjects.length > 0 && canUseNotebook3D(window);
!failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
setCapabilityResolved(true);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
@@ -108,7 +113,9 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section || !("IntersectionObserver" in window)) {
if (!engine || !section) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined;
}
@@ -139,33 +146,58 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
gsap.registerPlugin(ScrollTrigger);
const context = gsap.context(() => {
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({
defaults: { ease: "none" },
onUpdate: applyProgress,
scrollTrigger: {
trigger: sectionRoot,
start: "top top",
end: "bottom bottom",
scrub: 0.85,
scrub: true,
invalidateOnRefresh: true,
onUpdate: (self) => {
progressRef.current = self.progress;
const nextIndex = getNotebookFrame(
self.progress,
notebookProjects.length,
).activeTabIndex;
if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
},
onRefresh: () => layoutReadyRef.current?.(),
},
});
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);
cleanup = () => context.revert();
cleanup = () => {
seekRef.current = null;
context.revert();
};
}
void setupTimeline();
void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => {
cancelled = true;
cleanup();
@@ -173,47 +205,38 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
failedRef.current = true;
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(
(index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return;
activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex);
seekToTab(safeIndex);
if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
},
[notebookProjects.length, seekToTab],
[engine, notebookProjects.length],
);
if (!engine) {
return (
<section
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"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
>
<div className="notebook-story-static-content">
<div className="notebook-story-copy">
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
tecnologias em produção.
</p>
</div>
<NotebookFallback projects={notebookProjects} />
<NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div>
</section>
);
@@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects}
/>
</div>
@@ -7,7 +7,7 @@ import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns the four ordered projects with local screenshots", () => {
it("returns featured projects in notebook order", () => {
const result = selectNotebookProjects(projectsFixture);
expect(result.map((project) => project.id)).toEqual([
@@ -16,11 +16,6 @@ describe("selectNotebookProjects", () => {
"sev-modaintima",
"as-servicos",
]);
expect(
result.every((project) =>
project.screenshotSrc.startsWith("/assets/prints/"),
),
).toBe(true);
});
it("omits a project when its screenshot mapping is missing", () => {
+8 -4
View File
@@ -9,6 +9,8 @@ export type NotebookProject = {
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
screenshotWidth: number;
screenshotHeight: number;
};
const NOTEBOOK_ORDER = [
@@ -22,10 +24,10 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
(typeof NOTEBOOK_ORDER)[number],
string
> = {
nutriscan: "/assets/prints/print-nutriscan.png",
"sc-plus": "/assets/prints/print-scplus.png",
"sev-modaintima": "/assets/prints/print-sev.png",
"as-servicos": "/assets/prints/print-asservicos.png",
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function isApprovedDemoUrl(
@@ -58,6 +60,8 @@ export function selectNotebookProjects(
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
screenshotWidth: 1600,
screenshotHeight: 1000,
},
];
});
+60 -111
View File
@@ -1,126 +1,75 @@
import { getNotebookFrame } from "./notebookTimeline";
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
describe("getNotebookFrame", () => {
it("clamps progress below zero and starts with a closed device", () => {
expect(getNotebookFrame(-1, 4)).toMatchObject({
phase: "approach",
lidAngle: 0,
activeTabIndex: -1,
cursorVisible: false,
});
describe("notebook choreography", () => {
it("opens and boots before browsing, then blanks the display before closing", () => {
const frames = Array.from({ length: 1001 }, (_, index) =>
getNotebookFrame(index / 1000, 4),
);
const phases = [...new Set(frames.map((frame) => frame.phase))];
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", () => {
expect(getNotebookFrame(2, 4)).toMatchObject({
phase: "close",
lidAngle: 0,
activeTabIndex: -1,
cursorVisible: false,
});
});
it("keeps the phase boundaries deterministic", () => {
expect(getNotebookFrame(0.1, 4).phase).toBe("open");
expect(getNotebookFrame(0.25, 4).phase).toBe("boot");
expect(getNotebookFrame(0.34, 4).phase).toBe("cursor-in");
expect(getNotebookFrame(0.44, 4).phase).toBe("browse");
expect(getNotebookFrame(0.78, 4).phase).toBe("hold");
expect(getNotebookFrame(0.87, 4).phase).toBe("close");
});
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("keeps the previous project until cursor arrival, including reverse scroll", () => {
const frames = Array.from({ length: 10001 }, (_, index) =>
getNotebookFrame(index / 10000, 4),
);
for (let selected = 1; selected < 4; selected += 1) {
const arrival = frames.findIndex(
(frame) => frame.activeTabIndex === selected,
);
const before = getNotebookFrame((arrival - 1) / 10000, 4);
const atClick = frames[arrival];
expect(before.activeTabIndex).toBe(selected - 1);
expect(before.cursorClicking).toBe(false);
expect(atClick.cursorClicking).toBe(true);
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
expect(reversed.activeTabIndex).toBe(selected - 1);
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
}
});
it("tracks the real number of tabs", () => {
expect(getNotebookFrame(0.68, 2).cursorX).toBeCloseTo(0.75, 6);
expect(getNotebookFrame(0.6, 3).cursorX).toBeCloseTo(0.5, 6);
it("seeks directly into a readable hold for the requested project", () => {
for (const count of [1, 4]) {
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", () => {
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
});
it("does not expose an invalid tab when there are no projects", () => {
it("does not expose a tab or cursor for empty or invalid project counts", () => {
expect(getNotebookFrame(0.6, 0)).toMatchObject({
activeTabIndex: -1,
cursorVisible: 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;
};
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 BROWSE_START = 0.44;
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 };
const ARRIVAL = 0.18;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function normalizeProgress(progress: number): number {
return clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
function between(value: number, start: number, end: number): number {
return clamp((value - start) / (end - start), 0, 1);
}
function interpolate(from: number, to: number, progress: number): number {
return from + (to - from) * clamp(progress, 0, 1);
}
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,
export function getNotebookSeekProgress(
index: number,
tabCount: number,
phase: NotebookPhase,
): number {
if (
tabCount < 1 ||
phase === "approach" ||
phase === "open" ||
phase === "boot" ||
phase === "cursor-in" ||
phase === "close"
) {
return -1;
}
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;
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
if (!count) return BROWSE_START;
const selected = clamp(
Number.isFinite(index) ? Math.floor(index) : 0,
0,
count - 1,
);
return (
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
);
}
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
export function getNotebookFrame(
progress: number,
tabCount: number,
target?: NotebookFrame,
): NotebookFrame {
const normalizedProgress = normalizeProgress(progress);
const normalizedTabCount = Math.max(0, Math.floor(tabCount));
const phase = getPhase(normalizedProgress);
const activeTabIndex = getActiveTabIndex(
normalizedProgress,
normalizedTabCount,
phase,
);
const isApproach = normalizedProgress < 0.1;
const isOpening = phase === "open";
const isBooting = phase === "boot";
const isClosing = phase === "close";
const lidAngle = isOpening
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15)
: isClosing
? normalizedProgress < CLOSE_LID_START
? OPEN_ANGLE
: interpolate(
OPEN_ANGLE,
0,
(normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START),
)
: normalizedProgress >= 0.25
? OPEN_ANGLE
: 0;
const screenOpacity = isBooting
? interpolate(0, 1, (normalizedProgress - 0.25) / 0.09)
: normalizedProgress >= 0.34 && !isClosing
? 1
: isClosing
? interpolate(
1,
0,
(normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END),
)
: 0;
const deviceOpacity = isApproach
? interpolate(0, 1, normalizedProgress / 0.1)
: isClosing
? interpolate(1, 0.92, (normalizedProgress - HOLD_END) / (1 - HOLD_END))
: 1;
const cursorVisible =
(phase === "cursor-in" || phase === "browse" || phase === "hold") &&
normalizedTabCount > 0;
const { x: cursorX, y: cursorY } = getCursorPoint(
normalizedProgress,
phase,
activeTabIndex,
normalizedTabCount,
);
return {
phase,
lidAngle,
deviceOpacity,
screenOpacity,
activeTabIndex,
cursorVisible,
cursorX,
cursorY,
cursorClicking:
cursorVisible &&
isCursorClicking(normalizedProgress, phase, normalizedTabCount),
};
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
const phase: NotebookPhase =
p < OPEN_START
? "approach"
: p < BOOT_START
? "open"
: p < CURSOR_START
? "boot"
: p < BROWSE_START
? "cursor-in"
: p < BROWSE_END
? "browse"
: p < CLOSE_START
? "hold"
: "close";
const slot = between(p, BROWSE_START, BROWSE_END) * count;
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
const local = slot - destination;
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
const travel = between(local, 0, ARRIVAL);
const cursorTab = moving
? destination - 1 + travel * travel * (3 - 2 * travel)
: destination;
const entry = between(p, CURSOR_START, BROWSE_START);
const frame = target ?? ({} as NotebookFrame);
frame.phase = phase;
frame.lidAngle =
OPEN_ANGLE *
between(p, OPEN_START, BOOT_START) *
(1 - between(p, CLOSE_LID_START, 1));
frame.deviceOpacity = between(p, 0, OPEN_START);
frame.screenOpacity =
between(p, BOOT_START, CURSOR_START) *
(1 - between(p, CLOSE_START, CLOSE_LID_START));
frame.activeTabIndex = activeTabIndex;
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
frame.cursorX = count
? phase === "cursor-in"
? 0.5 + (0.5 / count - 0.5) * entry
: (cursorTab + 0.5) / count
: 0.5;
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
frame.cursorClicking =
count > 0 &&
phase === "browse" &&
(destination === 0
? local < 0.07
: local >= ARRIVAL && local < ARRIVAL + 0.07);
return frame;
}
+557 -57
View File
@@ -82,10 +82,6 @@ html {
scroll-padding-top: 68px;
}
html [id] {
scroll-margin-top: 68px;
}
.site-header-nav,
.site-header-cv,
.site-header-version {
@@ -230,8 +226,6 @@ textarea {
.ds-section {
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
border-bottom: 1px solid var(--border);
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
.ds-section:nth-of-type(even):not(.ds-section-dark) {
@@ -1056,7 +1050,15 @@ textarea {
.notebook-story-runway {
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 {
@@ -1066,15 +1068,15 @@ textarea {
height: 100dvh;
min-height: 100dvh;
place-items: center;
padding: clamp(40px, 7vw, 96px) clamp(20px, 6vw, 80px);
padding: 32px clamp(20px, 3vw, 48px);
}
.notebook-story-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;
gap: clamp(28px, 6vw, 96px);
width: min(1240px, 100%);
gap: clamp(20px, 3vw, 48px);
width: min(1440px, 100%);
}
.notebook-story-copy {
@@ -1116,7 +1118,7 @@ textarea {
position: relative;
display: grid;
min-width: 0;
min-height: min(62vw, 680px);
height: min(82dvh, 780px);
place-items: center;
}
@@ -1144,19 +1146,91 @@ textarea {
}
.notebook-screen-surface {
width: min(660px, 68vw);
pointer-events: auto;
min-width: 280px;
overflow: hidden;
border-radius: 2px;
transform: translateZ(0);
transform-origin: 0 0;
}
.notebook-screen-surface .notebook-browser {
width: 100%;
height: 100%;
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 {
width: min(1080px, 100%);
margin: 0 auto;
@@ -1241,7 +1315,8 @@ textarea {
.notebook-tablist {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 1px;
margin-top: var(--sp-5);
border-block: 1px solid rgba(244, 242, 237, 0.12);
@@ -1274,8 +1349,9 @@ textarea {
color: var(--white);
}
.notebook-tab[data-cursor-clicking="true"] {
transform: scale(0.97);
.notebook-tab:focus-visible {
outline: 2px solid var(--slash);
outline-offset: -3px;
}
.notebook-tab-number {
@@ -1296,12 +1372,16 @@ textarea {
.notebook-panel {
display: grid;
grid-template-columns: minmax(140px, 0.35fr) minmax(0, 1fr);
align-items: center;
gap: clamp(20px, 3vw, 36px);
grid-template-columns: 1fr;
align-items: start;
gap: var(--sp-6);
margin-top: var(--sp-6);
}
.notebook-panel-copy {
order: 2;
}
.notebook-panel-copy h3 {
color: var(--white);
font-family: var(--font-display);
@@ -1375,38 +1455,43 @@ textarea {
.notebook-screenshot-wrap {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
aspect-ratio: 8 / 5;
border: 1px solid rgba(244, 242, 237, 0.16);
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 {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.notebook-screenshot-current,
.notebook-screenshot-outgoing {
.notebook-screenshot {
position: absolute;
inset: 0;
}
.notebook-screenshot-current {
z-index: 0;
animation: notebook-screenshot-in 180ms ease both;
z-index: 1;
animation: notebook-screenshot-in 220ms ease both;
}
.notebook-screenshot-outgoing {
z-index: 0;
animation: notebook-screenshot-out 180ms ease both;
}
.notebook-screenshot-wrap img.notebook-screenshot-preload {
position: absolute;
z-index: -1;
width: 1px;
height: 1px;
.notebook-screenshot-pending {
opacity: 0;
pointer-events: none;
}
@@ -1420,35 +1505,26 @@ textarea {
}
}
@keyframes notebook-screenshot-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.notebook-cursor {
position: absolute;
top: 0;
left: 0;
z-index: 3;
width: 15px;
height: 20px;
border: 2px solid var(--white);
background: var(--ink);
box-shadow: 2px 2px 0 var(--slash);
width: 28px;
height: 36px;
overflow: visible;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -45%) rotate(-18deg);
transition:
width 120ms ease,
height 120ms ease,
box-shadow 120ms ease;
transform-origin: 1px 1px;
filter: drop-shadow(1px 2px 1px rgba(0, 0, 0, 0.4));
}
.notebook-cursor.is-clicking {
width: 12px;
height: 16px;
box-shadow: 1px 1px 0 var(--slash);
.notebook-cursor-pulse {
opacity: 0;
}
.notebook-cursor.is-clicking .notebook-cursor-pulse {
opacity: 0.7;
}
@media (max-width: 1024px) {
@@ -1500,6 +1576,7 @@ textarea {
}
.notebook-tablist {
grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1528,8 +1605,431 @@ textarea {
transition: none;
animation: none;
}
}
.notebook-tab[data-cursor-clicking="true"] {
transform: none;
/* Interactive quality laboratory — scoped to the Lighthouse section. */
.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(
<OgCard
eyebrow={service.eyebrow}
title={service.headline}
description={service.answer}
title={service.navLabel}
description={service.intro}
/>,
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 heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(service.answer);
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", {
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", () => {
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", {
name: "Breadcrumb",
});
@@ -225,7 +228,7 @@ it("keeps hub cards and structured items aligned", () => {
"BreadcrumbList",
]);
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([
1, 2, 3, 4, 5, 6,
@@ -236,7 +239,9 @@ it("keeps hub cards and structured items aligned", () => {
expect([...structuredUrls].sort()).toEqual(
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"));
expect(visibleHrefs).toHaveLength(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-grid-2">
<article className="ds-card">
<div className="ds-kicker">Para quem faz sentido</div>
<h2 className="ds-card-title">
Contextos em que esse serviço encaixa melhor
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
<ul className="ds-list">
{service.audience.map((item) => (
<li key={item}>{item}</li>
))}
@@ -159,11 +156,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
</article>
<article className="ds-card">
<div className="ds-kicker">O que muda</div>
<h2 className="ds-card-title">
Resultados que eu busco destravar
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
<ul className="ds-list">
{service.outcomes.map((item) => (
<li key={item}>{item}</li>
))}
@@ -176,48 +170,59 @@ export default async function ServicePage({ params }: ServicePageProps) {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<article className="ds-card">
<div className="ds-kicker">Como eu estruturo</div>
<h2 className="ds-card-title">Processo de trabalho</h2>
<ol className="list-none mt-sp-5 space-y-sp-4 text-[14px] leading-8 text-[var(--ink-80)]">
{service.process.map((step, index) => (
<li
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>
<article className="min-w-0">
<div className="ds-kicker">Da conversa à entrega</div>
<h2 className="ds-card-title">Como trabalhamos</h2>
<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) => (
<li key={step} className="pl-sp-2">
{step}
</li>
))}
</ol>
</article>
<article className="ds-card">
<div className="ds-kicker">Stack e repertório</div>
<h2 className="ds-card-title">
Tecnologias e capacidades envolvidas
</h2>
<article className="min-w-0">
<div className="ds-kicker">Possibilidades de uso</div>
<h2 className="ds-card-title">Imagine na sua rotina</h2>
<p className="ds-copy">
A stack entra como meio para resolver o problema certo, com
critério de manutenção, observabilidade e evolução do produto.
Exemplos ilustrativos para ajudar a explicar o serviço, não
projetos já entregues. O seu escopo será definido na conversa.
</p>
<ul className="ds-tags">
{service.stack.map((item) => (
<MagneticButton
key={item}
as="li"
className="ds-tag"
strength={0.15}
>
{item}
</MagneticButton>
<ul className="ds-list">
{service.examples.map((example) => (
<li key={example}>{example}</li>
))}
</ul>
</article>
</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 className="ds-section">
@@ -226,11 +231,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title">
Projetos que reforçam esse tipo de entrega
Projetos para conhecer meu trabalho
</h2>
<p className="ds-section-description">
Alguns exemplos de contexto técnico em que esse serviço se
conecta a entregas reais.
Referências de problemas e soluções relacionados. Cada projeto
usa as tecnologias indicadas no próprio case; não representa
todas as plataformas ou tecnologias oferecidas neste serviço.
</p>
</div>
</div>
@@ -246,7 +252,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
@@ -265,7 +271,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
target="_blank"
rel="noreferrer"
>
Abrir projeto
Abrir projeto: {caseStudy.title}
</a>
) : null}
{caseStudy.githubUrl ? (
@@ -275,7 +281,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
target="_blank"
rel="noreferrer"
>
Ver código
Ver código: {caseStudy.title}
</a>
) : null}
</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-kicker">Próximo passo</div>
<h2 className="ds-card-title text-white">
Se esse serviço parece aderente ao seu contexto, a conversa
inicial já pode ser objetiva.
Conte o que você precisa resolver.
</h2>
<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
leitura técnica e indico o caminho mais seguro para avançar.
Pode ser uma ideia, uma tarefa que toma tempo ou um sistema que
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>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<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";
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 contentType = OG_CONTENT_TYPE;
@@ -10,8 +10,8 @@ export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Serviços"
title="Engenharia de software e consultoria técnica"
description="Base em Garuva, Norte de Santa Catarina e remoto."
title="Sites, sistemas e aplicativos"
description="Web, mobile e desktop para Windows e Linux. Desenvolvimento, integrações e melhorias para o seu negócio."
/>,
OG_SIZE,
);
+49 -23
View File
@@ -1,15 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
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 =
"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 = {
title: pageTitle,
@@ -80,13 +79,15 @@ export default function ServicesIndexPage() {
</li>
</ol>
</nav>
<p className="ds-eyebrow">Hub de serviços</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Serviços de engenharia de software para contextos técnicos de alta
exigência.
<p className="ds-eyebrow">Serviços de desenvolvimento</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Da presença na internet ao sistema que move seu negócio.
</h1>
<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>
</div>
</div>
@@ -94,25 +95,50 @@ export default function ServicesIndexPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
{serviceHubLinks.map((service) => (
<article key={service.href} className="ds-card">
<div className="ds-kicker">Serviço especializado</div>
<h2 className="ds-card-title">{service.label}</h2>
<p className="ds-copy">{service.description}</p>
<div className="mt-sp-6">
<MagneticButton
as="a"
href={service.href}
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir página
</MagneticButton>
<div className="max-w-[72ch]">
<h2 className="ds-card-title !mt-0">O que você precisa fazer?</h2>
<p className="ds-copy">
Conheça as possibilidades abaixo. Cada serviço explica para quem
faz sentido, exemplos de uso, escopo e etapas de trabalho. Os
detalhes técnicos ficam disponíveis para aprofundar a conversa.
</p>
</div>
<div className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service, index) => (
<article
key={service.href}
className="grid gap-sp-4 py-sp-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-sp-8"
>
<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>
<p className="ds-copy !mt-0">{service.description}</p>
</article>
))}
</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>
</section>
</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", () => {
const caseIds = new Set(caseStudies.map(({ id }) => id));
const unresolved = servicePages.flatMap(({ slug, relatedCaseIds }) =>
+208 -188
View File
@@ -56,6 +56,8 @@ export interface ServicePageContent {
audience: string[];
outcomes: string[];
process: string[];
examples: string[];
technicalDetails: string[];
stack: string[];
ctaIntent: "consulting";
ctaLabel: string;
@@ -79,271 +81,291 @@ export interface CaseStudyContent {
export const servicePages: ServicePageContent[] = [
{
slug: "criacao-de-sites",
navLabel: "Criação de Sites",
navLabel: "Sites profissionais",
eyebrow: "Presença digital",
seoTitle: "Criação de Sites e Sistemas Web com Next.js",
seoTitle: "Criação de Sites Profissionais e Institucionais",
metaDescription:
"Criação de sites institucionais e sistemas web responsivos, com acessibilidade, performance e SEO técnico considerados desde a implementação.",
headline: "Criação de sites institucionais e sistemas web com Next.js.",
"Sites profissionais para apresentar serviços, produtos e canais de contato. Desenvolvimento responsivo com acessibilidade e SEO técnico, em escopo definido.",
headline: "Um site que explica seu negócio.",
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:
"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: [
"Empresas e profissionais que ainda não têm site ou precisam substituir uma experiência lenta e desatualizada.",
"Negócios que precisam organizar conteúdo e fundamentos técnicos para busca orgânica sem criar páginas locais artificiais.",
"Marcas que precisam de um site institucional rápido, com boa performance e pronto para escalar para um sistema web completo.",
"Empresas e profissionais que ainda não têm site ou precisam substituir uma presença desatualizada.",
"Negócios que querem explicar seus serviços sem depender apenas das redes sociais.",
"Equipes que precisam organizar páginas, conteúdo e contato em um endereço próprio.",
],
outcomes: [
"Site institucional rápido, responsivo e com boa nota em Core Web Vitals.",
"Estrutura de SEO técnico (title, metadata, dados estruturados e sitemap) desde o lançamento.",
"Presença digital pronta para captar clientes que pesquisam por serviços na sua cidade.",
"Base de código organizada, fácil de evoluir para loja, sistema ou painel administrativo.",
"Páginas com uma sequência clara: o que você faz, para quem e como entrar em contato.",
"Layout adaptado a celular e computador, com atenção à leitura e à navegação.",
"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.",
],
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: [
"Levantamento do objetivo do site, público local e páginas necessárias.",
"Definição de estrutura, conteúdo e palavras-chave locais relevantes.",
"Implementação em Next.js com foco em performance, acessibilidade e SEO.",
"Publicação, configuração de domínio e orientação sobre indexação no Google.",
"Conversamos sobre o negócio, o público e o que o visitante precisa conseguir fazer.",
"Combinamos páginas, conteúdo, referências visuais, responsabilidades e orçamento.",
"Você acompanha a construção e revisa conteúdo e navegação em etapas.",
"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"],
ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto",
ctaLabel: "Conversar sobre meu site",
ctaHref: "/#contact",
relatedCaseIds: ["sc-plus", "nutriscan"],
faq: [
{
question: "Quanto custa um site profissional?",
question: "Quanto custa e o que preciso ter pronto?",
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:
"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:
"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.",
},
{
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.",
"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.",
},
],
},
{
slug: "engenharia-de-software",
navLabel: "Engenharia de Software",
eyebrow: "Serviço principal",
seoTitle: "Engenharia de Software para Produtos e Sistemas Críticos",
navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Engenharia de software",
seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
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.",
headline:
"Engenharia de software para evoluir produto sem aumentar o caos técnico.",
"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: "Sistemas e apps para o seu trabalho.",
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:
"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: [
"Times que já tem produto em operação e precisam voltar a ganhar velocidade.",
"Empresas que sofrem com integrações, retrabalho e backlog técnico acumulado.",
"Operações em que confiabilidade e continuidade pesam mais do que velocidade teatral.",
"Empresas que controlam processos em planilhas, mensagens e ferramentas que não conversam entre si.",
"Negócios que precisam de um sistema interno, área de clientes ou aplicativo para uso no celular.",
"Equipes com software em operação que precisam corrigir problemas e continuar evoluindo.",
],
outcomes: [
"Arquitetura mais legível para manter e escalar.",
"Entrega incremental com menos retrabalho entre produto e engenharia.",
"Integrações mais robustas entre sistemas, APIs e fluxos internos.",
"Base técnica preparada para crescer sem depender de improviso constante.",
"Uma primeira versão com as funções prioritárias, em vez de tentar resolver toda a operação de uma vez.",
"Fluxos para consultar, registrar e compartilhar informações, com acessos definidos por perfil.",
"Escopo explícito para web ou mobile: dispositivos, sistemas operacionais, uso sem internet e distribuição são avaliados na proposta, não presumidos.",
],
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: [
"Diagnóstico do contexto técnico, do risco e do gargalo principal.",
"Definição do recorte de entrega com prioridades e trade-offs claros.",
"Implementação com rastreabilidade, code review e critério de produção.",
"Handoff ou continuidade com documentação e próximo ciclo recomendado.",
"Entendemos a rotina atual, quem usa o sistema e onde estão as dificuldades.",
"Escolhemos o primeiro fluxo a entregar e combinamos plataformas, integrações e critérios de aceitação.",
"Construímos em etapas curtas, com demonstrações e revisão de quem vai usar.",
"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"],
ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto",
ctaLabel: "Conversar sobre meu sistema ou app",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
faq: [
{
question:
"Como contratar um desenvolvedor para integração de sistemas?",
question: "Preciso saber qual tecnologia usar?",
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:
"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:
"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.",
},
{
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.",
"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.",
},
],
},
{
slug: "consultoria-nextjs",
navLabel: "Consultoria Next.js",
eyebrow: "Stack especializada",
seoTitle: "Consultoria Next.js para Produtos, SEO e Performance",
navLabel: "Melhorias em sites e sistemas Next.js",
eyebrow: "Consultoria Next.js",
seoTitle: "Consultoria Next.js para Sites e Sistemas",
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.",
headline:
"Consultoria Next.js para produto, performance e SEO técnico sem gambiarra.",
"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: "Seu projeto Next.js pode funcionar melhor.",
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:
"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: [
"Produtos em Next.js que perderam clareza com o crescimento da base.",
"Sites e plataformas que dependem de SEO técnico e performance para converter.",
"Times que precisam migrar, estabilizar ou revisar arquitetura App Router.",
"Empresas com um site ou sistema em Next.js que não está atendendo bem seus usuários.",
"Equipes que precisam de apoio para investigar falhas, carregamento ou indexação.",
"Responsáveis por um produto que precisam entender o que corrigir primeiro e por quê.",
],
outcomes: [
"Estrutura mais consistente para App Router, Server Components e dados.",
"Melhoras práticas em title, metadata, schema, semântica e links internos.",
"Redução de gargalos em renderização, bundle e fetch.",
"Base mais pronta para crescer sem regressão de SEO ou UX.",
"Diagnóstico com problemas observados, impacto para o usuário e prioridades.",
"Plano de correção que distingue ajustes pontuais de mudanças maiores.",
"Implementação ou acompanhamento do time, conforme a contratação; metas de performance dependem da medição e do contexto, sem promessa de ranking.",
],
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: [
"Auditoria de arquitetura, rotas, metadata e performance.",
"Priorização das correções de maior impacto comercial e técnico.",
"Implementação ou acompanhamento do time interno nas partes críticas.",
"Validação final com build, semântica e consistência entre páginas.",
"Você apresenta os sintomas, o objetivo e os acessos necessários para a análise.",
"Investigo o projeto e explico as causas encontradas e suas consequências.",
"Combinamos prioridades e o formato: diagnóstico, correções ou apoio ao time.",
"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",
ctaLabel: "Solicitar consultoria",
ctaLabel: "Conversar sobre meu projeto Next.js",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
faq: [
{
question: "Next.js vale a pena para site institucional?",
question: "Não sei se meu site usa Next.js. Posso conversar?",
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:
"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:
"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.",
},
{
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.",
"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.",
},
],
},
{
slug: "tauri-rust",
navLabel: "Tauri + Rust",
eyebrow: "Desktop e performance",
seoTitle: "Desenvolvimento Tauri e Rust para Apps Desktop",
navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Aplicativos desktop",
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
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.",
headline:
"Tauri e Rust para apps desktop mais leves, seguros e prontos para operar.",
"Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
headline: "Aplicativos para Windows e Linux.",
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:
"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: [
"Produtos desktop que precisam integrar recursos do sistema operacional.",
"Ferramentas internas com demanda por distribuição leve e manutenção simples.",
"Times avaliando migração de stack desktop ou construindo um MVP técnico robusto.",
"Empresas que precisam de uma ferramenta instalada, em vez de depender apenas do navegador.",
"Equipes que trabalham com arquivos, processamento local ou recursos do computador.",
"Produtos que precisam avaliar uma aplicação desktop nova ou a evolução de uma ferramenta existente.",
],
outcomes: [
"Aplicações desktop com melhor eficiência de recursos.",
"Fluxos locais e integrações nativas com critério técnico mais alto.",
"Arquitetura preparada para distribuição, atualização e manutenção.",
"Menos atrito entre interface moderna e motor de execução confiável.",
"Uma interface para executar as tarefas locais prioritárias e entender o resultado de cada operação.",
"Compatibilidade acordada para Windows e/ou Linux, incluindo versões, distribuições e dependências necessárias.",
"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.",
],
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: [
"Definição da necessidade real de desktop e do papel de Rust no produto.",
"Desenho da arquitetura entre frontend, camada nativa e distribuição.",
"Implementação dos fluxos críticos com foco em confiabilidade e ergonomia.",
"Empacotamento, validação e estratégia de evolução do aplicativo.",
"Entendemos a tarefa e confirmamos se um aplicativo instalado faz mais sentido que um sistema web.",
"Definimos Windows e/ou Linux, ambientes de uso, acesso a arquivos e integrações necessárias.",
"Construímos e demonstramos os fluxos principais nos ambientes combinados.",
"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: [
"Tauri",
"Rust",
"TypeScript",
"Desktop apps",
"System integration",
"Desktop Apps",
"System Integration",
],
ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto",
ctaLabel: "Conversar sobre meu aplicativo desktop",
ctaHref: "/#contact",
relatedCaseIds: ["gcc-installer"],
faq: [
{
question: "Vale a pena criar app desktop com Tauri e Rust?",
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
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:
"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:
"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.",
},
{
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.",
"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.",
},
],
},
{
slug: "integracao-hardware-software-iot",
navLabel: "Hardware + Software + IoT",
eyebrow: "Produtos conectados",
seoTitle: "Integração Hardware + Software e IoT",
navLabel: "Integração de equipamentos e sistemas",
eyebrow: "Hardware, software e IoT",
seoTitle: "Integração de Equipamentos, Sistemas e IoT",
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.",
headline:
"Integração hardware + software para produtos conectados que precisam operar de verdade.",
"Integração entre equipamentos e software para consultar dados e acompanhar operações. Viabilidade, interfaces e tratamento de falhas definidos por projeto.",
headline: "Dados dos equipamentos no seu sistema.",
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:
"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: [
"Empresas com dispositivos, sensores ou equipamentos conectados ao software.",
"Operações que dependem de telemetria, dashboards e troca de dados em tempo real.",
"Times que precisam ligar produto digital a processos físicos com confiabilidade.",
"Operações que registram manualmente informações disponíveis em sensores ou equipamentos.",
"Empresas que precisam reunir dados físicos e informações de seus sistemas.",
"Equipes que querem avaliar a viabilidade de um produto conectado antes de ampliar o projeto.",
],
outcomes: [
"Fluxos mais robustos entre dispositivo, API, banco e interface.",
"Menos perda de contexto entre operação física e produto digital.",
"Arquitetura com observabilidade e manutenção mais previsível.",
"Base melhor para evoluir IA, automação ou analytics em cima dos dados.",
"Um caminho definido entre a informação do equipamento e a tela de quem precisa usá-la.",
"Tratamento combinado para falta de conexão, leituras inválidas e outras falhas relevantes.",
"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.",
],
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: [
"Mapeamento de sinal, eventos, integrações e pontos de falha.",
"Definição de contratos entre hardware, backend e interfaces.",
"Implementação do fluxo com testes e tratamento de exceções relevantes.",
"Entrega com documentação de operação, limites e próximo passo.",
"Mapeamos equipamentos, dados disponíveis e a decisão que essas informações precisam apoiar.",
"Verificamos documentação, formas de conexão e limites do ambiente.",
"Implementamos um fluxo delimitado e avaliamos as falhas relevantes com os acessos combinados.",
"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: [
"IoT",
@@ -353,61 +375,64 @@ export const servicePages: ServicePageContent[] = [
"Integração de sistemas",
],
ctaIntent: "consulting",
ctaLabel: "Conversar sobre o projeto",
ctaLabel: "Avaliar minha integração",
ctaHref: "/#contact",
relatedCaseIds: ["twins-lab"],
faq: [
{
question: "Como integrar equipamentos e sistemas legados?",
question: "Qualquer equipamento pode ser conectado?",
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:
"É 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:
"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.",
},
{
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.",
"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.",
},
],
},
{
slug: "arquitetura-de-software",
navLabel: "Arquitetura de Software",
navLabel: "Diagnóstico e arquitetura de software",
eyebrow: "Diagnóstico e decisão",
seoTitle: "Arquitetura de Software e Consultoria Técnica",
seoTitle: "Diagnóstico e Arquitetura de Software",
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.",
headline:
"Arquitetura de software para times que precisam de clareza antes de acelerar.",
"Diagnóstico de software para entender falhas, organizar prioridades e planejar evolução. Arquitetura com riscos, alternativas e plano de mudança explicado.",
headline: "Entenda o problema antes de mudar tudo.",
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:
"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: [
"Produtos que cresceram rápido e perderam legibilidade técnica.",
"Times travados entre refatorar tudo ou seguir acumulando dívida.",
"Operações com muitas dependências, integrações ou regras sensíveis.",
"Empresas que dependem de um sistema e não conseguem entender o custo ou o risco das mudanças.",
"Equipes que enfrentam falhas recorrentes, integrações frágeis ou dificuldade para entregar novas funções.",
"Responsáveis por um produto que precisam comparar caminhos antes de investir numa migração.",
],
outcomes: [
"Mapa claro dos gargalos e do risco técnico real.",
"Prioridades de arquitetura ligadas a impacto de negócio.",
"Plano executável de refatoração, estabilização ou reestruturação.",
"Mais qualidade de decisão entre produto, engenharia e operação.",
"Um mapa dos problemas encontrados e de como afetam o produto e a operação.",
"Alternativas com benefícios, limitações e riscos explicados para apoiar a decisão.",
"Plano de execução por prioridades; implementar as mudanças ou acompanhar o time depende do formato contratado.",
],
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: [
"Leitura da base, fluxos, integrações e dores do time.",
"Diagnóstico escrito com pontos de risco, oportunidades e trade-offs.",
"Proposta de arquitetura alvo e plano de transição realista.",
"Acompanhamento da execução ou handoff estruturado para o time interno.",
"Conversamos com quem usa e mantém o sistema e reunimos os acessos e materiais necessários.",
"Analiso a base e os fluxos relevantes para o problema, sem ampliar a investigação sem motivo.",
"Apresento o diagnóstico e comparo as alternativas com o time e os responsáveis pelo negócio.",
"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: [
"Arquitetura de software",
@@ -417,29 +442,24 @@ export const servicePages: ServicePageContent[] = [
"Sistemas críticos",
],
ctaIntent: "consulting",
ctaLabel: "Solicitar diagnóstico",
ctaLabel: "Conversar sobre um diagnóstico",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
faq: [
{
question: "Quando investir em arquitetura de software?",
question: "Preciso ter uma equipe técnica para contratar?",
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:
"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:
"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.",
},
{
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.",
"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.",
},
],
},
@@ -699,7 +719,7 @@ export const caseStudies: CaseStudyContent[] = [
export const serviceHubLinks = servicePages.map((service) => ({
href: `/servicos/${service.slug}`,
label: service.navLabel,
description: service.metaDescription,
description: service.intro,
}));
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