diff --git a/README.md b/README.md index 2c0d4a3..a86d8de 100644 --- a/README.md +++ b/README.md @@ -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 `
` 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 diff --git a/app/components/Header.test.tsx b/app/components/Header.test.tsx index 65b5f7a..b6978a1 100644 --- a/app/components/Header.test.tsx +++ b/app/components/Header.test.tsx @@ -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 ( + <> +
+
+

Sobre

+
+ + ); +} + describe("Header", () => { it("exposes the primary navigation and the existing CV asset", () => { render(
); @@ -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(
); + it("closes the mobile menu without taking focus away from a home destination", () => { + window.history.replaceState(null, "", "/"); + render(); 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(); }); }); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 8ec18be..f4f4540 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -15,16 +15,14 @@ export function Header() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const menuButtonRef = useRef(null); - const closeMobileMenu = () => { - setMobileMenuOpen(false); - menuButtonRef.current?.focus(); - }; + const closeMobileMenu = () => setMobileMenuOpen(false); const handleMenuKeyDown = (event: KeyboardEvent) => { 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 diff --git a/app/components/Hero.reducedMotion.test.tsx b/app/components/Hero.reducedMotion.test.tsx index a08797d..5420634 100644 --- a/app/components/Hero.reducedMotion.test.tsx +++ b/app/components/Hero.reducedMotion.test.tsx @@ -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 = () =>
; - 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 | 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; - style?: Record; - } & Record) => { - 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(); + 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(); - - 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, , { + 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(); - }); -}); + }, +); diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 6200706..0bb22dc 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -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(null); - const shouldReduceMotion = useReducedMotion() ?? true; + const shouldReduceMotion = useReducedMotion(); const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false); const [isHeroVisible, setIsHeroVisible] = useState(true); diff --git a/app/components/LighthouseMetrics.test.tsx b/app/components/LighthouseMetrics.test.tsx new file mode 100644 index 0000000..d5e83e6 --- /dev/null +++ b/app/components/LighthouseMetrics.test.tsx @@ -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(); + + 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/); +}); diff --git a/app/components/LighthouseMetrics.tsx b/app/components/LighthouseMetrics.tsx index acbe717..c779ac9 100644 --- a/app/components/LighthouseMetrics.tsx +++ b/app/components/LighthouseMetrics.tsx @@ -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 = ( + +); + export default function LighthouseMetrics() { - const shouldReduceMotion = useReducedMotion(); + const [active, setActive] = useState(METRICS[0]); return ( -
-
- {METRICS.map((metric, i) => ( - - {/* The Ring Container */} -
- -
- {metric.score} -
-
+
+
+
+

Quality & Performance

+

+ Engenharia de ponta a ponta. + Qualidade em cada camada. +

+

+ 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. +

+ + Conversar sobre a qualidade do seu projeto + +
- {/* Content Details */} -
-

- {metric.label} -

-

- {metric.description} -

+
+
+ Laboratório de qualidade + Modelo interativo
- - ))} + {BLUEPRINT} +
+ {METRICS.map((metric) => ( + + {metric.caption} + + ))} +
+
+
+ +
+ Escolha um aspecto da qualidade + {METRICS.map((metric) => ( + + ))} +
+ +

+ Selecione um pilar para explorar. No teclado, use as setas. +

+ +
+ {METRICS.map((metric) => ( +
+
+

{metric.code}

+

{metric.title}

+

{metric.description}

+
+
    + {metric.checks.map((check) => ( +
  • + + {check} +
  • + ))} +
+
+ ))} +
+ +

+ Metas de qualidade, não uma auditoria em tempo real. Resultados + Lighthouse variam conforme página, dispositivo e condições do teste. +

-
+ ); } diff --git a/app/components/WorkModalities.tsx b/app/components/WorkModalities.tsx index d4e7aed..10e0323 100644 --- a/app/components/WorkModalities.tsx +++ b/app/components/WorkModalities.tsx @@ -199,22 +199,23 @@ export function WorkModalities() { return (
-
Páginas de serviço
+
O que você pode contratar

- Especialidades organizadas por intenção de busca + Uma solução para a sua rotina, não uma lista de tecnologias.

- 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.

- Ver hub de serviços + Conhecer todos os serviços
@@ -235,12 +236,12 @@ export function WorkModalities() { -
+
{service.label}
-
+
{service.description}
diff --git a/app/components/cinematic/CinematicHome.test.tsx b/app/components/cinematic/CinematicHome.test.tsx index a7d7701..d0ae9bd 100644 --- a/app/components/cinematic/CinematicHome.test.tsx +++ b/app/components/cinematic/CinematicHome.test.tsx @@ -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 ( + <> + + Sobre + Contato +
+ +
+
+ +
+ + ); +} - return () => { - const label = labels[index++] ?? "DynamicSection"; - return function DynamicMock() { - return
{label}
; - }; - }; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +beforeEach(() => { + window.history.replaceState(null, "", "/"); + HTMLElement.prototype.scrollIntoView = jest.fn(); }); -jest.mock("../Hero", () => () =>
); -jest.mock("../Projects", () => () => ( -
-)); -jest.mock("../story/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( - , - ); - 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(); + 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(); + 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(); + 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(); + 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(); }); }); diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index 99e8ebe..5e183c6 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -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: () => , -}); -const Skills = dynamic(() => import("../Skills"), { - loading: () => , -}); -const Experience = dynamic(() => import("../Experience"), { - loading: () => , -}); -const WorkModalities = dynamic( - () => import("../WorkModalities").then((mod) => mod.WorkModalities), - { loading: () => }, -); -const Contact = dynamic(() => import("../Contact"), { - loading: () => , -}); -const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { - loading: () => , -}); -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 (
@@ -58,7 +40,7 @@ export default function CinematicHome({
- + diff --git a/app/components/cinematic/ScrollVelocityText.tsx b/app/components/cinematic/ScrollVelocityText.tsx index a293b8c..43e78a0 100644 --- a/app/components/cinematic/ScrollVelocityText.tsx +++ b/app/components/cinematic/ScrollVelocityText.tsx @@ -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); }; diff --git a/app/components/cinematic/useHomeFragmentNavigation.ts b/app/components/cinematic/useHomeFragmentNavigation.ts new file mode 100644 index 0000000..f584f21 --- /dev/null +++ b/app/components/cinematic/useHomeFragmentNavigation.ts @@ -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?.(); + }, []); +} diff --git a/app/components/story/NotebookBrowser.test.tsx b/app/components/story/NotebookBrowser.test.tsx index 504a891..716d9db 100644 --- a/app/components/story/NotebookBrowser.test.tsx +++ b/app/components/story/NotebookBrowser.test.tsx @@ -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 = {}) { ); } +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( { onSelectTab={jest.fn()} />, ); + let finishDecode!: () => void; + imageFor(view.container, 1).decode = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementation( + () => + new Promise((resolve) => { + finishDecode = resolve; + }), + ); + await act(async () => { + fireEvent.load(imageFor(view.container, 1)); + }); + expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i); + + view.rerender( + , + ); + 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( + , + ); + 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( + , + ); + await act(async () => { + fireEvent.load(imageFor(view.container, 1)); + }); + view.rerender( + , + ); + 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, + ); }); }); diff --git a/app/components/story/NotebookBrowser.tsx b/app/components/story/NotebookBrowser.tsx index a666938..79fa9ff 100644 --- a/app/components/story/NotebookBrowser.tsx +++ b/app/components/story/NotebookBrowser.tsx @@ -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; 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(null); + const rootRef = useRef(null); + const cursorRef = useRef(null); + const cursorFrame = useRef(frame); + const tabPoints = useRef>([]); + const browserSize = useRef({ width: 0, height: 0 }); + const [displayed, setDisplayed] = useState(null); + const [outgoing, setOutgoing] = useState(null); + const [failedSrc, setFailedSrc] = useState(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( + `#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('[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} > -
- {outgoingProject ? ( - - ) : null} - {`Captura - {nextProject ? ( - +
+ {imageProjects.map((project) => { + const current = + project.screenshotSrc === displayed?.screenshotSrc; + const previous = + project.screenshotSrc === outgoing?.screenshotSrc; + return ( + {current { + void revealImage(event.currentTarget, project); + }} + onError={() => { + if (project.screenshotSrc === requestRef.current) + setFailedSrc(project.screenshotSrc); + }} + onAnimationEnd={() => { + if (current) setOutgoing(null); + }} + /> + ); + })} + {waiting ? ( +

+ {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} +

) : null}
- {frame.cursorVisible ? ( -
); } diff --git a/app/components/story/NotebookFallback.tsx b/app/components/story/NotebookFallback.tsx index 7d6bca3..6e03cb9 100644 --- a/app/components/story/NotebookFallback.tsx +++ b/app/components/story/NotebookFallback.tsx @@ -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} />
diff --git a/app/components/story/NotebookStage3D.test.tsx b/app/components/story/NotebookStage3D.test.tsx index 079b97a..ab9e7e6 100644 --- a/app/components/story/NotebookStage3D.test.tsx +++ b/app/components/story/NotebookStage3D.test.tsx @@ -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(); - - 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(); - - expect(screen.getByTestId("mock-canvas")).toHaveAttribute( - "data-frameloop", - "never", - ); - }); it("returns usable browser content when the stage boundary fails", () => { const onStageError = jest.fn(); diff --git a/app/components/story/NotebookStage3D.tsx b/app/components/story/NotebookStage3D.tsx index b6462cc..dea9eed 100644 --- a/app/components/story/NotebookStage3D.tsx +++ b/app/components/story/NotebookStage3D.tsx @@ -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; +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} /> - - - ); } function ScreenSurface({ frame, + material, projects, activeIndex, onSelectTab, }: { - frame: NotebookFrame; + frame: RefObject; + material: THREE.MeshStandardMaterial; projects: NotebookProject[]; activeIndex: number; onSelectTab: (index: number) => void; }) { + const screen = useRef(null); + const surface = useRef(null); + const browser = useRef(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 ( - -
- 0.05} - onSelectTab={onSelectTab} - projects={projects} - /> -
- + + + + +
+ +
+ + ); } @@ -267,33 +340,34 @@ function NotebookScene({ onSelectTab, progress, projects, + requestRender, + isVisible, }: NotebookSceneProps) { const lidRig = useRef(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 ( @@ -307,6 +381,7 @@ function NotebookScene({ @@ -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", }} > - + ); } diff --git a/app/components/story/NotebookStory.test.tsx b/app/components/story/NotebookStory.test.tsx index 4ede3f5..acda9e4 100644 --- a/app/components/story/NotebookStory.test.tsx +++ b/app/components/story/NotebookStory.test.tsx @@ -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 }) => (
@@ -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(); + render(); 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(); @@ -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(); - - expect( - addEventListener.mock.calls.some(([type]) => type === "scroll"), - ).toBe(false); - }); }); diff --git a/app/components/story/NotebookStory.tsx b/app/components/story/NotebookStory.tsx index facac9d..6fb4234 100644 --- a/app/components/story/NotebookStory.tsx +++ b/app/components/story/NotebookStory.tsx @@ -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; 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(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 (
0 ? " notebook-story-pending" : ""}`} data-testid="notebook-story" id="story" + ref={sectionRef} + style={ + { + "--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`, + } as CSSProperties + } >
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) { tecnologias em produção.

- +
); @@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) { onSelectTab={handleSelectTab} onStageError={handleStageError} progress={progressRef} + requestRender={requestRenderRef} projects={notebookProjects} />
diff --git a/app/components/story/notebookProjects.test.ts b/app/components/story/notebookProjects.test.ts index 63b8a71..64ce009 100644 --- a/app/components/story/notebookProjects.test.ts +++ b/app/components/story/notebookProjects.test.ts @@ -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", () => { diff --git a/app/components/story/notebookProjects.ts b/app/components/story/notebookProjects.ts index 9f3605b..dda3c1b 100644 --- a/app/components/story/notebookProjects.ts +++ b/app/components/story/notebookProjects.ts @@ -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, }, ]; }); diff --git a/app/components/story/notebookTimeline.test.ts b/app/components/story/notebookTimeline.test.ts index e85fc1a..d0d2b78 100644 --- a/app/components/story/notebookTimeline.test.ts +++ b/app/components/story/notebookTimeline.test.ts @@ -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); }); }); diff --git a/app/components/story/notebookTimeline.ts b/app/components/story/notebookTimeline.ts index 8c51fc8..8095023 100644 --- a/app/components/story/notebookTimeline.ts +++ b/app/components/story/notebookTimeline.ts @@ -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; } diff --git a/app/globals.css b/app/globals.css index 61c21c1..7e6a7cb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } } diff --git a/app/servicos/[slug]/opengraph-image.tsx b/app/servicos/[slug]/opengraph-image.tsx index dc380d4..de010f5 100644 --- a/app/servicos/[slug]/opengraph-image.tsx +++ b/app/servicos/[slug]/opengraph-image.tsx @@ -40,8 +40,8 @@ export default async function Image({ return new ImageResponse( , OG_SIZE, ); diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx index 33f3948..280b690 100644 --- a/app/servicos/[slug]/page.test.tsx +++ b/app/servicos/[slug]/page.test.tsx @@ -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(); - 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); diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx index ecccd2f..c70826c 100644 --- a/app/servicos/[slug]/page.tsx +++ b/app/servicos/[slug]/page.tsx @@ -147,11 +147,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
Para quem faz sentido
-

- Contextos em que esse serviço encaixa melhor -

-
    +

    Para quem faz sentido

    +
      {service.audience.map((item) => (
    • {item}
    • ))} @@ -159,11 +156,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
O que muda
-

- Resultados que eu busco destravar -

-
    +

    O que podemos entregar

    +
      {service.outcomes.map((item) => (
    • {item}
    • ))} @@ -176,48 +170,59 @@ export default async function ServicePage({ params }: ServicePageProps) {
      -
      -
      Como eu estruturo
      -

      Processo de trabalho

      -
        - {service.process.map((step, index) => ( -
      1. - - {index + 1} - +
        +
        Da conversa à entrega
        +

        Como trabalhamos

        +
          + {service.process.map((step) => ( +
        1. {step}
        2. ))}
        -
        -
        Stack e repertório
        -

        - Tecnologias e capacidades envolvidas -

        +
        +
        Possibilidades de uso
        +

        Imagine na sua rotina

        - 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.

        -
          - {service.stack.map((item) => ( - - {item} - +
            + {service.examples.map((example) => ( +
          • {example}
          • ))}
      +
      +
      + + Detalhes técnicos e tecnologias + +
      +
        + {service.technicalDetails.map((detail) => ( +
      • {detail}
      • + ))} +
      +

      + Tecnologias consideradas neste serviço. A seleção depende do + problema e do ambiente; não é uma lista obrigatória para todo + projeto. +

      +
        + {service.stack.map((item) => ( +
      • + {item} +
      • + ))} +
      +
      +
      +
      @@ -226,11 +231,12 @@ export default async function ServicePage({ params }: ServicePageProps) {

      Casos relacionados

      - Projetos que reforçam esse tipo de entrega + Projetos para conhecer meu trabalho

      - 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.

@@ -246,7 +252,7 @@ export default async function ServicePage({ params }: ServicePageProps) {

    {caseStudy.stack.map((item) => ( -
  • +
  • {item}
  • ))} @@ -265,7 +271,7 @@ export default async function ServicePage({ params }: ServicePageProps) { target="_blank" rel="noreferrer" > - Abrir projeto + Abrir projeto: {caseStudy.title} ) : 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} ) : null}
@@ -290,12 +296,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
Próximo passo

- Se esse serviço parece aderente ao seu contexto, a conversa - inicial já pode ser objetiva. + Conte o que você precisa resolver.

- 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.

, OG_SIZE, ); diff --git a/app/servicos/page.tsx b/app/servicos/page.tsx index 3454b07..3fcca7f 100644 --- a/app/servicos/page.tsx +++ b/app/servicos/page.tsx @@ -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() { -

Hub de serviços

-

- Serviços de engenharia de software para contextos técnicos de alta - exigência. +

Serviços de desenvolvimento

+

+ Da presença na internet ao sistema que move seu negócio.

- {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.

@@ -94,25 +95,50 @@ export default function ServicesIndexPage() {
-
- {serviceHubLinks.map((service) => ( -
-
Serviço especializado
-

{service.label}

-

{service.description}

-
- - Abrir página - +
+

O que você precisa fazer?

+

+ 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. +

+
+
+ {serviceHubLinks.map((service, index) => ( +
+
+ +

+ + {service.label} + +

+

{service.description}

))}
+
+

+ Ainda não sabe qual caminho seguir? +

+

+ 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. +

+ + Conversar sobre minha ideia + +
diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index 8ded29e..1fc6fe2 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -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 }) => diff --git a/lib/site-content.ts b/lib/site-content.ts index 0ea7c9b..997e949 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -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) { diff --git a/public/assets/notebook/as-servicos.webp b/public/assets/notebook/as-servicos.webp new file mode 100644 index 0000000..3ca357a Binary files /dev/null and b/public/assets/notebook/as-servicos.webp differ diff --git a/public/assets/notebook/nutriscan.webp b/public/assets/notebook/nutriscan.webp new file mode 100644 index 0000000..5fb1d95 Binary files /dev/null and b/public/assets/notebook/nutriscan.webp differ diff --git a/public/assets/notebook/sc-plus.webp b/public/assets/notebook/sc-plus.webp new file mode 100644 index 0000000..0a5e5b6 Binary files /dev/null and b/public/assets/notebook/sc-plus.webp differ diff --git a/public/assets/notebook/sev-modaintima.webp b/public/assets/notebook/sev-modaintima.webp new file mode 100644 index 0000000..bcc3f70 Binary files /dev/null and b/public/assets/notebook/sev-modaintima.webp differ