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:
1 parent
e2b20bc699
commit
8b12e51bc5
35 files changed
+2359
-1299
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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">
|
||||
<h1>
|
||||
</text>
|
||||
<path d="M331 317h213v29H331z" />
|
||||
<text x="350" y="337">
|
||||
<article> / <nav>
|
||||
</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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
}, []);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 |
Reference in new issue
Block a user