| name | frontend-nextjs-shadcn |
| description | Use ao implementar frontend em Next.js com shadcn/ui, quando project.config.md indicar esta stack. Cobre estrutura de pastas, convenção de hooks, uso de componentes shadcn/ui, e padrões específicos do App Router. |
Frontend: Next.js (App Router) + shadcn/ui
Convenção de nomenclatura completa em
examples/naming-conventions/frontend-nextjs-typescript.md — leia antes
de nomear arquivos, componentes ou hooks. Exemplo de árvore de pastas
completa em examples/folder-structures/frontend-nextjs-shadcn.md.
Qualidade e contratos de código (Biome, knip, dependency-cruiser)
Regra universal em CLAUDE.md. Configurar na primeira entrega do projeto.
Arquivos de exemplo em examples/quality/.
Biome — lint + format (substitui ESLint + Prettier)
npm install --save-dev @biomejs/biome
cp examples/quality/biome.json .
{
"scripts": {
"lint": "biome lint .",
"format": "biome format --write .",
"check": "biome check .",
"ci:quality": "biome ci ."
}
}
No CI: npm run ci:quality — falha com exit 1 se houver lint ou formatação incorreta.
knip — detectar código morto
npm install --save-dev knip
cp examples/quality/knip.config.ts .
{ "scripts": { "knip": "knip" } }
dependency-cruiser — contratos arquiteturais
npm install --save-dev dependency-cruiser
cp examples/quality/.dependency-cruiser.js .
{
"scripts": {
"arch:check": "depcruise src --config .dependency-cruiser.js",
"arch:diagram": "depcruise src --output-type dot | dot -T svg > architecture.svg"
}
}
Valida em CI que shared/ nunca importa de features/, que features/
não se importam diretamente entre si, e que não há dependências circulares.
commitlint + husky — Conventional Commits
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
cp examples/quality/commitlint.config.js .
⚠️ Verificar versão antes de implementar
npm show next version
npm show @shadcn/ui version
cat package.json | grep next
Consultar https://ui.shadcn.com/docs para componentes atualizados.
Se a versão instalada diferir da atual, verificar breaking changes
(especialmente App Router vs Pages Router e mudanças de API).
Estrutura de pastas
src/
app/ # rotas Next.js (App Router)
(shell)/ # layout autenticado
layout.tsx
processos/page.tsx # cada feature tem sua rota
usuarios/page.tsx
page.tsx # rota raiz = login
components/
layout/ # AppShell, Header, Sidebar, Footer
shared/
ui/ # componentes visuais sem lógica de domínio
status-badge.tsx # StatusBadge, Skeleton, EmptyState, ErrorState
skeleton-table.tsx
empty-state.tsx
error-state.tsx
modal.tsx # Modal/Dialog genérico
confirm-delete.tsx # Confirmação de exclusão
forms/ # componentes de formulário reutilizáveis
autocomplete-create.tsx # AutocompleteCreate (já implementado)
rich-text-editor.tsx # RichTextEditor (já implementado)
hooks/ # hooks sem vínculo com domínio
use-local-storage.ts # useLocalStorage
use-pagination.ts # usePagination
use-debounce.ts # useDebounce
features/ # componentes e hooks por domínio
processo/
components/ # componentes específicos da feature
hooks/ # useProcessos, useProcesso, etc.
types.ts # interfaces e types da feature
usuario/
components/
hooks/
types.ts
lib/ # utilitários puros (sem JSX, sem hooks)
Regra de decisão shared/ vs features/:
- Pode ser usado em mais de uma feature →
shared/ui/, shared/forms/ ou shared/hooks/
- Específico de uma entidade →
features/<entidade>/
- Componente
shared/ nunca importa de features/
Convenções específicas
- Instalar componente via
npx shadcn add <componente> antes de
implementar algo do zero — verificar primeiro se já existe equivalente
em /components/ui.
- Componentes de UI consomem hooks (
useProcesso(), etc.), nunca fazem
fetch direto via useEffect + fetch solto no componente.
- Tratamento de erro e loading: todo hook expõe
{ data, isLoading, error }
ou equivalente — componente sempre trata os três estados visivelmente.
- Formato de erro de API: ler
project.config.md (seção "Padrão de
comunicação") para o formato JSON de erro e tratar de forma consistente
em todos os hooks (ex: um apiClient central que já normaliza o erro).
- Server Components por padrão (App Router); usar
"use client" apenas
quando houver interatividade real (estado, evento, hook de navegador).
- Acessibilidade: shadcn/ui já usa Radix por baixo (ARIA correto) — não
sobrescrever atributos ARIA gerados pelos primitivos sem motivo forte.
Formulário de CRUD: componente autônomo (página ou modal)
O formulário de criação/edição nunca sabe se está numa página ou num
Dialog do shadcn/ui — ele só recebe props:
interface ProcessoFormProps {
initialData?: Processo;
onSubmit: (data: ProcessoFormData) => void;
onCancel: () => void;
}
export function ProcessoForm({ initialData, onSubmit, onCancel }: ProcessoFormProps) {
}
Uso em página:
<ProcessoForm onSubmit={salvar} onCancel={() => router.back()} />
Uso em modal:
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<ProcessoForm onSubmit={salvar} onCancel={() => setOpen(false)} />
</DialogContent>
</Dialog>
A decisão de página-vs-modal fica no componente que envolve o formulário,
nunca dentro do ProcessoForm — ver ux.md da feature para qual dos dois
foi decidido.
Autocomplete/Combobox com criação inline
Padrão universal definido no CLAUDE.md. Use quando ux.md indicar campo
de autocomplete. O comportamento é o mesmo em qualquer tecnologia — esta
seção descreve apenas a implementação específica desta stack.
Use o componente <Command> do shadcn/ui:
interface AutocompleteCreateProps<T extends { id: string; codigo: string; descricao: string }> {
value: T | null;
onChange: (item: T) => void;
onSearch: (query: string) => Promise<T[]>;
onCreateNew?: (descricao: string) => Promise<T>;
placeholder?: string;
}
export function AutocompleteCreate<T extends { id: string; codigo: string; descricao: string }>({
value, onChange, onSearch, onCreateNew, placeholder = "Buscar..."
}: AutocompleteCreateProps<T>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [items, setItems] = useState<T[]>([]);
const [isCreating, setIsCreating] = ();
( {
timer = ( () => {
(query. >= ) ( (query));
}, );
(timer);
}, [query]);
exactMatch = items.( i..() === query.());
(
</>
);
}
Endpoint de busca: GET /api/v1/<entidade>/autocomplete?q=<texto>&limit=20
Retorno: [{ id, codigo, descricao }] — ILIKE no Postgres com índice na coluna.
Indicador de navegação entre rotas (barra no topo + item com loading)
Padrão universal em CLAUDE.md: dois indicadores obrigatórios juntos.
Implementar no AppShell — não por feature.
Lib: nprogress
npm install nprogress && npm install --save-dev @types/nprogress
'use client'
import { useEffect, useState } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false, minimum: 0.15, speed: 300 })
let _setNavigatingTo: ((path: string | null) => void) | null = null
export function useNavigatingTo() {
const [navigatingTo, setNavigatingTo] = useState<string | null>(null)
useEffect(() => { _setNavigatingTo = setNavigatingTo }, [])
return navigatingTo
}
export function NavigationProgress() {
const pathname = usePathname()
const searchParams = ()
( {
.()
_setNavigatingTo?.()
}, [pathname, searchParams])
}
() {
.()
_setNavigatingTo?.(path)
}
#nprogress .bar { background: hsl(var(--primary)); height: 3px; }
#nprogress .peg { box-shadow: 0 0 10px hsl(var(--primary)); }
#nprogress .spinner { display: none; }
'use client'
import { Loader2 } from 'lucide-react'
import { startNavigation, useNavigatingTo } from './navigation-progress'
function SidebarNavItem({ item }: { item: NavItem }) {
const navigatingTo = useNavigatingTo()
const isLoading = navigatingTo === item.path
return (
<Link
href={item.path}
onClick={() => startNavigation(item.path)}
aria-busy={isLoading}
className={cn(
'flex items-center gap-2 p-2 rounded hover:bg-accent transition-opacity',
isLoading && 'opacity-70 pointer-events-none' // bloqueia duplo clique
)}
>
{isLoading
? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
: <Icon name={item.icon} className="h-4 w-4" aria-hidden="true" />
}
<span>{item.label}</span>
</Link>
)
}
import { Suspense } from 'react'
import { NavigationProgress } from '@/components/layout/navigation-progress'
export default function ShellLayout({ children }) {
return (
<div className="flex flex-col h-screen">
<Suspense><NavigationProgress /></Suspense>
<AppHeader ... />
...
</div>
)
}
Interceptor HTTP automático — barra dispara em toda chamada fetch:
import { startNavigation, doneNavigation } from '@/components/layout/navigation-progress'
let activeRequests = 0
export async function fetchWithProgress(input: RequestInfo, init?: RequestInit): Promise<Response> {
activeRequests++
if (activeRequests === 1) startNavigation('')
try {
return await fetch(input, init)
} finally {
activeRequests--
if (activeRequests === 0) doneNavigation()
}
}
export const startNavigation = (path: string) => {
NProgress.start()
_setNavigatingTo?.(path)
}
export const doneNavigation = () => {
NProgress.done()
_setNavigatingTo?.(null)
}
import { fetchWithProgress } from '@/lib/fetch-with-progress'
export function useProcessos() {
async function pesquisar(filtros: FiltroProcesso) {
const res = await fetchWithProgress('/api/v1/processos?' + new URLSearchParams(filtros as any))
if (!res.ok) throw new Error(await res.text())
return res.json()
}
return { pesquisar }
}
Qualquer link de navegação (não só menu) dispara a barra:
import { startNavigation } from '@/components/layout/navigation-progress'
export function NavLink({ href, children, ...props }: LinkProps) {
return (
<Link href={href} onClick={() => startNavigation(href as string)} {...props}>
{children}
</Link>
)
}
Sistema de notificações toast
Padrão universal em CLAUDE.md. Implementar no AppShell — não por feature.
Lib: Sonner (já vem com shadcn/ui)
npx shadcn@latest add sonner
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
export const notify = {
sucesso: (msg: string, desc?: string) => toast.success(msg, { description: desc, duration: 4000 }),
erro: (msg: string, desc?: string) => toast.error(msg, { description: desc, duration: 8000 }),
aviso: (msg: string, desc?: string) => toast.warning(msg, { description: desc, duration: 6000 }),
info: (msg: string, desc?: string) => toast.info(msg, { description: desc, : }),
}
richColors aplica cores e ícones (✅ ❌ ⚠️ ℹ️) automaticamente.
Relação com o UX Designer
- Antes de implementar uma tela nova, leia
specs/<feature>/ux.md (gerado
pelo subagent ux-designer) para fluxo, estados de tela (vazio, erro,
carregando) e hierarquia visual — não improvise layout sem esse
documento quando ele existir.
Dois Dockerfiles por serviço
Antes de criar ou atualizar qualquer Dockerfile, leia os exemplos em
examples/docker/frontend/ — são os arquivos de referência deste projeto.
Dockerfile — produção: multi-stage, imagem final com apenas o output
do next build (standalone + static). Ver
examples/docker/frontend/Dockerfile.
Dockerfile.dev — desenvolvimento: imagem Node completa, não copia
código (vem do volume), roda npm install && npm run dev na
inicialização. Ver examples/docker/frontend/Dockerfile.dev.
.dockerignore (obrigatório, criado junto com o Dockerfile)
node_modules
.next
out
.env
.env.*
*.log
.git
Comandos sempre via container
docker compose run --rm frontend npm install
docker compose run --rm frontend npm run lint
docker compose run --rm frontend npm run build
Mesma regra do backend: se algo só funciona instalando na máquina do
desenvolvedor, o ambiente containerizado está incompleto.
Acessibilidade (WCAG 2.2 AA — obrigatório, não opcional)
Leia .claude/skills/accessibility/SKILL.md antes de implementar qualquer
tela nova. O resumo abaixo cobre os pontos mais críticos na prática com
shadcn/ui, mas a skill tem o padrão completo.
O que o Radix/shadcn já entrega (não refazer):
- ARIA roles em todos os componentes interativos
- Gerenciamento de foco em Dialog, Modal, DropdownMenu
Escape fecha dropdown/dialog
aria-expanded em Accordion, Collapsible, Select
O que você ainda precisa garantir:
<label> associado a cada campo — nunca depender só de placeholder
- Mensagens de erro vinculadas via
aria-describedby
alt descritivo em imagens informativas; alt="" em decorativas
aria-label em botões de ícone sem texto visível
aria-live="polite" em resultados de busca e mensagens de status que
atualizam sem recarregar
- Nunca adicionar
* { outline: none } — remove foco de teclado de tudo
- Nunca usar
autocomplete="off" em campos de senha — bloqueia gerenciador
de senhas
- Área de toque mínima de 24x24px (idealmente 44x44px) em botões de ícone
- Não sobrescrever cores com valores de baixo contraste — usar variáveis CSS
do tema shadcn
Para sistemas públicos governamentais — verificar se eMAG é necessário
(ver skill de acessibilidade).
UI assíncrona por padrão (obrigatório em todo componente interativo)
Toda ação que chama o backend segue este padrão — não é opcional por
componente.
Hook expõe estados, componente reflete
const { data, isLoading, isSubmitting, error, search } = useProcessos();
Botão de qualquer ação
<Button
onClick={handlePesquisar}
disabled={isLoading || isSubmitting}
>
{isLoading ? (
<><Spinner className="mr-2 h-4 w-4" /> Aguarde...</>
) : (
'Pesquisar'
)}
</Button>
Regra: nunca um botão que dispara ação para o backend permanece
habilitado enquanto a operação está em andamento. Isso previne:
- Duplo clique criando dois objetos
- Dupla pesquisa com resultados se sobrepondo
- Duplo submit de formulário
Formulário durante envio
<fieldset disabled={isSubmitting}> {}
<Input name="descricao" />
<Select name="categoria" />
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</fieldset>
Grid com estados visualmente distintos
function ProcessoGrid({ state, data, meta }: Props) {
if (state.isLoading) {
return <GridSkeleton rows={state.pageSize} />;
}
if (state.hasError) {
return (
<GridError
message={state.error}
onRetry={state.retry} // sempre oferecer retry
/>
);
}
if (!data.length) {
return <GridEmpty message="Nenhum resultado encontrado" />;
}
return (
<DataTable data={data} columns={columns} />
);
}
Os quatro estados são obrigatórios e visualmente distintos:
isLoading → skeleton rows (usuário sabe que está carregando)
hasError → mensagem + botão de tentar novamente
isEmpty → mensagem "sem resultados" (não vazio em branco)
hasData → tabela/grid real
Construtor de testes E2E deve verificar estes estados
O construtor-testes-e2e verifica explicitamente:
- Botão desabilitado durante carregamento (
expect(button).toBeDisabled())
- Grid mostra loading antes do resultado (
expect(skeleton).toBeVisible())
- Estado vazio diferente de loading (
expect(emptyMessage).toBeVisible())
Grid de listagem: paginação e ordenação (obrigatório, ver CLAUDE.md)
Três comportamentos sempre juntos: clique alterna asc/desc, ordenação
padrão garantida, e estado persistido entre visitas.
Hook de estado do grid (URL + localStorage combinados)
Três regras de ouro:
- Grid não executa busca automática ao carregar — só após o usuário clicar em "Pesquisar". Isso evita requisição desnecessária quando o usuário ainda quer ajustar os filtros.
- Filtros e critérios de ordenação voltam preenchidos ao retornar à tela — restaurados do localStorage antes de qualquer interação do usuário.
- URL reflete o estado atual — página compartilhável com os mesmos filtros.
A URL é a fonte de verdade para compartilhar; o localStorage faz os filtros voltarem preenchidos quando o usuário retorna à tela. Regra de reconciliação: se a URL já tem parâmetros explícitos (link compartilhado), a URL vence; se a URL está "limpa" (navegação normal), hidrata a partir do localStorage; se não há nada em nenhum dos dois, usa os defaults de design.md.
const STORAGE_KEY_PREFIX = 'grid-state:';
interface GridState {
filtros: Record<string, string>;
sort: string;
order: 'asc' | 'desc';
page: number;
pageSize: number;
}
export function useGridState(feature: string, defaults: GridState) {
const router = useRouter();
const searchParams = useSearchParams();
const storageKey = STORAGE_KEY_PREFIX + feature;
const [state, setState] = useState<GridState>(() => {
if (searchParams.toString()) {
return parseParamsOrDefaults(searchParams, defaults);
}
const salvo = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null;
return salvo ? .(salvo) : defaults;
});
() {
novo = { ...state, ...partial };
(novo);
.(storageKey, .(novo));
router.(, { : });
}
() {
(state. === coluna) {
({ : coluna, : state. === ? : });
} {
({ : coluna, : , : });
}
}
() {
({ filtros, : });
}
{ state, toggleSort, updateFiltros, updateState };
}
Cabeçalho de coluna clicável
function ColumnHeader({ coluna, label, state, onSort }: ColumnHeaderProps) {
const ativo = state.sort === coluna;
return (
<TableHead
role="button"
tabIndex={0}
onClick={() => onSort(coluna)}
aria-sort={ativo ? (state.order === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{label} {ativo && (state.order === 'asc' ? '↑' : '↓')}
</TableHead>
);
}
Defaults vêm de design.md, nunca inventados no componente
const defaults: GridState = {
filtros: {},
sort: 'criado_em',
order: 'desc',
page: 1,
pageSize: 20,
};
Paginação resiliente a estado obsoleto
Se o localStorage tiver uma página salva (ex: página 5) mas a busca
atual só retornar 2 páginas (meta.total_pages), o componente clampa
para a última página válida em vez de mostrar grid vazio — nunca confiar
ciegamente no número salvo sem validar contra a resposta real da API.
AppShell (estrutura única, criada na primeira funcionalidade visual)
app/
page.tsx # rota raiz = LOGIN, não a aplicação
(shell)/
layout.tsx # combina header + sidebar + conteúdo + footer
dashboard/page.tsx
processos/page.tsx
...
components/
shell/
app-shell.tsx # layout root
app-header.tsx # logo + título + toggle + info do usuário
app-sidebar.tsx # menu hierárquico (accordion 2 níveis)
app-footer.tsx # rodapé fixo
sidebar-group.tsx # grupo expansível do menu
nav-config.ts # configuração do menu (não hardcoded nos componentes)
Header
export function AppHeader({ onToggleSidebar, sidebarOpen }: HeaderProps) {
return (
<header className="h-16 border-b flex items-center px-4 gap-4">
<Button variant="ghost" size="icon" onClick={onToggleSidebar}
aria-label={sidebarOpen ? "Recolher menu" : "Expandir menu"}>
<Menu className="h-5 w-5" />
</Button>
<img src="/logo.svg" alt="Logo" className="h-8 w-8" />
<span className="font-semibold text-lg">{SYSTEM_NAME}</span>
<div className="ml-auto flex items-center gap-2">
{/* nome do usuário + avatar + dropdown de logout */}
</div>
</header>
);
}
Menu hierárquico (2 níveis com accordion)
export const NAV_CONFIG: NavGroup[] = [
{
label: "Cadastros", icon: "users",
items: [
{ label: "Clientes", path: "/app/clientes" },
{ label: "Fornecedores", path: "/app/fornecedores" },
]
},
{
label: "Tabelas Acessórias", icon: "table",
items: [
{ label: "Categorias", path: "/app/categorias" },
]
},
];
function SidebarGroup({ group, collapsed }: { group: NavGroup, collapsed: boolean }) {
return (
<Collapsible defaultOpen>
<CollapsibleTrigger className="flex items-center gap-2 w-full p-2">
<Icon name={group.icon} />
{!collapsed && <span>{group.label}</span>}
{!collapsed && <ChevronDown className="ml-auto" />}
{group.items.map(item => (
{item.label}
))}
);
}
Quando sidebar recolhida: mostrar apenas ícones dos grupos. Ao hover,
mostrar tooltip com o nome do grupo.
Rodapé
export function AppFooter() {
return (
<footer className="h-10 border-t flex items-center justify-center px-4 text-sm text-muted-foreground">
{SYSTEM_NAME} v{VERSION} — {new Date().getFullYear()}
{/* Para DSGOV: links obrigatórios do Padrão Digital de Governo */}
</footer>
);
}
Layout do shell
export default function ShellLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex flex-col h-screen">
<AppHeader onToggleSidebar={() => setSidebarOpen(v => !v)} sidebarOpen={sidebarOpen} />
<div className="flex flex-1 overflow-hidden">
<AppSidebar open={sidebarOpen} navConfig={NAV_CONFIG} />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
<AppFooter />
</div>
);
}
- Login (
app/page.tsx) não usa o ShellLayout — é página isolada.
Versão da aplicação no frontend
Rodapé com versão (injetada em build time via variável de ambiente):
export function AppFooter() {
return (
<footer className="h-10 border-t flex items-center justify-center
px-4 text-sm text-muted-foreground gap-4">
<span>{process.env.NEXT_PUBLIC_APP_NAME}</span>
<span>·</span>
<span>v{process.env.NEXT_PUBLIC_APP_VERSION ?? 'dev'}</span>
<span>·</span>
<span>{new Date().getFullYear()}</span>
<span>·</span>
<Link href="/sobre" className="hover:underline">Sobre</Link>
</footer>
)
}
Variáveis de ambiente (.env.example):
NEXT_PUBLIC_APP_NAME=Nome do Sistema
NEXT_PUBLIC_APP_VERSION=
NEXT_PUBLIC_BUILD_DATE=
NEXT_PUBLIC_GIT_COMMIT=
Página /sobre (pública, sem autenticação):
export default async function SobrePage() {
const api = await fetch(`${process.env.BACKEND_URL}/version`)
.then(r => r.json()).catch(() => null)
return (
<main className="flex items-center justify-center min-h-screen">
<div className="border rounded-lg p-8 max-w-sm w-full space-y-2 text-sm">
<h1 className="text-2xl font-semibold mb-4">
{process.env.NEXT_PUBLIC_APP_NAME}
</h1>
{[
['Frontend', `v${process.env.NEXT_PUBLIC_APP_VERSION ?? 'dev'}`],
['Backend', api?.backend ?? '–'],
['Build', process.env.NEXT_PUBLIC_BUILD_DATE ?? '–'],
['Commit', process.env.NEXT_PUBLIC_GIT_COMMIT ?? '–'],
].map(([label, value]) => (
<div key={label} className="flex justify-between">
<span className="text-muted-foreground">{label}</span>
<span className=>{value}
))}
)
}
Loading por linha no grid (padrão obrigatório)
Padrão universal em CLAUDE.md. Rastrear qual ID está sendo processado,
nunca um boolean global — cada linha tem seu próprio estado.
export function useProcessos() {
const [deletingId, setDeletingId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
async function excluir(id: string) {
setDeletingId(id)
try {
await fetchWithProgress(`/api/v1/processos/${id}`, { method: 'DELETE' })
notify.sucesso('Processo excluído')
await recarregar()
} catch (e) {
notify.erro('Erro ao excluir')
} finally {
setDeletingId(null)
}
}
async function abrirEdicao(id: string) {
setEditingId(id)
await router.push(`/processos/${id}/editar`)
()
}
{ deletingId, editingId, excluir, abrirEdicao }
}
function ProcessoRow({ row }: { row: Processo }) {
const { deletingId, editingId, excluir, abrirEdicao } = useProcessos()
return (
<tr>
<td>{row.descricao}</td>
<td>
<LoadingButton
variant="ghost"
size="icon"
loading={editingId === row.id}
loadingText="" // ícone spinner substitui o ícone de editar
aria-label="Editar processo"
onClick={() => abrirEdicao(row.id)}
>
<Pencil className="h-4 w-4" />
</LoadingButton>
<LoadingButton
variant="ghost"
size="icon"
loading={deletingId === row.id}
loadingText=""
=
= => excluir(row.id)}
>
)
}
O spinner substitui o ícone durante o loading — mesma área, sem
deslocar o layout da tabela. A barra superior dispara automaticamente
via fetchWithProgress (exclusão) ou startNavigation (edição).
APP_ENV — configuração por ambiente (CSP prático para shadcn/ui)
Regra universal em CLAUDE.md. A configuração abaixo é a que
realmente funciona com Next.js + Tailwind + Radix/shadcn.
Por que style-src 'unsafe-inline' é necessário e aceitável
Tailwind (via @layer), Radix UI e shadcn/ui injetam estilos inline em
runtime — é parte do funcionamento desses frameworks. Remover
'unsafe-inline' do style-src quebra a aplicação completamente.
Isso é um tradeoff aceitável porque:
- CSS injection tem vetor de ataque muito mais restrito que JS injection
- A proteção que importa é no
script-src — bloquear JS malicioso
- O CSP abaixo usa nonce para scripts, que é a defesa real contra XSS
Nonce por requisição — proteção real do script-src
Em vez de 'unsafe-inline' em scripts (que anula a proteção), usar
nonce criptográfico gerado por request. O Next.js 14+ suporta isso
nativamente via middleware:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import crypto from 'crypto'
export function middleware(request: NextRequest) {
const isProd = process.env.APP_ENV === 'production'
if (!isProd) return NextResponse.next()
const nonce = crypto.randomBytes(16).toString('base64')
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
].join('; ')
const response = NextResponse.({
: { : (request.) },
})
response..(, csp)
response..(, )
response..(, )
response..(, )
response..(, )
response..(, )
response..(, nonce)
response
}
config = {
: [],
}
import { headers } from 'next/headers'
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce') ?? ''
return (
<html>
<body>
{children}
{/* Scripts inline precisam do nonce — sem ele o CSP bloqueia */}
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: '' }} />
</body>
</html>
)
}
const isProd = process.env.APP_ENV === 'production'
const nextConfig = {
productionBrowserSourceMaps: false,
async headers() {
if (isProd) return []
return [{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
],
}]
},
}
export default nextConfig
O que o CSP bloqueia vs. o que aceita
✅ Bloqueia: JS inline sem nonce (<script>código malicioso</script>)
✅ Bloqueia: JS de domínio externo (cdn.atacante.com/xss.js)
✅ Bloqueia: iframes de outros domínios (frame-ancestors 'none')
⚠️ Aceita: CSS inline (Tailwind/Radix) — tradeoff consciente
✅ Mitiga CSS: sem 'unsafe-eval' + headers X-Frame-Options + HSTS
APP_ENV=development
NEXT_PUBLIC_APP_ENV=development
'use client'
import { useEffect } from 'react'
export function EnvGuard() {
useEffect(() => {
if (process.env.NEXT_PUBLIC_APP_ENV !== 'production') return
const handler = () => {
const start = performance.now()
debugger
if (performance.now() - start > 100) {
document.body.innerHTML = ''
}
}
const interval = setInterval(handler, 1000)
return () => clearInterval(interval)
}, [])
return null
}
APP_ENV=development
NEXT_PUBLIC_APP_ENV=development
Em produção o CI injeta APP_ENV=production e NEXT_PUBLIC_APP_ENV=production.
Autenticação — regra de segurança obrigatória
JWT nunca vai para localStorage ou sessionStorage.
O token é armazenado em cookie HttpOnly pelo backend — o frontend
não toca no token diretamente. O cookie é enviado automaticamente
pelo browser em cada requisição.
localStorage.setItem('token', jwt)
const token = localStorage.getItem('token')
headers: { Authorization: `Bearer ${token}` }
await fetchWithProgress('/api/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ email, senha }),
})
await fetchWithProgress('/api/v1/processos', {
credentials: 'include',
})
await fetchWithProgress('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
O fetchWithProgress deve sempre incluir credentials: 'include'
nas chamadas autenticadas. O interceptor HTTP automático pode setar
isso globalmente se toda a aplicação for autenticada.
LoadingButton — componente obrigatório em shared/ui/
Padrão universal em CLAUDE.md. Todo botão que dispara operação assíncrona
usa este componente — nunca <Button disabled={loading}> ad-hoc sem visual.
import { forwardRef } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, ButtonProps } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface LoadingButtonProps extends ButtonProps {
loading?: boolean
loadingText?: string
}
const LoadingButton = forwardRef<HTMLButtonElement, LoadingButtonProps>(
({ loading = false, loadingText, children, disabled, className, ...props }, ref) => (
<Button
ref={ref}
disabled={disabled || loading}
className={cn(loading && 'opacity-75', className)}
{...props}
>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
{loading && loadingText ? loadingText : children}
)
)
. =
{ }
Uso nos formulários e ações:
<LoadingButton
type="submit"
loading={isSubmitting}
loadingText="Salvando..."
>
Salvar
</LoadingButton>
<LoadingButton
loading={isLoading}
loadingText="Pesquisando..."
onClick={pesquisar}
>
Pesquisar
</LoadingButton>
<LoadingButton
variant="destructive"
loading={isDeleting}
loadingText="Excluindo..."
onClick={() => excluir(id)}
>
Excluir
</LoadingButton>
Acessibilidade automática:
disabled bloqueia clique e é anunciado por screen readers
- O spinner tem
aria-hidden="true" — o texto já comunica o estado
- Para ações longas, adicionar
aria-live="polite" no container pai
Editor de texto rico (WYSIWYG)
Usar quando ux.md indicar campo com editor rico. Regras universais
(quando usar, sanitização obrigatória no backend, acessibilidade)
em CLAUDE.md → "Editor de texto rico".
Lib: TipTap (@tiptap/react) — acessível, extensível, WAI-ARIA correto.
Suporta saída em HTML ou Markdown — definir por feature no spec.md.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit \
@tiptap/extension-link @tiptap/extension-image \
@tiptap/extension-markdown
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
interface RichTextEditorProps {
value: string
onChange: (html: string) => void
outputFormat?: 'html' | 'markdown'
placeholder?: string
disabled?: boolean
}
export function RichTextEditor({
value, onChange, outputFormat = 'html', placeholder, disabled
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({ openOnClick: false }),
],
content: value,
editable: !disabled,
onUpdate: ({ editor }) => {
out = outputFormat ===
? editor..?.?.() ?? editor.()
: editor.()
(out)
},
})
(
)
}
Integrar com react-hook-form:
<Controller name="descricao" control={control}
render={({ field }) =>
<RichTextEditor value={field.value} onChange={field.onChange} outputFormat="html" />
}
/>
O backend sanitiza antes de persistir — ver CLAUDE.md "Editor de texto rico".
sanitização equivalente no lado do cliente também.
Skeleton, lazy loading e animações (Framer Motion)
Padrão universal em CLAUDE.md. Implementar em todo componente com conteúdo assíncrono.
npm install framer-motion
Skeleton — componente em shared/ui/
import { cn } from '@/lib/utils'
export function Skeleton({ className }: { className?: string }) {
return (
<div className={cn(
'animate-pulse rounded-md bg-muted',
className
)} />
)
}
export function SkeletonTableRow() {
return (
<tr className="border-b">
<td className="p-4"><Skeleton className="h-4 w-48" /></td>
<td className="p-4"><Skeleton className="h-4 w-24" /></td>
<td className="p-4"><Skeleton className="h-4 w-32" /></>
)
}
() {
(
)
}
Lazy loading de rotas e componentes pesados
import dynamic from 'next/dynamic'
const MapEditor = dynamic(
() => import('@/components/shared/geo/map-editor'),
{
loading: () => <Skeleton className="h-[400px] w-full rounded-md" />,
ssr: false,
}
)
const RichTextEditor = dynamic(
() => import('@/components/shared/forms/rich-text-editor'),
{ loading: () => <Skeleton className="h-48 w-full rounded-md" /> }
)
Animações com Framer Motion
export const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.2 } },
exit: { opacity: 0, transition: { duration: 0.15 } },
}
export const slideUp = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: [0, 0, 0.2, 1] } },
exit: { opacity: 0, y: 8, transition: { duration: 0.15 } },
}
export const scaleModal = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: , : , : { : , : [, , , ] } },
: { : , : , : { : } },
}
staggerList = {
: { : { : } }
}
import { motion, AnimatePresence } from 'framer-motion'
import { scaleModal, fadeIn } from '@/lib/animations'
export function Modal({ open, children }: { open: boolean; children: React.ReactNode }) {
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50 z-40"
variants={fadeIn} initial="hidden" animate="visible" exit="exit"
/>
{/* Modal */}
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
variants={scaleModal} initial="hidden" animate="visible" exit="exit"
>
<div className="bg-background rounded-lg p-6 max-w-md w-full shadow-xl">
{children}
</div>
</motion.div>
</>
)}
</>
)
}
{ staggerList, slideUp }
() {
(
)
}
() {
(
)
}
Imagens com lazy loading
import Image from 'next/image'
<Image
src={url}
alt={alt}
width={400}
height={300}
placeholder="blur"
blurDataURL="data:image/png;base64,..."
className="rounded-md transition-opacity duration-300"
/>
Dirty state — proteção contra perda de dados
Regra universal em CLAUDE.md. Aplicar em todo formulário de modal e de página.
import { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function useDirtyState<T extends Record<string, unknown>>(
initialValues: T
) {
const [values, setValues] = useState<T>(initialValues)
const [isDirty, setIsDirty] = useState(false)
const router = useRouter()
const setValue = useCallback((key: keyof T, value: unknown) => {
setValues(prev => {
const next = { ...prev, [key]: value }
const dirty = Object.keys(next).some(k => next[k] !== initialValues[k])
setIsDirty(dirty)
return next as T
})
}, [initialValues])
const reset = useCallback(() => {
setValues(initialValues)
setIsDirty(false)
}, [initialValues])
( {
(!isDirty)
= () => { e.() }
.(, handler)
.(, handler)
}, [isDirty])
( {
(!isDirty)
originalPush = router..(router)
}, [isDirty, router])
{ values, setValue, isDirty, reset }
}
import { AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle }
from '@/components/ui/alert-dialog'
interface UnsavedChangesDialogProps {
open: boolean
onDiscard: () => void
onKeepEditing: () => void
}
export function UnsavedChangesDialog({
open, onDiscard, onKeepEditing
}: UnsavedChangesDialogProps) {
return (
<AlertDialog open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Alterações não salvas</AlertDialogTitle>
<AlertDialogDescription>
Você tem alterações que não foram salvas. Se sair agora, elas serão perdidas.
</AlertDialogDescription>
</AlertDialogHeader>
Continuar editando
Descartar alterações
)
}
export function ProcessoModal({ open, onClose }: ModalProps) {
const { values, setValue, isDirty, reset } = useDirtyState({ nome: '', status: '' })
const [confirmClose, setConfirmClose] = useState(false)
const handleClose = () => {
if (isDirty) {
setConfirmClose(true)
} else {
onClose()
}
}
return (
<>
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleClose() }}>
<DialogContent
onInteractOutside={(e) => {
e.preventDefault() // impedir fechar ao clicar fora
handleClose() // tratar manualmente com dirty check
}}
onEscapeKeyDown={(e) => {
e.preventDefault()
handleClose()
}}>
{/* campos do formulário */}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancelar</Button>
Salvar
{ reset(); setConfirmClose(false); onClose() }}
onKeepEditing={() => setConfirmClose(false)}
/>
)
}
() {
{ values, setValue, isDirty, reset } = (initialValues)
router = ()
[confirmNav, setConfirmNav] = ()
= () => {
(isDirty) ()
router.()
}
(
)
}
Anti-padrões de design — detectar antes de entregar
shadcn/ui é o template mais afetado pelos vícios de "AI slop". Antes de
considerar qualquer tela entregue, verificar que não há:
❌ Inter como única fonte (vício de AI slop)
❌ Gradiente roxo-para-azul decorativo
❌ Texto cinza (#999, #aaa) sobre fundo colorido
❌ Cards aninhados dentro de outros cards
❌ Ícone em rounded-square acima de todo heading
❌ Preto puro — usar tint da cor primária
❌ Bounce/elastic easing em animações
❌ Padding menor que 16px em áreas de conteúdo
Detector automático (sem API key, roda no CI):
npx impeccable detect src/
npx impeccable detect --json src/
60 regras determinísticas — instalar via npx impeccable install para
integração com Claude Code. Repositório: https://github.com/pbakaus/impeccable
Relação com o UX Designer
- Testes de integração de componente (ex: Testing Library) > testes
unitários isolados de componente puro.
npm run lint / npm run build antes de considerar a tarefa concluída.