mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
style(C216): aplica a crítica visual do designer (paridade com o artefato) (Related #1292)
This commit is contained in:
parent
516a18b225
commit
0dddec990c
8 changed files with 80 additions and 54 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue