diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml
index 24dc005d..b4f19f99 100644
--- a/.github/workflows/ci-pr.yml
+++ b/.github/workflows/ci-pr.yml
@@ -129,7 +129,7 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- - run: pnpm exec knip
+ - run: pnpm knip
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 14262138..33da2960 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,7 +38,7 @@ jobs:
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
- - run: pnpm exec knip
+ - run: pnpm knip
- run: pnpm check:cycles
- run: pnpm test:unit
- name: Cancel workflow on failure
diff --git a/docs/plans/orientacao-shell-sem-titulos-secao-impl.md b/docs/plans/orientacao-shell-sem-titulos-secao-impl.md
new file mode 100644
index 00000000..7d88573f
--- /dev/null
+++ b/docs/plans/orientacao-shell-sem-titulos-secao-impl.md
@@ -0,0 +1,80 @@
+# Impl: Orientação na shell + aba — sem títulos grandes de seção
+
+Status: aprovado
+Atualizado em: 2026-08-02
+Issue: #250
+Intenção: docs/plans/orientacao-shell-sem-titulos-secao.md
+Appetite restante: herdado (~1–1,5 dia eng)
+
+## Leitura da intenção
+
+- **Outcome:** orientação “onde estou” sai do corpo da página e vai para chrome estável (header mobile/desktop + aba `Solla - Campanha -
`); corpo começa direto no trabalho; detalhe mantém h1 da entidade; Início sem título no header.
+- **O que NÃO negociar:** wizard/auth/convite/offline intocados; h1 de entidade em detalhe; leader lockdown; chips de universo removidos (não no header).
+- **O que reavaliar:** hipótese de `CampaignListPageHeader` — não existe; B118 nunca landou. Pathname registry + context override é mais maintainable que 15 nested layouts.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph server [Server RSC]
+ P[page.tsx] --> M[export metadata via campaignPageMetadata]
+ P --> S[SetCampaignPageChrome em edit dinâmico]
+ end
+ subgraph client [Shell client]
+ R[resolveCampaignPageChrome pathname+role] --> C[Provider merge override]
+ C --> MB[CampaignMobileTopBar]
+ C --> DH[CampaignDesktopHeader]
+ end
+```
+
+**Opções consideradas:** A) nested `layout.tsx` por segmento · B) pathname registry + override context · C) só metadata sem shell
+**Recomendação:** B — um owner (`campaignPageChrome.ts`), precedente wizard/home-search, role no layout, override só em edit dinâmico.
+**Rejeitadas:** A (34 rotas, drift) · C (aceite exige shell)
+
+### Componentes / mudanças
+
+- **`campaignPageChrome.ts`** (`src/lib/`): vocabulário client-safe; `resolveCampaignPageChrome`; `campaignPageMetadata`
+- **`CampaignPageChromeContext.tsx`**: provider + `SetCampaignPageChrome` (override, layout effect cleanup)
+- **`CampaignPageChromeResolver.tsx`**: client; pathname + role → base chrome
+- **`CampaignDesktopHeader.tsx`**: uma linha título+subtítulo md+
+- **`CampaignMobileTopBar.tsx`**: app mode lê chrome efetivo; home vazio
+- **`(app)/layout.tsx`**: provider + resolver + desktop header; passa `role`
+- **`(campaign)/layout.tsx`**: `title.template: 'Solla - Campanha - %s'`
+- **Páginas/componentes**: remover headers de seção/create; manter h1 entidade + back em detalhe; `SetCampaignPageChrome` em edit; metadata em rotas
+- **Migration:** sem migration
+- **Access / Consent:** inalterado
+
+### Dados → forma
+
+- Orientação = texto estático por rota + role (quadro); sem dados de Payload no resolver.
+
+## Fases verificáveis
+
+1. **Infra** — lib + context + shell headers + metadata template
+2. **Páginas** — strip body headers; metadata; edit bridges
+3. **Gates** — `pnpm gate:fast`; `pnpm push`
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Reintroduzir chips de escopo no header
+- Mexer em wizard chrome
+- h2 internos (ex. “Meus contatos (N)”)
+
+## Riscos e mitigação
+
+- E2E busca `heading` level 1 → atualizar para shell slot ou manter entity h1 only
+- Home search focus: chrome vazio em `/campanha` mesmo focado
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto
+- [x] Invariantes AGENTS/engineering-standards
+- [x] Testes unit mobile top bar + metadata resolver previstos
+
+## Débitos deferidos (/simplify)
+
+- **Registry único** (pathRules + metadata por página): defer — gatilho quando 3ª rota dinâmica exigir override; hoje catálogo + metadata explícita é legível.
+- **Resolver único no provider** (evitar dupla resolução mobile/desktop): defer — custo negligível (~28 regex); revisit se bundle crescer.
+- **Desktop header RSC** (slot bell sem client boundary): **corrigido** nesta entrega — `notificationBell` slot como mobile.
+- **Subtitle desktop truncado** em uma linha: defer — validar com usuários; mobile já usa duas linhas.
+- **Table-driven pathRules tests**: defer — cobertura smoke suficiente para B123; expandir se matcher quebrar de novo.
diff --git a/docs/plans/sidebar-sem-logo-escopo-impl.md b/docs/plans/sidebar-sem-logo-escopo-impl.md
new file mode 100644
index 00000000..73f5d7ed
--- /dev/null
+++ b/docs/plans/sidebar-sem-logo-escopo-impl.md
@@ -0,0 +1,65 @@
+# Impl: Sidebar /campanha — sem logo nem chip de escopo no topo
+
+Status: aprovado
+Atualizado em: 2026-08-02
+Issue: #271
+Intenção: docs/plans/sidebar-sem-logo-escopo.md
+Appetite restante: ~0,5 dia (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** O topo da sidebar (desktop e Sheet mobile) não exibe logo nem badge de papel; a lista de navegação começa no topo, alinhada com o header da área principal. Login mantém logo; chips de escopo em páginas permanecem.
+- **O que NÃO negociar:** `CampaignScopeBadge` e `CampaignLogo` seguem vivos fora da sidebar; leader lockdown e nav por role intactos; auth shell inalterado.
+- **O que reavaliar:** A hipótese de editar só `CampaignSidebar.tsx` confirma-se — não há outro call site de logo/badge na sidebar.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ A[CampaignSidebar] --> B[Remove SidebarHeader + logo]
+ A --> C[Remove CampaignScopeBadge do grupo nav]
+ D[CampaignAuthPageShell] --> E[logo intacto]
+ F[páginas de lista] --> G[CampaignScopeBadge intacto]
+```
+
+**Opções consideradas:**
+
+- A — Remover `SidebarHeader` inteiro e o badge do primeiro `SidebarGroup` em `CampaignSidebar.tsx`
+- B — Esconder via CSS (`hidden`) mantendo DOM
+- C — Mover logo para o rodapé
+
+**Recomendação:** A — remoção direta no dono; menos DOM, sem degrau vertical, sem código morto.
+**Rejeitadas:** B (DOM fantasma, testes frágeis); C (fora do escopo de produto).
+
+### Componentes / mudanças
+
+- **`CampaignSidebar`** (`src/components/campaign/shell/CampaignSidebar.tsx`): remover `SidebarHeader` (logo link), `CampaignScopeBadge` e imports órfãos (`CampaignLogo`, `CampaignScopeBadge`, `SidebarHeader`, `campaignRoleLabels`).
+- **Migration:** sem migration
+- **Access / Consent:** N/A
+- **UI:** Impeccable B — encaixe no shell; nav cola no topo do `SidebarContent`; footer com avatar/perfil inalterado.
+
+### Dados → forma
+
+N/A — chrome estrutural.
+
+## Fases verificáveis
+
+1. **UI** — editar `CampaignSidebar.tsx`; limpar imports
+2. **Gates** — `pnpm gate:fast`; push via `pnpm push`
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Não tocar `CampaignScopeBadge` em páginas de conteúdo
+- Não alterar `CampaignAuthPageShell` nem `campaign-logo.tsx`
+- Não redesenhar padding do `SidebarGroup` além do que a remoção naturalmente resolve
+
+## Riscos e mitigação
+
+- **Teste de convenção que pinne logo na sidebar:** `campaignComponents.unit.spec.ts` lê o source — verificar que pins de shell (`collapsible`, `print:hidden`, tokens) permanecem válidos.
+- **Alinhamento vertical:** remover `SidebarHeader` com `border-b` elimina o degrau; header principal já é `min-h-11` — aceite visual coberto pela remoção, sem padding compensatório artificial.
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto
+- [x] Invariantes AGENTS/engineering-standards
+- [x] Testes de domínio previstos (unit source pins existentes; sem access/write)
diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs
index 5e867818..f670d8cd 100644
--- a/scripts/gate-ci.mjs
+++ b/scripts/gate-ci.mjs
@@ -77,7 +77,7 @@ const main = async () => {
console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)')
} else {
run('typecheck', 'pnpm', ['typecheck'])
- run('knip', 'pnpm', ['exec', 'knip'])
+ run('knip', 'pnpm', ['knip'])
run('check:cycles', 'pnpm', ['check:cycles'])
}
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
index 24b7587b..1d8b97f2 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
@@ -5,7 +5,6 @@ import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
-import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
@@ -13,8 +12,8 @@ import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntent
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
-import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import {
loadSupporterDetailConsentData,
loadSupporterDetailPageData,
@@ -26,6 +25,8 @@ import {
} from '@/utilities/supporter/supporterUi'
import { parseSupporterId } from '@/utilities/supporter/supporterViewModels'
+export const metadata = campaignPageMetadataFromCatalog('apoiadores')
+
type SupporterDetailPageProps = {
params: Promise<{ id: string }>
}
@@ -71,7 +72,6 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
Voltar para apoiadores
- {campaignRoleLabels[user.role]}
{supporter.name}
{supporter.city ?? 'Cidade não informada'}
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx
index a0c4c811..dc0fa325 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx
@@ -1,28 +1,23 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
-import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard'
import { Button } from '@/components/ui/button'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
+export const metadata = campaignPageMetadataFromCatalog('apoiadoresImportar')
+
export default async function ImportSupportersPage() {
const user = await requireCampaignPageActor()
if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores')
return (
-
-
- Importação · Coordenador Geral
-
Importar apoiadores via CSV
-
- Envie uma planilha, confira a prévia e confirme a importação em lote.
-
-
+
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
index 009d9112..5d2ee1b5 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
@@ -4,14 +4,15 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
-import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/supporter/SupporterForm'
import { Button } from '@/components/ui/button'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
-import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
+export const metadata = campaignPageMetadataFromCatalog('apoiadoresNovo')
+
export default async function NewSupporterPage() {
const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })])
@@ -21,16 +22,9 @@ export default async function NewSupporterPage() {
return (
-
-
- {campaignRoleLabels[user.role]}
-
Novo apoiador
-
- Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
-
- Atualize os detalhes da atividade sem alterar o título original.
-
-
+
searchParams: Promise>
diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
index eac45795..92ba04f6 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
@@ -1,6 +1,5 @@
import config from '@payload-config'
import { MapPinnedIcon } from 'lucide-react'
-import type { Metadata } from 'next'
import Link from 'next/link'
import { getPayload } from 'payload'
@@ -14,6 +13,7 @@ import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
import { Button } from '@/components/ui/button'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDayLabel } from '@/lib/campaignTime'
import { formatElectionNumber } from '@/lib/electionFormat'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@@ -31,10 +31,7 @@ import {
import { createTourDraftsFormAction } from './formActions'
-export const metadata: Metadata = {
- title: 'Planejar giro | Campanha',
- robots: { index: false, follow: false },
-}
+export const metadata = campaignPageMetadataFromCatalog('atividadesGiros')
type TourComposerPageProps = {
searchParams: Promise>
@@ -113,19 +110,11 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
return (
-
-
-
Planejar giro
-
- Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para
- receber, e uma semente de expansão para o giro não repetir só a base. A proposta é
- sugestão — você edita antes de gerar.
-
-
+
Voltar para Atividades
-
+
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */}
diff --git a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx
index 507666bf..1c98d78b 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx
@@ -7,6 +7,7 @@ import {
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
@@ -14,6 +15,8 @@ import {
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
+export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
+
export default async function NewActivityPage() {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
@@ -29,13 +32,6 @@ export default async function NewActivityPage() {
return (
-
-
Atividades
-
Nova atividade
-
- Defina a ação, quando e onde ela acontece e quem responde por ela.
-
- {/* E13: the planner generates several drafts at once, so it sits next
- to "Nova atividade" rather than replacing it. */}
-
-
-
- Planejar giro
-
-
-
-
-
- Nova atividade
-
-
-
- ) : null}
-
+ ) : null}
-
-
Conceitos de inteligência
-
- O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje
- — a lista cresce conforme novas análises entram.
-
-
-
{/*
Desktop-only index: on a phone it would push all seven sections below
diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx
index a8cc8a89..99deaba5 100644
--- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx
@@ -3,12 +3,15 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
export const dynamic = 'force-dynamic'
+export const metadata = campaignPageMetadataFromCatalog('contatos')
+
export default async function CampaignContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
@@ -20,7 +23,6 @@ export default async function CampaignContatosPage() {
return (
}
diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
index c6c99756..91b08834 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
@@ -7,12 +7,15 @@ import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/dem
import { DemandForm } from '@/components/campaign/demand/DemandForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
+import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
+export const metadata = campaignPageMetadataFromCatalog('demandasNova')
+
type NewDemandPageProps = {
searchParams: Promise>
}
@@ -38,19 +41,12 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
return (
-
-
-
-
- Voltar para demandas
-
-
-
Nova demanda
-
- Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria
- revisa e responde por aqui.
-