style(C216): aplica a crítica visual do designer (paridade com o artefato) (Related #1292)

This commit is contained in:
Francisco Solla 2026-09-24 16:34:53 -03:00
parent d0063d39f3
commit ebe1a98e85
8 changed files with 80 additions and 54 deletions

View file

@ -1 +1 @@
**C216 — Terceira fonte do acervo: "Falas na internet" (2026-09-24):** o alternador do acervo passa a ter três fontes — "Falas da Câmara", "Gravações enviadas" e "Falas na internet" — e a terceira abre sobre o catálogo web do C215 com a paridade de busca e filtros da Câmara: busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração, municípios citados, contagem, paginação e ordenação. Contrato sem migration e sem twin: o vocabulário de fonte saiu de `recordingListUrl` para o dono puro `lib/acervoSource` (3 vias, desconhecido→Câmara, fail-closed) e o de ordenação para `lib/acervoListSort` (2º consumidor do vocabulário do C219, com o mapeamento de Payload por domínio); `speechListUrl` ganha `source?: 'internet'` e `sort?` de forma **aditiva** (os bytes canônicos da Câmara ficam intocados, a fase é ignorada na web) e `speechListFilters` deriva `origin: web` do estado, mantendo a fase só na Câmara e o gate de duração (`durationSeconds exists`) nos sorts de duração. Loaders e view models ficam nos donos (`speechPageData` ganha `loadWebSpeechAcervoPageData`/`loadWebSpeechDetailPageData`/`loadWebSpeechTitleForActor` e opções de filtro por origem; `speechViewModels` ganha a família `WebSpeech*`) e o detalhe da Câmara passa a filtrar `origin: camara` — um id web vira 404 honesto, e vice-versa. Rotas novas em `/campanha/comunicacao/acervo/internet/[id]` (+ `/arquivo` e `/capa`), precedente `gravacoes/[id]`: a mídia espelhada privada é servida pelo dono `buildPrivateMediaResponse` com gate `canReadCommunicationCatalog`, range/416/404 silencioso e **nome de download derivado do título** (`webSpeechDownloadFilename` fecha o defer S4 do C215); a capa usa a mesma rota autenticada (nunca `/api/media/file`) e o card sem thumbnail renderiza "SEM CAPA". UI (Impeccable C, porte do design aprovado `docs/plans/acervo-fonte-falas-web-ui-design.html`, estendido pelo `designer` na cena 05 com o player de áudio e os chips de plataforma Instagram/Áudio): `AcervoSourceToggle` vira shell de 3 abas em `shared/` (grid de rótulos curtos no mobile), `AcervoSortSelect` compartilhado (serializador escolhido pelo discriminador de fonte dentro do client component — função nunca cruza a fronteira server→client) e `CampaignTranscriptSegmentButton` movido para `shared/` com tipo estrutural; `SpeechAcervoFilters` ganha props de copy (defaults exatos da Câmara, sem Fase na web), `WebSpeechResultCard/List` portam a cena 01 e `WebSpeechDetailPlayer` a 02/05 (vídeo ou áudio nativo no mesmo palco com o badge "Arquivo espelhado · privado", "Baixar", "Abrir na origem", box de origem com plataforma + canal/autor + data e transcrição clicável que posiciona o player). Verificação: unit (`acervoSource`, `acervoListSort`, URL/filtros/omnibox/VMs web e `webSpeechDownloadFilename`, com os pins da Câmara intocados), int `webSpeechAcervo` (lista só web, facetas, ordenação, tema injetado, rotas privadas no VM, 404 cruzado entre origens, gate de papéis), e2e do `campaignSpeechAcervo` estendido (switcher, card com pill/capa/placeholder, facetas + vazio honesto, ordenação com hint, detalhe com player/transcrição/download/origem, `arquivo`/`capa` com range/download nomeado/negação) e manifest e2e com os vocabulários e shells novos; `pnpm gate:fast`, `pnpm test`, `pnpm build` e os e2e locais (`--workers=1`). Crítica final do `designer` (trigger c) registrada no PR.
**C216 — Terceira fonte do acervo: "Falas na internet" (2026-09-24):** o alternador do acervo passa a ter três fontes — "Falas da Câmara", "Gravações enviadas" e "Falas na internet" — e a terceira abre sobre o catálogo web do C215 com a paridade de busca e filtros da Câmara: busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração, municípios citados, contagem, paginação e ordenação. Contrato sem migration e sem twin: o vocabulário de fonte saiu de `recordingListUrl` para o dono puro `lib/acervoSource` (3 vias, desconhecido→Câmara, fail-closed) e o de ordenação para `lib/acervoListSort` (2º consumidor do vocabulário do C219, com o mapeamento de Payload por domínio); `speechListUrl` ganha `source?: 'internet'` e `sort?` de forma **aditiva** (os bytes canônicos da Câmara ficam intocados, a fase é ignorada na web) e `speechListFilters` deriva `origin: web` do estado, mantendo a fase só na Câmara e o gate de duração (`durationSeconds exists`) nos sorts de duração. Loaders e view models ficam nos donos (`speechPageData` ganha `loadWebSpeechAcervoPageData`/`loadWebSpeechDetailPageData`/`loadWebSpeechTitleForActor` e opções de filtro por origem; `speechViewModels` ganha a família `WebSpeech*`) e o detalhe da Câmara passa a filtrar `origin: camara` — um id web vira 404 honesto, e vice-versa. Rotas novas em `/campanha/comunicacao/acervo/internet/[id]` (+ `/arquivo` e `/capa`), precedente `gravacoes/[id]`: a mídia espelhada privada é servida pelo dono `buildPrivateMediaResponse` com gate `canReadCommunicationCatalog`, range/416/404 silencioso e **nome de download derivado do título** (`webSpeechDownloadFilename` fecha o defer S4 do C215); a capa usa a mesma rota autenticada (nunca `/api/media/file`) e o card sem thumbnail renderiza "SEM CAPA". UI (Impeccable C, porte do design aprovado `docs/plans/acervo-fonte-falas-web-ui-design.html`, estendido pelo `designer` na cena 05 com o player de áudio e os chips de plataforma Instagram/Áudio): `AcervoSourceToggle` vira shell de 3 abas em `shared/` (grid de rótulos curtos no mobile), `AcervoSortSelect` compartilhado (serializador escolhido pelo discriminador de fonte dentro do client component — função nunca cruza a fronteira server→client) e `CampaignTranscriptSegmentButton` movido para `shared/` com tipo estrutural; `SpeechAcervoFilters` ganha props de copy (defaults exatos da Câmara, sem Fase na web), `WebSpeechResultCard/List` portam a cena 01 e `WebSpeechDetailPlayer` a 02/05 (vídeo ou áudio nativo no mesmo palco com o badge "Arquivo espelhado · privado", "Baixar", "Abrir na origem", box de origem com plataforma + canal/autor + data e transcrição clicável que posiciona o player). Verificação: unit (`acervoSource`, `acervoListSort`, URL/filtros/omnibox/VMs web e `webSpeechDownloadFilename`, com os pins da Câmara intocados), int `webSpeechAcervo` (lista só web, facetas, ordenação, tema injetado, rotas privadas no VM, 404 cruzado entre origens, gate de papéis), e2e do `campaignSpeechAcervo` estendido (switcher, card com pill/capa/placeholder, facetas + vazio honesto, ordenação com hint, detalhe com player/transcrição/download/origem, `arquivo`/`capa` com range/download nomeado/negação) e manifest e2e com os vocabulários e shells novos; `pnpm gate:fast`, `pnpm test`, `pnpm build` e os e2e locais (`--workers=1`). Crítica final do `designer` (trigger c) **certificada em tier primário** (`Design tier: openai/gpt-5.6-sol`); os 5 bloqueantes de port (painel desktop da barra, ordem das facetas, atribuição inline no mobile, marcador ativo do segmento e vazio da cena 04) foram aplicados e reconfirmados sem bloqueante novo.

View file

@ -223,6 +223,10 @@ Aplicados na sessão (não reabrir): extração de `withSeekQuery` (os dois href
| D2 | Bytes MP4/MP3 de fixture copiados entre os int specs de fala web (2 sites) | simplify/reuse | 2 | defer_trigger | defer — gatilho: 3º spec de fala web precisar dos bytes |
| D3 | `firstParam` idêntico em `acervoSource`/`acervoListSort` (2 linhas) | simplify/quality | 1 | cheap_polish | descartar (camadas impedem dono comum; custa mais que a cópia) |
## Crítica final do designer (trigger c, 2026-09-24)
`certificado (tier primário — openai/gpt-5.6-sol)`. A primeira crítica encontrou 5 bloqueantes mecânicos (painel desktop da barra, ordem das facetas da web, atribuição inline no mobile, marcador ativo do segmento, vazio da cena 04) + 1 não-bloqueante (play overlay na capa); todos aplicados e reconfirmados sem bloqueante novo. O mobile do omnibox segue o B184 do shell (barra sticky sem borda) — a cena 03 do artefato foi corrigida pelo próprio designer para refletir o shell, sem fork. O play overlay e a variante `web` do segmento (`border-l-2 border-primary`) são o delta visual desta entrega na superfície compartilhada.
## Self-score de decision-quality
**4,5/5.**

View file

@ -489,13 +489,13 @@
>Internet</span
>
</nav>
<div class="mt-4 rounded-xl border border-border p-3">
<label class="text-sm font-medium">Buscar nas falas</label>
<div
class="mt-1.5 flex h-11 items-center gap-2 rounded-md border border-input px-3"
>
<svg class="ico text-muted-foreground"><use href="#search" /></svg
><span class="text-sm text-muted-foreground">Assunto, tema ou município…</span>
<form
class="sticky top-0 z-20 -mx-4 mt-4 border-b border-border bg-white px-4 pt-1 pb-0"
aria-label="Busca e filtros das falas na internet"
>
<div class="flex min-h-10 items-center gap-2 bg-transparent px-2">
<svg class="ico text-muted-foreground"><use href="#search" /></svg>
<span class="text-sm text-muted-foreground">Assunto, tema ou município…</span>
</div>
<div class="mt-3 grid grid-cols-2 rounded-lg bg-muted p-1 text-center text-sm">
<span class="rounded-md bg-white py-2 font-semibold shadow-sm">Termo exato</span
@ -505,7 +505,7 @@
<span class="chip">Ano⌄</span><span class="chip">Tema⌄</span
><span class="chip">Alcance⌄</span>
</div>
</div>
</form>
<p class="mt-4 text-sm font-semibold">
Resultados <span class="font-normal text-muted-foreground">(contagem)</span>
</p>

View file

@ -217,17 +217,21 @@ const WebSpeechesSource = ({
return (
<CampaignListPendingBoundary>
<SpeechAcervoFilters
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
omniboxLabel="Buscar nas falas na internet"
omniboxPlaceholder="Busque por assunto, tema ou município…"
municipalityFacetLabel="Município citado"
showPhaseFacet={false}
filtersAriaLabel="Filtros das falas na internet"
/>
{/* C216 scene 01: the desktop bar sits inside the approved panel; below
`md` the wrapper disappears and the shared sticky bar stays. */}
<div className="max-md:contents md:rounded-xl md:border md:border-border md:bg-card md:p-4 md:shadow-sm">
<SpeechAcervoFilters
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
omniboxLabel="Buscar nas falas na internet"
omniboxPlaceholder="Busque por assunto, tema ou município…"
municipalityFacetLabel="Município citado"
showPhaseFacet={false}
filtersAriaLabel="Filtros das falas na internet"
/>
</div>
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
@ -264,22 +268,21 @@ const WebSpeechesSource = ({
<WebSpeechResultList rows={data.rows} />
) : (
<CampaignListEmptyState
className={themeActive ? 'border-solid' : undefined}
className="border-solid"
mediaClassName="rounded-none bg-transparent"
icon={SearchXIcon}
title={
themeActive
? 'Nenhuma fala encontrada para este tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: hasFilters
? 'Nenhuma fala encontrada com esses filtros'
: 'Nenhuma fala da internet catalogada ainda'
: hasFilters
? 'Nenhuma fala encontrada'
: 'Nenhuma fala da internet catalogada ainda'
}
description={
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou limpe a busca.'
? 'Não encontramos uma fala para esta busca com os filtros atuais. Tente outro termo ou remova um filtro.'
: 'As falas encontradas na internet aparecem aqui depois que a ingestão as catalogar.'
}
>
@ -295,11 +298,11 @@ const WebSpeechesSource = ({
<Link href={buildAcervoSourceHref('internet')}>Limpar filtros</Link>
</Button>
</div>
) : (
<Button asChild variant="outline" className="min-h-11">
) : hasFilters ? (
<Button asChild className="min-h-11">
<Link href={buildAcervoSourceHref('internet')}>Limpar busca e filtros</Link>
</Button>
)}
) : null}
</CampaignListEmptyState>
)}

View file

@ -19,7 +19,8 @@ type TranscriptSegment = {
* C200 — the clickable transcript line shared by the plain C199 list, the
* grouped C200 blocks and the C216 web detail: seeks the player to the segment
* and renders the search highlight. `variant` keeps the approved
* paddings/sizes without a twin.
* paddings/sizes without a twin — the web artifact marks the active segment
* with the red left border while the recordings keep the neutral fill.
*/
export const CampaignTranscriptSegmentButton = ({
segment,
@ -29,7 +30,7 @@ export const CampaignTranscriptSegmentButton = ({
}: {
segment: TranscriptSegment
active: boolean
variant?: 'plain' | 'grouped'
variant?: 'plain' | 'grouped' | 'web'
onSeek: (seconds: number) => void
}) => (
<button
@ -39,7 +40,7 @@ export const CampaignTranscriptSegmentButton = ({
className={cn(
'grid w-full grid-cols-[3.25rem_1fr] gap-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
variant === 'grouped' ? 'px-3 py-2.5' : 'rounded-lg px-2 py-1.5',
active && 'bg-muted',
active && (variant === 'web' ? 'border-l-2 border-primary bg-primary/[0.08]' : 'bg-muted'),
)}
>
<span

View file

@ -168,6 +168,25 @@ export const SpeechAcervoFilters = ({
const municipalities = (viewState.municipalities ?? []).map(String)
const durations = viewState.durations ?? []
const municipalityFacet: SpeechFacet = {
id: 'speech-filter-municipality',
label: municipalityFacetLabel,
prefix: 'municipality',
stateKey: 'municipalities',
options: filterOptions.municipalities,
selected: municipalities,
labels: Object.fromEntries(municipalityLabelsById),
}
const durationFacet: SpeechFacet = {
id: 'speech-filter-duration',
label: 'Duração',
prefix: 'duration',
stateKey: 'durations',
options: durationOptions,
selected: durations,
labels: speechDurationLabels,
}
const facets: SpeechFacet[] = [
{
id: 'speech-filter-year',
@ -207,24 +226,11 @@ export const SpeechAcervoFilters = ({
},
]
: []),
{
id: 'speech-filter-municipality',
label: municipalityFacetLabel,
prefix: 'municipality',
stateKey: 'municipalities',
options: filterOptions.municipalities,
selected: municipalities,
labels: Object.fromEntries(municipalityLabelsById),
},
{
id: 'speech-filter-duration',
label: 'Duração',
prefix: 'duration',
stateKey: 'durations',
options: durationOptions,
selected: durations,
labels: speechDurationLabels,
},
// C216 — the approved web order is Ano · Tema · Alcance · Duração ·
// Município citado; the Câmara keeps its own (Município before Duração).
...(state.source === 'internet'
? [durationFacet, municipalityFacet]
: [municipalityFacet, durationFacet]),
]
return (

View file

@ -140,7 +140,8 @@ export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetai
) : null}
</div>
<div className="mt-4 rounded-lg border border-border bg-muted/40 p-4">
{/* Mobile shows the attribution inline under the title (scene 05). */}
<div className="mt-4 rounded-lg border border-border bg-muted/40 p-4 max-md:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Origem
</p>
@ -154,7 +155,12 @@ export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetai
<div className="min-w-0">
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{speech.title}</h1>
{metaLine ? <p className="mt-2 text-sm text-muted-foreground">{metaLine}</p> : null}
{metaLine ? (
<p className="mt-2 text-sm text-muted-foreground max-md:hidden">{metaLine}</p>
) : null}
<p className="mt-2 text-xs leading-5 text-muted-foreground md:hidden">
{[speech.platform.label, speech.channel, speech.dateLabel].filter(Boolean).join(' · ')}
</p>
{speech.segments.length > 0 ? (
<section aria-label="Transcrição" className="mt-6">
@ -170,6 +176,7 @@ export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetai
<CampaignTranscriptSegmentButton
segment={segment}
active={activeStart === segment.startSeconds}
variant="web"
onSeek={seekTo}
/>
</li>

View file

@ -1,4 +1,4 @@
import { VideoIcon } from 'lucide-react'
import { PlayIcon, VideoIcon } from 'lucide-react'
import Link from 'next/link'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
@ -51,6 +51,11 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
<span className="pointer-events-none absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[11px] text-white">
{speech.platform.label}
</span>
{thumbnailUrl ? (
<span className="pointer-events-none absolute inset-0 grid place-items-center">
<PlayIcon className="size-6 text-white drop-shadow" aria-hidden="true" />
</span>
) : null}
</div>
<div className="min-w-0">