diff --git a/docs/plans/busca-global-layout-resultados.md b/docs/plans/busca-global-layout-resultados.md
index 96a8a3fc..13d68d69 100644
--- a/docs/plans/busca-global-layout-resultados.md
+++ b/docs/plans/busca-global-layout-resultados.md
@@ -1,7 +1,7 @@
# Busca global — layout dos resultados (grid + cap de viewport)
-Status: rascunho
-Atualizado em: 2026-07-29
+Status: entregue (2026-07-30)
+Atualizado em: 2026-07-30 — as-built: `HomeSearchResultsLayout` (grid 1/2/3 col + `HomeSearchHitBudgetContext`); cap v1 constante por breakpoint (`HOME_SEARCH_HIT_BUDGET` 8/12/15) via `allocateHitBudget` com prioridade Municípios; grupos B48–B53 fatiam via `useHomeSearchHitLimit`.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B54 — busca global)
Impeccable: B — layout responsivo da região de resultados B47
Appetite: ~0,5–0,75 dia eng; CSS grid + política de cap; sem provider novo
diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx
index 40f8e953..58b5e968 100644
--- a/src/app/(campaign)/campanha/(app)/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/page.tsx
@@ -11,6 +11,7 @@ import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSear
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
+import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
@@ -50,12 +51,14 @@ export default async function CampaignHomePage() {
actions={actions}
searchResults={
-
-
-
-
-
-
+
+
+
+
+
+
+
+
}
summarySlot={summarySlot}
diff --git a/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx b/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
index c5534039..4c37c456 100644
--- a/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
@@ -1,23 +1,28 @@
'use client'
+import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { homeSearchActivityGroupHasHits } from '@/lib/campaignHomeSearchHits'
+import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
export const HomeSearchActivityGroup = () => {
const { results } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('activities')
if (results.status !== 'success') return null
if (!homeSearchActivityGroupHasHits(results.data)) return null
const { activities } = results.data
+ const visibleActivities = sliceHomeSearchHits(activities, hitLimit)
+ if (visibleActivities.length === 0) return null
return (
Atividades
- {activities.map((hit) => (
+ {visibleActivities.map((hit) => (
-
{
const { results } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('advisors')
if (results.status !== 'success') return null
const { advisors } = results.data
if (advisors.length === 0) return null
+ const visibleAdvisors = sliceHomeSearchHits(advisors, hitLimit)
+ if (visibleAdvisors.length === 0) return null
+
return (
Assessores
- {advisors.map((hit) => (
+ {visibleAdvisors.map((hit) => (
-
{
const { results } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('demands')
if (results.status !== 'success') return null
if (!homeSearchDemandGroupHasHits(results.data)) return null
const { demands } = results.data
+ const visibleDemands = sliceHomeSearchHits(demands, hitLimit)
+ if (visibleDemands.length === 0) return null
return (
Demandas
- {demands.map((hit) => (
+ {visibleDemands.map((hit) => (
-
(null)
+
+export const HomeSearchHitBudgetProvider = ({
+ limits,
+ children,
+}: {
+ limits: HomeSearchGroupHitLimits
+ children: ReactNode
+}) => (
+
+ {children}
+
+)
+
+export const useHomeSearchHitLimit = (groupId: HomeSearchGroupId): number | undefined => {
+ const limits = useContext(HomeSearchHitBudgetContext)
+ return limits?.[groupId]
+}
+
+const VIEWPORT_TIER_QUERIES: { tier: HomeSearchViewportTier; query: string }[] = [
+ { tier: 'desktop', query: '(min-width: 1024px)' },
+ { tier: 'tablet', query: '(min-width: 768px)' },
+]
+
+const resolveHomeSearchViewportTier = (
+ matchesDesktop: boolean,
+ matchesTablet: boolean,
+): HomeSearchViewportTier => {
+ if (matchesDesktop) return 'desktop'
+ if (matchesTablet) return 'tablet'
+ return 'mobile'
+}
+
+const readHomeSearchViewportTier = (): HomeSearchViewportTier => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return 'mobile'
+ }
+ return resolveHomeSearchViewportTier(
+ window.matchMedia(VIEWPORT_TIER_QUERIES[0].query).matches,
+ window.matchMedia(VIEWPORT_TIER_QUERIES[1].query).matches,
+ )
+}
+
+export const useHomeSearchViewportTier = (): HomeSearchViewportTier => {
+ const [tier, setTier] = useState(readHomeSearchViewportTier)
+
+ useEffect(() => {
+ if (typeof window.matchMedia !== 'function') return
+
+ const desktopMql = window.matchMedia(VIEWPORT_TIER_QUERIES[0].query)
+ const tabletMql = window.matchMedia(VIEWPORT_TIER_QUERIES[1].query)
+
+ const sync = () => {
+ setTier(resolveHomeSearchViewportTier(desktopMql.matches, tabletMql.matches))
+ }
+
+ sync()
+ desktopMql.addEventListener('change', sync)
+ tabletMql.addEventListener('change', sync)
+ return () => {
+ desktopMql.removeEventListener('change', sync)
+ tabletMql.removeEventListener('change', sync)
+ }
+ }, [])
+
+ return tier
+}
diff --git a/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
index 3fc7952a..d97f82c4 100644
--- a/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
@@ -1,23 +1,29 @@
'use client'
+import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { homeSearchLeadershipGroupHasHits } from '@/lib/campaignHomeSearchHits'
+import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
export const HomeSearchLeadershipGroup = () => {
const { results } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('leaderships')
if (results.status !== 'success') return null
const { leaderships } = results.data
if (!homeSearchLeadershipGroupHasHits(results.data)) return null
+ const visibleLeaderships = sliceHomeSearchHits(leaderships, hitLimit)
+ if (visibleLeaderships.length === 0) return null
+
return (
Lideranças
- {leaderships.map((hit) => (
+ {visibleLeaderships.map((hit) => (
-
{
const { results, resultKind } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('municipalities')
if (results.status !== 'success') return null
const { municipalities, territories } = results.data
if (!homeSearchMunicipalityGroupHasHits(results.data)) return null
+ const includeTerritories = resultKind === 'search'
+ const { municipalities: visibleMunicipalities, territories: visibleTerritories } =
+ sliceHomeSearchMunicipalityGroup(municipalities, territories, hitLimit, includeTerritories)
+
+ if (visibleMunicipalities.length === 0 && visibleTerritories.length === 0) return null
+
const sectionTitle = resultKind === 'suggest' ? 'Sugestões' : 'Municípios'
return (
{sectionTitle}
- {municipalities.map((hit) => (
+ {visibleMunicipalities.map((hit) => (
-
{
/>
))}
- {resultKind === 'search'
- ? territories.map((hit) => (
+ {includeTerritories
+ ? visibleTerritories.map((hit) => (
-
{
+ const { results } = useHomeSearchResults()
+ const viewportTier = useHomeSearchViewportTier()
+ const budget = homeSearchHitBudgetForTier(viewportTier)
+
+ const limits =
+ results.status === 'success'
+ ? allocateHitBudget(buildHomeSearchGroupHitCounts(results.data), budget)
+ : allocateHitBudget(EMPTY_GROUP_COUNTS, budget)
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx b/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx
index 33da24ee..49768053 100644
--- a/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx
@@ -1,11 +1,13 @@
'use client'
+import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import {
formatHomeSearchMunicipalityCount,
homeSearchStateDeputyGroupHasHits,
} from '@/lib/campaignHomeSearchHits'
+import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
const formatStateDeputySecondary = (
@@ -20,17 +22,21 @@ const formatStateDeputySecondary = (
export const HomeSearchStateDeputyGroup = () => {
const { results } = useHomeSearchResults()
+ const hitLimit = useHomeSearchHitLimit('stateDeputies')
if (results.status !== 'success') return null
const { stateDeputies } = results.data
if (!homeSearchStateDeputyGroupHasHits(results.data)) return null
+ const visibleStateDeputies = sliceHomeSearchHits(stateDeputies, hitLimit)
+ if (visibleStateDeputies.length === 0) return null
+
return (
Dobradinhas
- {stateDeputies.map((hit) => (
+ {visibleStateDeputies.map((hit) => (
-
+
+export type HomeSearchGroupHitLimits = Record
+
+const zeroLimits = (): HomeSearchGroupHitLimits =>
+ Object.fromEntries(HOME_SEARCH_GROUP_ORDER.map((id) => [id, 0])) as HomeSearchGroupHitLimits
+
+export const homeSearchHitBudgetForTier = (tier: HomeSearchViewportTier): number =>
+ HOME_SEARCH_HIT_BUDGET[tier]
+
+export const buildHomeSearchGroupHitCounts = (
+ data: HomeSearchSuccessResponse,
+): HomeSearchGroupHitCounts => ({
+ municipalities:
+ data.municipalities.length + (data.resultKind === 'search' ? data.territories.length : 0),
+ leaderships: data.leaderships.length,
+ advisors: data.advisors.length,
+ activities: data.activities.length,
+ stateDeputies: data.stateDeputies.length,
+ demands: data.demands.length,
+})
+
+/**
+ * Municípios consume the budget first; remaining slots fill secondary groups in
+ * display order (not round-robin — B54).
+ */
+export const allocateHitBudget = (
+ counts: HomeSearchGroupHitCounts,
+ budget: number,
+): HomeSearchGroupHitLimits => {
+ const limits = zeroLimits()
+ if (budget <= 0) return limits
+
+ let remaining = budget
+
+ const municipalitySlots = Math.min(counts.municipalities, remaining)
+ limits.municipalities = municipalitySlots
+ remaining -= municipalitySlots
+
+ for (const id of HOME_SEARCH_GROUP_ORDER) {
+ if (id === 'municipalities' || remaining <= 0) continue
+ const groupHits = counts[id]
+ if (groupHits <= 0) continue
+ const slots = Math.min(groupHits, remaining)
+ limits[id] = slots
+ remaining -= slots
+ }
+
+ return limits
+}
+
+export const sliceHomeSearchHits = (hits: readonly T[], limit: number | undefined): T[] =>
+ limit === undefined ? [...hits] : hits.slice(0, limit)
+
+export const sliceHomeSearchMunicipalityGroup = (
+ municipalities: HomeSearchSuccessResponse['municipalities'],
+ territories: HomeSearchSuccessResponse['territories'],
+ limit: number | undefined,
+ includeTerritories: boolean,
+): {
+ municipalities: HomeSearchSuccessResponse['municipalities']
+ territories: HomeSearchSuccessResponse['territories']
+} => {
+ if (limit === undefined) {
+ return {
+ municipalities: [...municipalities],
+ territories: includeTerritories ? [...territories] : [],
+ }
+ }
+
+ const visibleMunicipalities = municipalities.slice(0, limit)
+ const remaining = limit - visibleMunicipalities.length
+ const visibleTerritories =
+ includeTerritories && remaining > 0 ? territories.slice(0, remaining) : []
+
+ return { municipalities: visibleMunicipalities, territories: visibleTerritories }
+}
diff --git a/tests/unit/homeSearchHitBudget.unit.spec.ts b/tests/unit/homeSearchHitBudget.unit.spec.ts
new file mode 100644
index 00000000..bc114cf0
--- /dev/null
+++ b/tests/unit/homeSearchHitBudget.unit.spec.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from 'vitest'
+
+import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
+import {
+ allocateHitBudget,
+ buildHomeSearchGroupHitCounts,
+ HOME_SEARCH_HIT_BUDGET,
+ sliceHomeSearchMunicipalityGroup,
+} from '@/lib/homeSearchHitBudget'
+
+const baseResponse = (
+ overrides: Partial = {},
+): HomeSearchSuccessResponse => ({
+ status: 'success',
+ resultKind: 'search',
+ municipalities: [],
+ territories: [],
+ advisors: [],
+ leaderships: [],
+ stateDeputies: [],
+ activities: [],
+ demands: [],
+ ...overrides,
+})
+
+describe('allocateHitBudget', () => {
+ it('gives municipalities priority over secondary groups', () => {
+ const counts = buildHomeSearchGroupHitCounts(
+ baseResponse({
+ municipalities: Array.from({ length: 10 }, (_, index) => ({
+ kind: 'municipality' as const,
+ slug: `m-${index}`,
+ name: `M${index}`,
+ region: 'R',
+ priority: null,
+ votePosition2022: null,
+ })),
+ demands: Array.from({ length: 10 }, (_, index) => ({
+ id: index,
+ slug: `d-${index}`,
+ title: `Demand ${index}`,
+ secondary: 'x',
+ })),
+ }),
+ )
+
+ const limits = allocateHitBudget(counts, 8)
+
+ expect(limits.municipalities).toBe(8)
+ expect(limits.demands).toBe(0)
+ })
+
+ it('fills secondary groups in display order when budget remains', () => {
+ const counts = buildHomeSearchGroupHitCounts(
+ baseResponse({
+ municipalities: [
+ {
+ kind: 'municipality',
+ slug: 'cairu',
+ name: 'Cairu',
+ region: 'Recôncavo',
+ priority: null,
+ votePosition2022: null,
+ },
+ ],
+ leaderships: [
+ {
+ kind: 'leadership',
+ id: 1,
+ name: 'Leader A',
+ municipalitiesSummary: 'Cairu',
+ },
+ {
+ kind: 'leadership',
+ id: 2,
+ name: 'Leader B',
+ municipalitiesSummary: 'Salvador',
+ },
+ ],
+ demands: [
+ { id: 1, slug: 'd-1', title: 'Demand 1', secondary: 'x' },
+ { id: 2, slug: 'd-2', title: 'Demand 2', secondary: 'x' },
+ ],
+ }),
+ )
+
+ const limits = allocateHitBudget(counts, 4)
+
+ expect(limits.municipalities).toBe(1)
+ expect(limits.leaderships).toBe(2)
+ expect(limits.demands).toBe(1)
+ expect(limits.advisors).toBe(0)
+ })
+
+ it('counts territories toward the municipality group in search mode', () => {
+ const counts = buildHomeSearchGroupHitCounts(
+ baseResponse({
+ municipalities: [],
+ territories: [
+ { kind: 'territory', region: 'Recôncavo', votes2022: 1000 },
+ { kind: 'territory', region: 'Sertão', votes2022: 500 },
+ ],
+ }),
+ )
+
+ const limits = allocateHitBudget(counts, HOME_SEARCH_HIT_BUDGET.mobile)
+
+ expect(counts.municipalities).toBe(2)
+ expect(limits.municipalities).toBe(2)
+ })
+})
+
+describe('sliceHomeSearchMunicipalityGroup', () => {
+ it('slices municipalities before territories within the shared limit', () => {
+ const municipalities = [
+ {
+ kind: 'municipality' as const,
+ slug: 'a',
+ name: 'A',
+ region: 'R',
+ priority: null,
+ votePosition2022: null,
+ },
+ {
+ kind: 'municipality' as const,
+ slug: 'b',
+ name: 'B',
+ region: 'R',
+ priority: null,
+ votePosition2022: null,
+ },
+ ]
+ const territories = [{ kind: 'territory' as const, region: 'TI', votes2022: 1 }]
+
+ const sliced = sliceHomeSearchMunicipalityGroup(municipalities, territories, 2, true)
+
+ expect(sliced.municipalities).toHaveLength(2)
+ expect(sliced.territories).toHaveLength(0)
+ })
+
+ it('uses remaining slots for territories after municipalities', () => {
+ const municipalities = [
+ {
+ kind: 'municipality' as const,
+ slug: 'a',
+ name: 'A',
+ region: 'R',
+ priority: null,
+ votePosition2022: null,
+ },
+ ]
+ const territories = [
+ { kind: 'territory' as const, region: 'TI 1', votes2022: 1 },
+ { kind: 'territory' as const, region: 'TI 2', votes2022: 2 },
+ ]
+
+ const sliced = sliceHomeSearchMunicipalityGroup(municipalities, territories, 3, true)
+
+ expect(sliced.municipalities).toHaveLength(1)
+ expect(sliced.territories).toHaveLength(2)
+ })
+})
diff --git a/tests/unit/homeSearchResultsLayout.unit.spec.tsx b/tests/unit/homeSearchResultsLayout.unit.spec.tsx
new file mode 100644
index 00000000..5ab0334c
--- /dev/null
+++ b/tests/unit/homeSearchResultsLayout.unit.spec.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
+import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
+
+const searchResultsValue = (
+ data: Partial = {},
+ resultKind: HomeSearchSuccessResponse['resultKind'] = 'search',
+) => ({
+ results: {
+ status: 'success' as const,
+ data: {
+ status: 'success' as const,
+ resultKind,
+ municipalities: [],
+ territories: [],
+ advisors: [],
+ leaderships: [],
+ stateDeputies: [],
+ activities: [],
+ demands: [],
+ ...data,
+ },
+ },
+ isFetching: false,
+ resultKind,
+})
+
+describe('HomeSearchResultsLayout', () => {
+ it('renders a responsive grid container for result groups', () => {
+ const { container } = render(
+
+
+
Group A
+ Group B
+
+ ,
+ )
+
+ const layout = container.querySelector('[data-slot="home-search-results-layout"]')
+ expect(layout?.className).toContain('flex-col')
+ expect(layout?.className).toContain('md:grid-cols-2')
+ expect(layout?.className).toContain('lg:grid-cols-3')
+ expect(screen.getByText('Group A')).toBeTruthy()
+ expect(screen.getByText('Group B')).toBeTruthy()
+ })
+})