diff --git a/.prettierignore b/.prettierignore
index 7c35acb..05e2689 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,7 @@
.next
node_modules
coverage
+.remember/**
+docs/uiux-assets/**
pnpm-lock.yaml
.agents/skills/**/SKILL.md
diff --git a/docs/uiux-analysis.md b/docs/uiux-analysis.md
new file mode 100644
index 0000000..edd89e1
--- /dev/null
+++ b/docs/uiux-analysis.md
@@ -0,0 +1,117 @@
+# Análise UI/UX — WebIngressos
+
+Data da análise: 2026-07-31
+Escopo: landing page pública e candidatura ao Programa Piloto.
+
+## Resumo executivo
+
+A landing já tinha uma base consistente: Next.js 16, React 19, Tailwind 4, HTML semântico básico, skip link, foco visível, formulário com React Hook Form + Zod, feedback via Sonner e ilustrações SVG leves.
+
+O foco desta rodada foi conversão da candidatura, sem trocar a identidade visual nem adicionar dependências de runtime. A proposta continua usando evergreen/mint e o motivo de ingresso/perfuração como assinatura própria; cinco assets locais foram adicionados de forma pontual como apoio visual.
+
+### Pontos principais corrigidos
+
+1. **Menu mobile sem ciclo de foco previsível** — o primeiro item agora recebe foco ao abrir e `Escape` fecha o menu e devolve foco ao botão.
+2. **Erros do formulário pouco anunciados** — mensagens individuais agora usam `role="alert"`; o envio e falhas de rede são anunciados por região viva.
+3. **Formulário sem agrupamento semântico** — contato e dados do evento agora são `fieldset`s com `legend` acessível.
+4. **Proposta de valor pouco contextualizada acima da dobra** — o hero informa imediatamente que se trata do programa piloto e explicita o prazo de retorno já descrito no FAQ.
+5. **Texto de eyebrow em brand-500** — pequenos textos de contexto foram movidos para brand-700, que tem contraste adequado em superfícies claras.
+
+## Evidências técnicas
+
+### Acessibilidade
+
+- O layout mantém `lang="pt-BR"`, skip link, landmarks principais, labels associados e controles com targets de toque adequados.
+- O foco global usa anel de 3px com offset de 3px; componentes que já possuem foco customizado continuam preservando seus estilos.
+- O `FormControl` mantém `aria-describedby` e `aria-invalid`; `FormMessage` anuncia erros com `role="alert"`.
+- A navegação mobile mantém `aria-expanded`, `aria-controls` e labels distintos para navegação principal e móvel.
+- O movimento reduzido continua coberto por `prefers-reduced-motion`.
+
+### Contraste calculado
+
+Os valores abaixo foram calculados diretamente a partir dos tokens CSS atuais; não são resultados estimados de uma ferramenta visual.
+
+| Combinação | Contraste | Referência WCAG AA |
+| ------------------------ | --------: | -----------------------------: |
+| brand-500 sobre branco | 3,38:1 | insuficiente para texto normal |
+| brand-700 sobre branco | 7,30:1 | aprovado |
+| ink-500 sobre ink-50 | 5,52:1 | aprovado |
+| destructive sobre branco | 5,06:1 | aprovado |
+
+Por isso, brand-500 permanece reservado para detalhes e elementos decorativos, enquanto textos pequenos de contexto usam brand-700.
+
+### Conversão e UX
+
+- O CTA primário continua levando ao formulário do piloto.
+- O hero agora explicita o público e o caráter de candidatura sem compromisso.
+- O formulário informa que a candidatura não garante vaga e que o retorno ocorre em até 48 horas úteis, conteúdo já presente no FAQ.
+- Em validação inválida, o foco é movido para o primeiro campo com erro.
+- Em falha de rede/API, a pessoa recebe feedback visual persistente e anúncio para tecnologia assistiva, além do toast existente.
+
+### Performance
+
+- Os cinco PNGs selecionados foram convertidos para WebP, redimensionados e adicionados em `public/assets/landing`; o conjunto servido soma 95.928 bytes.
+- O ingresso principal usa carregamento prioritário no hero; as imagens abaixo da dobra usam lazy loading e dimensões explícitas.
+- Os PNGs originais chegaram a ser arquivados para curadoria, mas o acervo `docs/uiux-assets` foi removido do repositório (ver Atualização).
+- O dashboard decorativo continua sendo SVG/CSS e permanece marcado como ilustrativo.
+- Não há métrica Lighthouse registrada nesta rodada; qualquer pontuação futura deve ser medida em ambiente de produção ou preview equivalente.
+
+### Assets adicionados
+
+- `ticket-event.webp`: assinatura visual do hero.
+- `mobile-ticket.webp`, `qr-code-ticket.webp` e `checkmark-ticket.webp`: sequência operacional de venda, check-in e confirmação.
+- `concert-ticket.webp`: apoio visual da seção de público universitário.
+
+Os arquivos têm licença de produção ainda não confirmada; a publicação comercial depende da validação da licença oficial do Envato.
+
+## Atualização (2026-07-31) — Auditoria de design
+
+Uma auditoria contra o `DESIGN.md` reprovou os cinco `.webp` adicionados nesta rodada:
+eram renders 3D glossy multicolor (azul/laranja/verde), violando a regra de ilustração
+"traço fino, uma cor, nunca preenchido/multicolor/fotográfico", introduzindo azul e
+laranja fora da paleta verde-única (o pior caso no hero, acima da dobra) e mantendo a
+licença Envato pendente.
+
+Ações aplicadas:
+
+- Os cinco `.webp` foram removidos de `public/assets/landing/` (diretório eliminado),
+ encerrando o risco de licença na aplicação servida.
+- **Hero**: o ingresso 3D flutuante foi removido; o `DashboardPreview` passa a ser o
+ foco visual único.
+- **Fluxo operacional**: deixou de ser uma terceira faixa `brand-900` (que competia com
+ a band de fechamento) e virou seção clara com cards brancos e ícones de traço verde
+ (`lucide`), preservando a numeração de sequência 01/02/03.
+- **Público**: o render 3D foi trocado pela ilustração de traço `TicketLine` em verde; o
+ painel de apoio deixou de ser verde (`bg-brand-50`) e passou a neutro (`bg-ink-100`),
+ para não criar uma terceira faixa verde entre a band do piloto e o CTA final.
+
+Em seguida, todo o acervo de referência `docs/uiux-assets` (~413 MB de pacotes Envato não referenciados pelo app) foi removido do repositório, encerrando de vez a exposição à licença pendente.
+
+### Performance e dependências
+
+Diferente da primeira rodada (que foi feita sem dependências de runtime), esta etapa de
+performance **adicionou duas dependências de runtime deliberadamente**, com necessidade
+demonstrada:
+
+- `@vercel/speed-insights` e `@vercel/analytics`, montados em `src/app/layout.tsx`. Como
+ não há Google Chrome nesta máquina para rodar Lighthouse, o Speed Insights é o caminho
+ para obter Core Web Vitals de campo reais após o deploy.
+- O formulário do piloto (abaixo da dobra) passou a ser carregado por `next/dynamic` com
+ `ssr: false` + `IntersectionObserver`, tirando a stack de validação (`zod` +
+ `react-hook-form` + `radix`) do first-load: **~338 KB → 222 KB gzip** no first-load da
+ home (medido no build).
+
+## Validação executada
+
+- `pnpm format:check`: passou.
+- `pnpm lint`: passou sem erros; existe um warning preexistente em `.remember/tmp/last-ndc.ts`.
+- `pnpm typecheck`: passou.
+- `pnpm build`: passou; rotas estáticas e `/api/subscribe` foram compiladas.
+- `curl http://localhost:3000/`: respondeu `200` com headers de segurança e HTML contendo o novo contexto do piloto, formulário e menu.
+- Contraste dos tokens principais: calculado e registrado acima.
+
+## Limitações e próximos passos
+
+- Ainda é necessário executar Lighthouse em uma URL servida e testar manualmente com teclado/leitor de tela em navegador real; a tentativa automatizada de abrir o navegador local expirou antes de permitir essa inspeção.
+- O acervo `docs/uiux-assets` foi removido do repositório; não há mais arquivos de licença pendente versionados.
+- Não foram alterados contratos da API, banco de dados ou implantação.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index d4eba46..f3b2f5c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -5,5 +5,5 @@ import nextTypeScript from "eslint-config-next/typescript"
export default defineConfig([
...nextVitals,
...nextTypeScript,
- globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
+ globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "docs/uiux-assets/**"]),
])
diff --git a/src/app/globals.css b/src/app/globals.css
index 469c4e6..33a4c9c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -53,7 +53,9 @@
--destructive-foreground: #ffffff;
--border: var(--ink-200);
--input: var(--ink-200);
- --ring: var(--brand-500);
+ --ring: var(--brand-600);
+ /* Single source for focus color: the 3px outline and component rings share it. */
+ --focus-ring: var(--ring);
--radius: 0.625rem;
}
@@ -74,6 +76,7 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
+ --color-focus-ring: var(--focus-ring);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -144,16 +147,45 @@
border-color: var(--color-border);
}
+ html {
+ scroll-padding-top: 5rem;
+ }
+
body {
background-color: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+
+ :where(a, button, input, select, textarea, [tabindex]):focus-visible {
+ outline: 3px solid var(--focus-ring);
+ outline-offset: 3px;
}
::selection {
background-color: var(--brand-100);
color: var(--brand-800);
}
+
+ a,
+ button {
+ touch-action: manipulation;
+ }
+}
+
+/* Respect prefers-reduced-motion for every transition/animation (accordion
+ height, header mobile menu slide, card hover-lift) instead of hand-rolling
+ a reduced variant per component. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
}
/* Decorative dot grid, tinted with `text-*`. Replaces the /grid.svg that never existed. */
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e873c85..ee791bc 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,7 @@
import type { Metadata, Viewport } from "next"
import { Plus_Jakarta_Sans } from "next/font/google"
+import { Analytics } from "@vercel/analytics/next"
+import { SpeedInsights } from "@vercel/speed-insights/next"
import "./globals.css"
import { Toaster } from "@/components/ui/sonner"
@@ -28,8 +30,16 @@ export default function RootLayout({
return (
+
+ Pular para o conteúdo
+
{children}
+
+
)
diff --git a/src/app/obrigado/page.tsx b/src/app/obrigado/page.tsx
index 6e3f457..b5d4a5f 100644
--- a/src/app/obrigado/page.tsx
+++ b/src/app/obrigado/page.tsx
@@ -5,7 +5,10 @@ import { Logo } from "@/components/brand/logo"
export default function ThankYouPage() {
return (
-
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index e19d93f..695892d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -4,25 +4,27 @@ import { Problems } from "@/components/landing/problems"
import { Pillars } from "@/components/landing/pillars"
import { TargetAudience } from "@/components/landing/target-audience"
import { PilotBand } from "@/components/landing/pilot-band"
-import { PilotForm } from "@/components/landing/pilot-form"
import { Faq } from "@/components/landing/faq"
import { FinalCta } from "@/components/landing/final-cta"
import { Footer } from "@/components/landing/footer"
+import { OperationalFlow } from "@/components/landing/operational-flow"
+import { PilotFormLazy } from "@/components/landing/pilot-form-lazy"
import { Section } from "@/components/landing/section"
export default function Home() {
return (
<>
-
+
+
-
+
diff --git a/src/app/privacidade/page.tsx b/src/app/privacidade/page.tsx
index d77d43b..672ea84 100644
--- a/src/app/privacidade/page.tsx
+++ b/src/app/privacidade/page.tsx
@@ -3,7 +3,7 @@ import { ArrowLeft } from "lucide-react"
export default function PrivacyPolicyPage() {
return (
-
+
{/* Explicit breaks from lg up so the three lines land exactly as in
the reference design; below lg the copy wraps naturally. */}
@@ -38,9 +41,16 @@ export function Hero() {
Conhecer a proposta
+
+
+ Candidatura sem compromisso. Retornamos em até 48 horas úteis para entender o seu
+ evento.
+
-
+
+
+
diff --git a/src/components/landing/operational-flow.tsx b/src/components/landing/operational-flow.tsx
new file mode 100644
index 0000000..5c29974
--- /dev/null
+++ b/src/components/landing/operational-flow.tsx
@@ -0,0 +1,52 @@
+import { QrCode, Smartphone, TicketCheck } from "lucide-react"
+
+import { Section, SectionHeading } from "@/components/landing/section"
+
+const FLOW_STEPS = [
+ {
+ title: "Ingresso digital",
+ description: "A pessoa compra e acessa o ingresso pelo celular, sem fila e sem papel.",
+ icon: Smartphone,
+ },
+ {
+ title: "Check-in por QR Code",
+ description: "A equipe valida a entrada com rapidez e clareza na porta do evento.",
+ icon: QrCode,
+ },
+ {
+ title: "Confirmação da entrada",
+ description: "O evento acompanha em tempo real o público que já entrou.",
+ icon: TicketCheck,
+ },
+] as const
+
+export function OperationalFlow() {
+ return (
+
+
+
+
+ {FLOW_STEPS.map((step, index) => (
+
diff --git a/src/components/landing/pilot-form-lazy.tsx b/src/components/landing/pilot-form-lazy.tsx
new file mode 100644
index 0000000..160e16c
--- /dev/null
+++ b/src/components/landing/pilot-form-lazy.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import * as React from "react"
+import dynamic from "next/dynamic"
+
+/**
+ * Placeholder that mirrors the form's card container and reserves height, so the
+ * swap to the real form doesn't shift the page. Kept intentionally quiet.
+ */
+function FormSkeleton() {
+ return (
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+// ssr:false keeps the validation stack (zod + react-hook-form + radix) out of
+// the home's first-load JS entirely; it loads as an async chunk on approach.
+const PilotForm = dynamic(
+ () => import("@/components/landing/pilot-form").then((m) => m.PilotForm),
+ { ssr: false, loading: () => },
+)
+
+export function PilotFormLazy() {
+ const ref = React.useRef(null)
+ const [show, setShow] = React.useState(false)
+
+ React.useEffect(() => {
+ const el = ref.current
+ if (!el || show) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setShow(true)
+ observer.disconnect()
+ }
+ },
+ { rootMargin: "600px" },
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [show])
+
+ return
{show ? : }
+}
diff --git a/src/components/landing/pilot-form.tsx b/src/components/landing/pilot-form.tsx
index e7d9757..83e61b0 100644
--- a/src/components/landing/pilot-form.tsx
+++ b/src/components/landing/pilot-form.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
-import { useForm } from "react-hook-form"
+import { useForm, type FieldErrors } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowRight, Loader2 } from "lucide-react"
import { toast } from "sonner"
@@ -57,6 +57,7 @@ const UF_OPTIONS = toOptions(UFS, UF_LABELS)
export function PilotForm() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [submitError, setSubmitError] = React.useState(null)
const form = useForm({
resolver: zodResolver(pilotFormSchema),
@@ -74,6 +75,7 @@ export function PilotForm() {
async function onSubmit(data: PilotFormData) {
setIsSubmitting(true)
+ setSubmitError(null)
try {
const res = await fetch("/api/subscribe", {
method: "POST",
@@ -89,6 +91,7 @@ export function PilotForm() {
form.reset()
router.push("/obrigado")
} catch {
+ setSubmitError("Não foi possível enviar agora. Tente novamente em alguns minutos.")
toast.error("Erro ao enviar candidatura", {
description: "Tente novamente em alguns minutos ou entre em contato diretamente.",
})
@@ -97,153 +100,190 @@ export function PilotForm() {
}
}
+ function onInvalid(errors: FieldErrors) {
+ const firstError = Object.keys(errors)[0] as keyof PilotFormData | undefined
+ if (firstError) {
+ window.requestAnimationFrame(() => form.setFocus(firstError))
+ }
+ }
+
return (
Candidate seu evento
+
+ Conte um pouco sobre a sua operação. A candidatura não garante vaga; retornamos em até 48
+ horas úteis para conversar sobre data, público e formato.
+