diff --git a/docs/plans/acervo-player-volta-ao-youtube-e-vod-com-espera.md b/docs/plans/acervo-player-volta-ao-youtube-e-vod-com-espera.md new file mode 100644 index 00000000..677aa067 --- /dev/null +++ b/docs/plans/acervo-player-volta-ao-youtube-e-vod-com-espera.md @@ -0,0 +1,85 @@ +# Acervo: o player sempre deixa voltar ao YouTube e espera a Câmara gerar o trecho + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1103 +Priority: P2 +Impeccable: A — N/A sem nova superfície (acréscimo de um controle simétrico no bloco de saída já desenhado + correção de comportamento do resolver) +Design UI: N/A — sem UI nova +Appetite: ~0,5–1 dia eng; um outcome verificável: numa fala, quem troca para a Câmara sempre consegue voltar ao YouTube na própria página, e a Câmara gerando o trecho não vira um "falhou" de primeira. +Responsável: — + +## Intenção + +O aviso do dono é um defeito de duas cabeças na mesma fala. Primeira: ao clicar em "Assistir na Câmara", o player troca a superfície para o trecho da Câmara — mas **não existe caminho de volta** para o embed do YouTube na própria página; a única saída passa a ser um link externo, e quem preferia o YouTube fica ilhado. Segunda: quando a Câmara responde "GERANDO", o player trata isso como fracasso na primeira resposta e manda o usuário tentar de novo, quando na verdade a Câmara está apenas transcodificando o trecho e ele fica pronto segundos depois — o retry então "funciona", o que confirma que a espera é curta e o problema é o app desistir cedo. Para a assessoria, no meio da montagem de uma peça, isso custa a fala: ou ela perde a fonte que estava usando, ou acha que o arquivo não existe. A correção é pequena e simétrica — dar o mesmo controle nos dois sentidos e deixar o clique do player esperar um orçamento curto antes de anunciar "gerando/falhou". + +## Persona e fluxo + +- **Persona / contexto:** assessoria de comunicação (`communicator`, coordenação e candidatura) em `/campanha/comunicacao/acervo/[id]`, na mesa ou no celular, com prazo curto. +- **Job principal:** alternar entre a superfície da Câmara e a do YouTube na mesma página, e assistir ao trecho sem ser mandado "tentar de novo" por algo que só estava sendo gerado. +- **Fluxo desejado:** abre a fala → assiste na superfície preferida (C178) → clica em "Assistir no YouTube" e volta ao embed no ponto da fala → se pedir o trecho da Câmara e ele ainda estiver sendo gerado, o clique espera alguns segundos e o vídeo toca sozinho; se o orçamento estourar, aí sim vê o estado honesto com "Tentar novamente". +- **Anti-goals de produto:** autenticar no YouTube ou trocar por embed novo; inventar estado ("sucesso" sem verificação de mídia); novo player/editor; baixar/espelhar/transcodificar; novo `Consent`/collection/migration; expor o acervo fora de `/campanha`. + +## Objetivo e aceite + +- Trocar de superfície **nos dois sentidos** na própria página (Câmara ↔ embed do YouTube), com o ponto da fala preservado ao voltar. +- Esperar a geração da Câmara **dentro do clique do player** (bounded), em vez de devolver "gerando" na primeira resposta; se estourar o orçamento, o "Tentar novamente" continua existindo e o estado honesto permanece. +- Nenhuma regressão nas saídas, na transcrição/posicionamento e na seleção/corte (C162/C166/C167/C172). +- **Guardrails:** nada de autenticar no YouTube nem embed novo; não inventar estado; a superfície que o app controla segue preferida (C178); transcrição/posicionamento e seleção/corte intactos; sem `Consent`/collection/migration; URLs públicas do acervo intocadas; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — é feedback de ação no player, não dado de acervo; nenhum KPI/gráfico novo. +- **Decisões desbloqueadas:** a assessoria decide seguir com a peça sem trocar de ferramenta; a coordenação decide, pelo uso, se o YouTube volta a ser superfície padrão algum dia. +- **Forma:** _adiada ao plano de implementação_ — restrição de produto: o estado do player é feedback de ação, nunca dado persistido. + +## Dados da decisão (literais) + +- **Duas políticas hoje (a raiz do defeito):** a do player `SPEECH_VOD_PLAYER_POLICY = { statusTimeoutMs: 15000, statusRetries: 1, retryDelayMs: 1000, pollAttempts: 0, pollDelayMs: 1000 }` — com `pollAttempts: 0`, o primeiro `GERANDO` já retorna `{state:'gerando'}`; a do corte `SPEECH_VOD_CUT_POLICY = { statusTimeoutMs: 45000, statusRetries: 1, retryDelayMs: 1000, pollAttempts: 2, pollDelayMs: 5000 }` — essa espera e por isso "funciona". +- **Copy pt-BR existente (manter):** bloco de saída "Se o vídeo não abrir aqui, assista por outro caminho:", botões "Abrir no YouTube" (link externo, no ponto) e "Assistir na Câmara"; estado de geração já nomeado "A Câmara está gerando o trecho"; retry "Tentar novamente". +- **Copy nova (proposta):** botão "Assistir no YouTube" no mesmo bloco de saída, no ponto da fala. +- **Embed do YouTube (contrato a preservar):** `https://www.youtube.com/embed/?playsinline=1&rel=0&start=`. +- Sem `Consent`/collection/migration; nada persistido no servidor. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/components/campaign/speech/SpeechDetailPlayer.tsx` (setter simétrico de `surface` + botão no bloco de saída, `renderYoutubeExit`), `src/utilities/speech/speechVodResolver.ts` + a rota/action `.../comunicacao/acervo/resolver-vod` (`resolveSpeechVodForActor` hoje usa a política do player), `src/lib/speechShare.ts`. +- **Precedente a olhar:** C178 (`docs/plans/acervo-player-youtube-sem-parede-de-signin.md`, #1081) — redesenha a superfície padrão; `docs/plans/acervo-vod-sob-demanda.md` (C162/C169, a política do corte e a verificação de mídia). +- **Risco de acoplamento:** mesmo arquivo de C178/C172/C173 — serializar; pins em `tests/unit/speechDetailPlayer.unit.spec.tsx`, `tests/unit/speechVodResolver.unit.spec.ts` (fixa a política do player) e `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` precisam ser renegociados no item. + +## Dependências + +- **Dura:** C178 — mesmo arquivo `SpeechDetailPlayer.tsx`; a superfície padrão precisa existir antes de dar a volta. +- **Suave:** C172/C173 — serializam no mesmo arquivo. +- Nenhum bloqueio de dados. + +## Fora de escopo + +- Fazer o YouTube tocar quando ele bloqueia; detectar/classificar a causa do bloqueio. +- Baixar/espelhar/transcodificar; tocar a superfície do VOD com um player novo. +- Tornar o YouTube a superfície padrão de novo (isso é C178). +- Segundo player/editor; exposição pública do acervo; `Consent`/collection/migration. + +## Rabbit holes de produto + +- **"Consertar o iframe do YouTube".** Se alguém "só completar": IFrame API, eventos, OAuth, player próprio. **Corte neste item:** a volta é um botão na nossa própria página; o YouTube continua embed anônimo. +- **Espera infinita / fila de render.** Se alguém "só completar": estado assíncrono persistido, cron, "avise quando ficar pronto". **Corte neste item:** orçamento curto dentro do clique; estourou → estado honesto + retry. +- **"Retry resolve, então é só mandar tentar".** **Corte:** isso é o defeito, não a solução — o app espera antes de acusar falha. +- **Redesenhar o player inteiro.** **Corte:** encaixe cirúrgico; transcrição, seleção e corte intactos. + +## Questões em aberto (produto) + +- **Quanto esperar dentro do clique do player?** **Opções:** A) reusar a política do corte (45s/2 polls de 5s) | B) política própria do player (ex.: alguns polls curtos) | C) manter `pollAttempts:0` e só melhorar a copy. **Recomendação:** B — um orçamento curto e próprio do clique, entre o imediato de hoje e o do corte, preservando o "bounded" e a verificação de mídia. _(assumido — validar com produto)_ +- **O botão "Assistir no YouTube" aparece também no estado "gerando"?** **Opções:** A) sim, sempre que houver embed | B) só quando a superfície for `vod` e a mídia estiver resolvida. **Recomendação:** A — a saída é justamente o que falta hoje; nunca deixar o usuário sem caminho. _(assumido)_ +- **Onde fica o botão?** **Opções:** A) no bloco de saída já existente | B) junto das ações do player. **Recomendação:** A — simétrico a "Assistir na Câmara", mesmo bloco, mesmo desenho. + +## Referências + +- GitHub Issue: — +- Planos irmãos: `docs/plans/acervo-player-youtube-sem-parede-de-signin.md` (C178), `docs/plans/acervo-vod-sob-demanda.md` (C162), `docs/plans/c171-player-youtube-sem-signin.md` +- Arquivos-chave (pista, não contrato): `src/components/campaign/speech/SpeechDetailPlayer.tsx`, `src/utilities/speech/speechVodResolver.ts`, `src/lib/speechShare.ts`, `src/app/(campaign)/campanha/actions/speech.ts`, `tests/unit/speechDetailPlayer.unit.spec.tsx`, `tests/unit/speechVodResolver.unit.spec.ts`, `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` +- `AGENTS.md` / `AGENTS-campaign.md` — vertical Comunicação, gate do acervo e crédito CC BY + +## Self-score (shaping) + +5/5 — (1) fatia = um outcome verificável (volta simétrica ao YouTube + espera bounded na geração); (2) appetite ~0,5–1 dia comporta o controle simétrico e o ajuste de política, sem schema; (3) persona/job/aceite em linguagem de produto; (4) direção no codebase é hipótese com precedentes e arquivos nomeados; (5) zero decisão dura de engenharia (política, rota e shape do estado ficam para o plano de implementação). diff --git a/docs/plans/acervo-preview-frame-do-meio-da-fala.md b/docs/plans/acervo-preview-frame-do-meio-da-fala.md new file mode 100644 index 00000000..698c9ed9 --- /dev/null +++ b/docs/plans/acervo-preview-frame-do-meio-da-fala.md @@ -0,0 +1,93 @@ +# Acervo: o preview de cada fala mostra um frame do meio daquela fala + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1104 +Priority: P2 +Impeccable: A — N/A sem nova superfície (muda a origem da imagem num slot já existente; sem mudança de layout/interação) +Design UI: N/A — sem UI nova (o artefato de design não representa o frame real — placeholder não é evidência) +Appetite: ~1–1,5 dia eng; um outcome verificável: cada resultado da busca/lista do acervo mostra uma imagem da PRÓPRIA fala (não a capa da sessão inteira), e falas da mesma sessão deixam de repetir a mesma imagem. +Responsável: — + +## Intenção + +Relato do usuário (verbatim): _"Os previews de cada fala na página de busca estão mostrando sempre o mesmo frame do Youtube. Quando na verdade eu queria um frame do meio da fala de Jorge Solla. O frame do youtube geralmente mostra uma pessoa aleatória, pois é o início da sessão inteira e não da fala de Jorge Solla."_ + +Hoje o preview de cada fala na busca do acervo (`/campanha/comunicacao/acervo`) é sempre a capa do vídeo do YouTube — o primeiro frame da sessão inteira. Como toda fala de uma sessão aponta para o mesmo vídeo, todos os resultados daquela sessão exibem a MESMA imagem, e essa imagem costuma mostrar outra pessoa, não Jorge Solla. A assessoria varre a lista e não reconhece nada: a miniatura não ajuda a distinguir uma fala da outra e ainda obriga a abrir fala por fala para descobrir qual é qual. O que se quer é uma âncora visual por fala: um frame do meio daquela fala. + +## Reabertura de anti-goal (evidência de produto) + +C175/#1085 (`docs/plans/acervo-preview-do-trecho-na-listagem.md` + `...-impl.md`) entregou a miniatura `hqdefault` do YouTube e declarou EXPLICITAMENTE como anti-goal extrair/renderizar um frame no servidor ou usar ffmpeg — "meio do trecho" foi dado como impossível. A evidência de produto acima mostra que o anti-goal produziu um preview inútil (repetido por sessão e mostrando o começo da sessão). Este item **reabre aquele anti-goal**, conforme `AGENTS.md` ("reopen only with new product evidence"). O que muda: agora há um pedido de produto explícito e já existe no repo um caminho de extração de vídeo (o pipeline ffmpeg do corte de trecho) que torna o frame do meio viável para falas com VOD da Câmara. + +## Persona e fluxo + +- **Persona / contexto:** assessoria de comunicação (jornalista/videomaker), com prazo curto, varrendo a busca do acervo no escritório ou em campo. +- **Job principal:** reconhecer cada fala pela imagem e decidir qual abrir, sem abrir fala por fala. +- **Fluxo desejado:** busca/filtra → cada resultado mostra uma imagem daquela fala → bate o olho e reconhece a cena → abre só a fala certa. +- **Anti-goals de produto:** LLM ou curadoria manual de frames; expor dado novo; mexer no leader/advisor (seguem fora do acervo); mirroring/backfill de todas as capas; o valor é por-fala, nunca por-sessão; player/hover-play na lista. + +## Objetivo e aceite + +- Cada fala com VOD da Câmara mostra um frame do meio da fala (não a capa da sessão). +- Falas da mesma sessão deixam de compartilhar a mesma imagem. +- Quando não há VOD, mantém-se a capa atual do YouTube (fallback honesto) ou um slot neutro — nunca imagem quebrada. +- **Guardrail (performance):** miniatura pequena, `lazy`, sem CLS, nunca bloqueia a listagem. +- **Guardrail (LGPD/escopo):** nada novo de Consent; nada exposto além do que a fala já expõe na lista. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — não é métrica nem agregado; é uma escolha de origem de imagem (reconhecimento visual). Nenhum número nasce aqui. +- **Decisões desbloqueadas:** a assessoria escolhe qual fala abrir pelo reconhecimento da cena. +- **Forma:** _adiada ao plano de implementação_ — aqui só a restrição de produto: nada de dashboard/contagem nova. + +## Dados da decisão (literais) + +- **O que existe hoje:** `youtubeThumbnailUrl(videoId)` → `https://i.ytimg.com/vi//hqdefault.jpg` (`src/lib/speechVod.ts:168-169`), consumido em `src/utilities/speech/speechViewModels.ts:240` (`SpeechListItemViewModel.thumbnailUrl`) e renderizado por `SpeechResultThumbnail.tsx` (`h-20 w-32`) em `SpeechResultCard.tsx:175-181`. +- **A API do VOD não traz poster:** `parseVodStatus` (`src/lib/speechVod.ts:72-92`) mapeia só `titulo/subtitulo/duracao/horario/linkParaDownload/linkParaReproducao`. +- **Campos da fala disponíveis hoje** (`src/collections/Speech.ts`): `durationSeconds:155`, `eventStartAt:218`, `youtubeUrl:230`, `excerptTMs:252`, `vodPlaybackUrl:259`, `vodDownloadUrl:265`; `excerptTMs` é o offset de início da fala na sessão. +- **Instante que define "meio da fala":** `excerptTMs + durationSeconds/2` (início da fala + metade da duração). Este é o único literal que a intenção fixa; o resto (onde/como gerar, cache) é implementação. +- **Sem campo de poster:** `src/collections/Media.ts:37-39` só tem `alt`; nenhum cache de poster de fala existe. +- **Pipeline de vídeo já existente:** `src/utilities/speech/speechCutJob.ts:230-245` baixa o VOD para `source.mp4` e roda ffmpeg; `buildSpeechCutFfmpegArgs` (`src/lib/speechCut.ts:157-195`) só corta `[start,end]`, sem extrair still. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/utilities/speech/speechViewModels.ts` (campo da miniatura), `SpeechResultThumbnail.tsx`/`SpeechResultCard.tsx` (slot já existe), `src/utilities/speech/speechVodResolver.ts`/`speechCutJob.ts`/`src/lib/speechCut.ts` (ffmpeg), campos de `src/collections/Speech.ts`. +- **Precedente a olhar:** `docs/plans/acervo-preview-do-trecho-na-listagem-impl.md` (o VM já é dono do link da miniatura; raw ``; e2e pina o literal). +- **Risco de acoplamento:** não transformar isto no chore DRY C179/#1093 (helper único da URL `hqdefault`); não alterar busca/excerto/highlight; leader lockdown intocado; os testes que hoje pinam `hqdefault` precisarão de revisão no plano de implementação. + +## Dependências + +- Nenhuma dura. Soft: C172 (mesma matemática de coordenadas da fala) e C178 (mesma vertical do acervo). C179 é só DRY do helper de thumbnail. + +## Fora de escopo + +- Player, autoplay ou hover-play na lista. +- Mirroring/backfill de todas as capas em S3/bucket. +- Extração de frame para falas sem VOD. +- Trocar `next/image` (o e2e browserless não alcança `i.ytimg.com`). +- Consolidar o helper `hqdefault` nas 3 ocorrências existentes (destino: C179/#1093). + +## Rabbit holes de produto + +- **"Extrair frame de todas as falas / backfill geral."** Se alguém "só completar": job de mirroring, storage, reconciliação e custo. **Corte neste item:** extração sob demanda, só onde há VOD. +- **"Escolher o frame à mão / curadoria manual."** Se alguém "só completar": fila editorial e UI de seleção. **Corte neste item:** o instante é `excerptTMs + durationSeconds/2`, sem curadoria. +- **"Preview em vídeo no hover."** Se alguém "só completar": embed, autoplay, banda e ruído na varredura. **Corte neste item:** imagem estática. +- **"Consertar os 3 hotlinks de `hqdefault` de uma vez."** Se alguém "só completar": blast radius em `corte/[id]` e no player e quebra do pino e2e. **Corte neste item:** fica no C179. + +## Questões em aberto (produto) + +- **De onde vem o frame do meio?** **Opções:** A) extrair do MP4 do VOD da Câmara no instante `excerptTMs + durationSeconds/2`, reusando o pipeline ffmpeg do corte, gerado sob demanda e cacheado | B) storyboard do YouTube na janela da fala (frágil/não documentado) | C) manter `hqdefault` como está. **Recomendação:** A quando há VOD — é o único caminho que entrega o meio da fala real; B rejeitada salvo prova de que funciona; C rejeitada — é exatamente o defeito. _(assumido — validar com produto)_ +- **E quando não há VOD?** **Opções:** A) capa atual do YouTube como fallback honesto | B) slot neutro. **Recomendação:** A — mantém reconhecimento parcial sem mentir sobre a origem; usar B só se o fallback confundir (mesma imagem repetida por sessão). +- **O frame é cacheado ou recalculado a cada render?** **Opções:** A) gerar uma vez e reusar | B) recalcular sob demanda. **Recomendação:** A — miniatura pequena e estável, sem custo por varredura. _(a forma concreta fica na implementação)_ +- **Comportamento quando o frame extraído vier ruim/escuro?** **Opções:** A) aceitar e mostrar | B) cair para a capa do YouTube. **Recomendação:** A — não inventar heurística de qualidade neste item; reavaliar com uso real. + +## Referências + +- `docs/plans/acervo-preview-do-trecho-na-listagem.md` (C175 — anti-goal reaberto) e `docs/plans/acervo-preview-do-trecho-na-listagem-impl.md` +- `docs/plans/c167-cortar-trecho-publicar.md` (precedente do pipeline de corte) +- `src/utilities/speech/speechCutJob.ts` · `src/lib/speechCut.ts` · `src/lib/speechVod.ts` · `src/utilities/speech/speechViewModels.ts` +- `AGENTS.md` / `AGENTS-campaign.md` — convenções do acervo e da campanha + +## Self-score (shaping) + +5/5 — (1) fatia = um outcome verificável (cada fala com uma imagem da própria fala, sem repetição por sessão); (2) appetite ~1–1,5 dia, aditivo ao slot existente; (3) persona + job + aceite claros, com fallback honesto declarado; (4) direção no codebase é hipótese, sem schema/signature; (5) zero decisão dura de engenharia — só o literal do instante "meio da fala" e a reabertura de anti-goal explicitada. diff --git a/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html b/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html new file mode 100644 index 00000000..ea588dfa --- /dev/null +++ b/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html @@ -0,0 +1,690 @@ + + + + + + + Design — Biblioteca de cortes: apagar e recuperar falhas + + + + +
+
+

Design de gate · campanha

+

+ Biblioteca limpa, falha recuperável +

+

+ A assessoria apaga um corte sem serventia com confirmação proporcional ao risco e tenta + novamente uma falha no mesmo lugar em que a encontra. +

+
+ + +
+
+
+

Cena A · desktop ~1280

+

+ Lista com os três estados +

+
+ +
+ +
+
+ + +
+
+
+

Comunicação

+

Biblioteca de cortes

+
+
+
+ +
+
+ +
+ + +
+
+ +
+ + + + + + + + +
+
+
+
+
+
+ + +
+
+

Cenas B e C · confirmação

+

+ O aviso cresce somente quando existe link público +

+
+
+
+
+ + Publicado +
+ +
+
+ + Despublicado ou falhou +
+
+
+ + +
+
+

Cena D · mobile ~390

+

+ Falha e próxima ação cabem sem esconder apagar +

+
+
+
+ + Biblioteca de cortes +
+
+
+ +
+ + +
+ +
+
+
+ Publicado + 16 set. 2026 +
+

Título do corte publicado

+

+ Fala de origem · 00:42 +

+
+ + Abrir corte + + + +
+
+ +
+
+ Falhou + 15 set. 2026 +
+

Título do corte que não foi concluído

+

+ Fala de origem · 00:35 +

+
+ +
+ + Abrir corte + + +
+
+
+
+
+ +
+
+
+ + diff --git a/docs/plans/biblioteca-cortes-apagar-e-falhas.md b/docs/plans/biblioteca-cortes-apagar-e-falhas.md new file mode 100644 index 00000000..3bd05ab7 --- /dev/null +++ b/docs/plans/biblioteca-cortes-apagar-e-falhas.md @@ -0,0 +1,105 @@ +# Biblioteca de cortes: apagar um corte e não acumular cortes falhados + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1105 +Priority: P2 +Impeccable: B — encaixe na biblioteca de cortes (lista + detalhe): ação de apagar e tratamento de corte falhado +Design UI: docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html +Appetite: ~1 dia eng; um outcome verificável: a assessoria consegue apagar um corte que não serve, e a lista não acumula itens "Falhou" sem ação. +Responsável: — + +## Intenção + +A biblioteca de cortes (C168) resolveu o reencontro, mas deixou dois becos. Primeiro: um corte errado, duplicado ou que ninguém vai usar fica lá para sempre — só um admin do Payload consegue apagar. A assessoria, que é quem sabe que o item não presta, não tem como limpar a própria biblioteca. Segundo: um corte que falhou continua listado como um item "Falhou" morto, sem nenhuma ação; o único "Tentar novamente" que existe mora no diálogo da fala de origem, não onde a pessoa vê a falha. O pedido é direto: poder apagar um corte, e não acumular cortes falhados — ou eles somem sozinhos, ou aparecem com um "Tentar novamente" acionável. + +## Persona e fluxo + +- **Persona / contexto:** assessoria de comunicação (`communicator`) e coordenação/candidatura, em `/campanha/comunicacao/acervo/cortes`, na mesa ou no celular, sob prazo — quem não é da vertical (`advisor`, `leader`) segue negado. +- **Job principal:** "manter a biblioteca limpa e recuperar um corte que falhou sem ter de voltar à fala de origem". +- **Fluxo desejado:** abre a biblioteca → vê um corte que não serve (ou um "Falhou") → no item ou no detalhe decide apagar (com confirmação) ou tentar de novo → a lista reflete: o corte sumiu, ou o corte falhado voltou a processar. +- **Anti-goals de produto:** não virar lixeira/desfazer (soft delete), restauração de apagado, ações em lote, editor de vídeo, métricas ou segundo fluxo de corte. +- **Reabertura de anti-goal (produto):** o C168 (#1015, `docs/plans/c168-biblioteca-cortes.md`) declarou "deletar corte não existe na UI (admin pode)" como guardrail aceito. O pedido humano reabre esse anti-goal de propósito. Ao apagar, respeitar o contrato da página pública: um corte `published` tem link público `/corte/`, então apagá-lo é ação explícita e avisada — nunca em silêncio. + +### Esboço de fluxo (B) + +```text +[biblioteca de cortes: lista] → abre item/corte +→ corte que não serve: [Apagar] → confirmação (avisa o link público se published) → some da lista +→ corte "Falhou": [Tentar novamente] (ou já não aparece como item morto) +→ [outcome: biblioteca limpa e corte falhado recuperável de onde se vê a falha] +``` + +### Design UI (B) + +- Design UI (gate): `docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html` + +## Objetivo e aceite + +- A biblioteca (lista e/ou detalhe) permite apagar um corte, com confirmação explícita do efeito no link público quando o corte está publicado. +- Corte falhado não fica como item morto: ou é descartado automaticamente, ou aparece com "Tentar novamente" acionável — nunca as duas coisas ausentes. +- Nenhuma leitura nova de métricas, comentários ou coleções. +- **Quem pode apagar:** a mesma audiência da vertical que hoje lê/edita o acervo (`communicator`, `coordinator`, `candidate`), alinhada ao kill-switch de publicar/despublicar; o admin do Payload segue podendo. `advisor` e `leader` negados (fail-closed). +- **Guardrail de produto preservado:** apagar um `failed` não quebra nada público (nunca houve página); apagar um `published` quebra o `/corte/` já distribuído — por isso o aviso. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — é uma decisão de estado do item (apagar/recuperar), não um painel. +- **Decisões desbloqueadas:** a assessoria decide o que sai da biblioteca e o que vale tentar de novo; nenhuma leitura agregada nova. +- **Forma:** _adiada ao plano de implementação_ — restrição de produto: sem contadores de biblioteca nem métricas de falha. + +## Dados da decisão (literais) + +- **Vocabulário de status:** `processing` | `published` | `unpublished` | `failed`; o rótulo visível de `failed` é "Falhou". +- **Contrato público:** `failed` **nunca** tem página pública (nada foi publicado); `published` tem link público `/corte/` — apagar quebra esse link para quem já recebeu. +- **Rotas internas:** `/campanha/comunicacao/acervo/cortes` (lista) e `/campanha/comunicacao/acervo/cortes/[id]` (detalhe). +- **Retry existente:** é por request (`retryOf`) e só aceita `status === 'failed'` — reusa a row, não duplica. +- **Copy de confirmação (apagar publicado):** título "Apagar este corte?" · corpo "O link público /corte/ deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita." · botões "Cancelar" / "Apagar". +- **Copy de confirmação (apagar não publicado/falho):** mesma pergunta sem a linha do link público; botões "Cancelar" / "Apagar". +- Acesso: `communicator`, `coordinator`, `candidate` apagam e reprocessam; `advisor` e `leader` negados (fail-closed); admin mantém o delete do Payload. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/collections/SpeechCut.ts` (access de delete hoje é só-admin), `src/utilities/speech/speechCutListUrl.ts` + `speechCutPageData.ts` (o `where` sem `q` volta `{}` e a lista inclui `failed`), `src/components/campaign/speech/SpeechCutLibraryCard.tsx` e o detalhe `.../cortes/[id]/page.tsx`, `SpeechCutDialog.tsx` + `actions/speech.ts` (retry reusável), e as rotas `cortes/[id]/...`. +- **Precedente a olhar:** o kill-switch de publicar/despublicar e a confirmação de "Despublicar este corte?" do C168; o retry por `retryOf` do C167. +- **Risco de acoplamento:** não criar fluxo paralelo ao retry do C167 (editar o dono); não misturar com o `where`/ordenação das falas; gate `speechCatalog` e leader lockdown intocados; `/corte/` é do C167 (aqui só se decide o efeito de apagar). + +## Dependências + +- **C168** (#1015, entregue) — a biblioteca é a superfície; seu anti-goal de "não deletar pela UI" é reaberto aqui. +- **C169** (#1045, entregue) — falha honesta e retry por row; é o material do tratamento de corte falhado. +- Suave: **C167** (#1014) — o retry por request que se reusa. + +## Fora de escopo + +- Editor de vídeo (trocar início/fim, re-render) — destino: nenhum, não é pedido. +- Lixeira/desfazer e restauração de apagado (soft delete) — reversibilidade fica fora desta fatia. +- Ações em lote (apagar/tentar vários). +- Métricas, comentários, coleções/kits. +- Mudar a página pública além de a ação de apagar refletir nela. + +## Rabbit holes de produto + +- **"Já que apaga, deixa uma lixeira pra desfazer."** Se alguém "só completar": soft delete, retenção e restauração. **Corte neste item:** apagar é definitivo, com confirmação; sem lixeira. +- **"Corte falhado vira fila de reprocessamento automático."** Se alguém "só completar": worker/cron e retry em lote. **Corte neste item:** resolver no ponto da falha (diálogo de origem) com o retry que já existe; na biblioteca, no máximo um "Tentar novamente" manual. +- **"Aproveita e mostra estatística de falhas."** Se alguém "só completar": dashboard de vaidade. **Corte neste item:** sem métrica. + +## Questões em aberto (produto) + +- **Descartar corte falhado automaticamente ou manter com "Tentar novamente"?** **Opções:** A) a falha deixa de virar item de biblioteca e é resolvida onde nasceu (diálogo da fala, retry existente) | B) o falhado aparece na biblioteca com "Tentar novamente" acionável. **Recomendação: A, com B como piso** — falha não é acervo; se por algum motivo ela aparecer na biblioteca, aparece com retry, nunca como item morto. _(assumido — validar no gate)_ +- **Apagar só o falhado/não publicado ou qualquer corte com aviso?** **Opções:** A) só falhado/não publicado | B) qualquer corte, com aviso explícito pelo link público. **Recomendação: B** — a assessoria precisa poder remover um corte errado/duplicado mesmo publicado; o aviso cobre o contrato do link. _(assumido)_ +- **Onde fica a ação?** **Opções:** A) no detalhe | B) no item da lista e no detalhe. **Recomendação: B** — a lista é onde a limpeza acontece; a confirmação evita o clique errado. _(assumido)_ +- **Publicado, apagar é definitivo ou só despublica?** **Opções:** A) apaga de vez com aviso | B) redireciona para "Despublicar". **Recomendação: A** — despublicar é reversível e já existe; quem quer apagar quer remover. _(assumido)_ + +## Referências + +- GitHub Issue: — (após `pnpm agent:register`) +- Design UI (gate): `docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html` +- `docs/plans/c168-biblioteca-cortes.md` (anti-goal reaberto) · `docs/plans/c169-corte-vod-confiabilidade.md` · `docs/plans/c167-cortar-trecho-publicar.md` +- Arquivos-chave (pista, não contrato): `src/collections/SpeechCut.ts`, `src/lib/speechCut.ts`, `src/utilities/speech/speechCutListUrl.ts`, `src/utilities/speech/speechCutPageData.ts`, `src/components/campaign/speech/SpeechCutLibraryCard.tsx`, `src/components/campaign/speech/SpeechCutDialog.tsx`, `src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx`, `src/app/(campaign)/campanha/actions/speech.ts` +- Testes a tocar: `tests/unit/speechCutDialog.unit.spec.tsx`, `tests/unit/speechCutListUrl.unit.spec.ts`, `tests/int/speechCut.int.spec.ts`, `tests/e2e/campaignSpeechCut.e2e.spec.ts` +- `AGENTS.md` / `AGENTS-campaign.md` — vertical Comunicação, gate do acervo, naming/i18n + +## Self-score (shaping) + +5/5 — (1) fatia = um outcome verificável (apagar um corte e não acumular falhados); (2) appetite ~1 dia declarado e o escopo cabe (encaixe na biblioteca, sem lixeira/fila/editor); (3) persona, job e aceite em linguagem de usuário, com a reabertura de anti-goal e o contrato do link público explicitados; (4) direção no codebase é hipótese com precedente nomeado, sem contrato; (5) zero decisão dura de engenharia (rota exata da ação e access ficam para o plano de implementação — aqui só se declara quem pode apagar, como decisão de produto). diff --git a/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html b/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html new file mode 100644 index 00000000..e838c74d --- /dev/null +++ b/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html @@ -0,0 +1,575 @@ + + + + + + + Design — Navegação de Comunicação agrupada + + + +
+
+

Design de gate · campanha

+

Comunicação se lê como um bloco

+

+ Pai e filhos ficam próximos, unidos por uma única linha de hierarquia. Só o destino mais + específico recebe o estado ativo — no rail e no drawer. +

+
+ + +
+
+
+

Cena A · desktop ~1280

+

+ Rail com agrupamento local, sem redesenhar a navegação +

+
+ +
+ +
+
+ + +
+
+
+

Comunicação

+

Biblioteca de cortes

+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+

Cena B · mobile ~390

+

+ Drawer “Mais” com a mesma gramática de agrupamento +

+
+ +
+
+ Campanha + +
+
+
+
+
+
+
+ + +
+
+ + +
+
+

Cena C · estados do sub-item

+

+ Uma forma compartilhada; contraste suficiente para um único ativo +

+
+ +
+ + + +
+ +
+ Destinos preservados: + /campanha/comunicacao, + /campanha/comunicacao/acervo + e + /campanha/comunicacao/acervo/cortes. +
+
+
+ + diff --git a/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai.md b/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai.md new file mode 100644 index 00000000..094e925b --- /dev/null +++ b/docs/plans/nav-comunicacao-sub-itens-junto-ao-pai.md @@ -0,0 +1,92 @@ +# Navegação: os sub-itens de Comunicação ficam junto do pai + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1106 +Priority: P3 +Impeccable: B — encaixe na navegação da campanha (sidebar desktop + drawer overflow mobile) +Design UI: docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html +Appetite: ~0,25–0,5 dia eng; um outcome verificável: numa olhada, "Acervo de falas" e "Biblioteca de cortes" se leem como filhos de "Comunicação", não como destinos distantes. +Responsável: — + +## Intenção + +O C174 aninhou os dois destinos da vertical de comunicação sob "Comunicação", mas a associação visual ficou fraca: "Acervo de falas" e "Biblioteca de cortes" aparecem como entradas soltas em vez de filhos do pai, e a assessoria perde a hierarquia que o aninhamento queria criar. O agravante é que os dois consumidores — sidebar desktop e drawer overflow mobile — desenham a sub-lista por caminhos diferentes (um usa os primitivos `SidebarMenuSub`, o outro uma indentação própria), então a leitura pode divergir entre telas sem ninguém notar. O pedido é estreito: fazer o par se ler como filho do pai, de forma igual nas duas superfícies, sem mexer em destinos, permissões ou no desenho geral da navegação. + +## Persona e fluxo + +- **Persona / contexto:** assessoria de comunicação (`communicator`, coordenação/candidatura) navegando em `/campanha`, muitas vezes no celular, em dia de produção de peça. +- **Job principal:** reconhecer de imediato que "Acervo de falas" e "Biblioteca de cortes" pertencem a "Comunicação". +- **Fluxo desejado:** abre o menu → vê "Comunicação" com seus dois filhos logo abaixo, agrupados, como um bloco → escolhe o destino → o sub-item ativo é apenas o mais específico. +- **Anti-goals de produto:** não é redesenho do rail nem novo nível de menu; não vira organograma de destinos. + +### Esboço de fluxo (B) + +```text +[menu aberto] → "Comunicação" lê-se como pai com bloco de 2 filhos logo abaixo +→ desktop (sidebar) e mobile (drawer overflow) mostram a MESMA relação +→ toca "Biblioteca de cortes" → só esse sub-item fica ativo (acervo não acende junto) +``` + +### Design UI (B) + +- Design UI (gate): `docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html` — cenas: sidebar desktop (pai + par de filhos, estados ativo/inativo) e drawer overflow mobile (~390px), com a leitura de agrupamento alinhada entre as duas. + +## Objetivo e aceite + +- O par de sub-itens se lê como **filho do pai**: agrupamento visual claro, indentação e proximidade coerentes com "Comunicação". +- A **mesma leitura** vale no desktop e no drawer mobile. +- O estado ativo do sub-item continua destacando **só o mais específico** (`activeCampaignSubItemHref`). +- **Nenhum destino some, muda de href ou ganha ícone.** +- **Guardrails:** não renomear rotas/hrefs; não mudar permissões (`communicator`/staff); não introduzir segundo nível além do existente; não redesenhar a navegação inteira. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — é navegação; nenhum número ou agregado é exibido. +- **Decisões desbloqueadas:** a assessoria decide rápido em qual destino da vertical entrar, sem procurar o que já está sob o pai. +- **Forma:** _adiada ao plano de implementação_ — restrição: sem badges/contadores novos. + +## Dados da decisão (literais) + +- Destinos e títulos exatos (inalterados): **"Acervo de falas"** → `CAMPAIGN_COMMUNICATION_ACERVO` (`/campanha/comunicacao/acervo`); **"Biblioteca de cortes"** → `CAMPAIGN_COMMUNICATION_CORTES` (`/campanha/comunicacao/acervo/cortes`). +- Pai: **"Comunicação"** → `CAMPAIGN_COMMUNICATION_HOME` (`/campanha/comunicacao`). +- O drawer mobile hoje usa indentação própria (`ml-5 border-l pl-3`); o desktop usa `SidebarMenuSub` (`mx-3.5 border-l px-2.5`). O item decide se os dois **convergem para uma leitura só**. +- Fonte única dos títulos/hrefs: `communicationSubItems` em `src/components/campaign/shell/nav.ts` — não duplicar a lista. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/components/campaign/shell/nav.ts` (fonte única), `CampaignSidebar.tsx` (desktop), `CampaignBottomNav.tsx` (drawer), `src/components/ui/Sidebar.tsx` (classes base), `src/lib/campaignPaths.ts`. +- **Precedente a olhar:** C174 em [`acervo-encontrar-cortes.md`](acervo-encontrar-cortes.md) — foi quem introduziu o aninhamento e o comentário "mesma forma nas duas superfícies". +- **Risco de acoplamento:** não regredir o `activeCampaignSubItemHref` (irmãos com prefixo compartilhado); o "como" converge aqui. + +## Dependências + +- C174 (entregue — introduziu o aninhamento). + +## Fora de escopo + +- Mudar a ordem dos destinos; novo item de navegação; badges/contadores; redesenho do rail; qualquer mudança em rotas, permissões ou ícones. + +## Rabbit holes de produto + +- **"Já que vamos mexer, agrupa tudo por seções."** Se alguém "só completar": segundo nível novo, recolhimento por grupo, reordenação. **Corte neste item:** só a relação pai↔filhos existente fica legível. +- **"Faz o rail inteiro colapsável com animação."** Se alguém "só completar": redesenho do shell. **Corte neste item:** encaixe local no desenho atual. +- **"Alinha desktop e mobile reescrevendo o drawer com os primitivos."** Se alguém "só completar": troca estrutural do drawer. **Corte neste item:** a decisão é de leitura; o caminho técnico é da implementação. + +## Questões em aberto (produto) + +- **Como aproximar?** **Opções:** A) só espaçamento/indentação no desenho atual | B) usar os primitivos shadcn também no drawer. **Recomendação:** garantir a hierarquia legível **nos dois**; o "como" fica na implementação. _(assumido — validar com produto)_ +- **O sub-item ativo ganha marcador extra?** **Opções:** A) manter o padrão atual | B) adicionar marcador. **Recomendação:** A — manter o padrão atual. _(assumido)_ + +## Referências + +- GitHub Issue: — (a registrar) +- Design UI (gate): `docs/plans/nav-comunicacao-sub-itens-junto-ao-pai-ui-design.html` +- Planos irmãos: [`acervo-encontrar-cortes.md`](acervo-encontrar-cortes.md) (C174), [`remodelagem-municipios.md`](remodelagem-municipios.md) (nav), [`acervo-cortes-divida-pos-c174.md`](acervo-cortes-divida-pos-c174.md) (defer da sub-lista duplicada). +- Arquivos-chave (pista, não contrato): `src/components/campaign/shell/nav.ts`, `src/components/campaign/shell/CampaignSidebar.tsx`, `src/components/campaign/shell/CampaignBottomNav.tsx`, `src/components/ui/Sidebar.tsx`, `src/lib/campaignPaths.ts` +- Testes a olhar: `tests/unit/campaignNav.unit.spec.ts`, `tests/e2e/campaignBottomNav.e2e.spec.ts` +- `AGENTS.md` / `AGENTS-campaign.md` — shell da campanha e convenções de navegação. + +## Self-score (shaping) + +4/5 — (1) fatia = um outcome verificável (o par se lê como filho do pai, igual nas duas superfícies); (2) appetite de ~0,25–0,5 dia comporta um encaixe de navegação; (3) persona, job e aceite em linguagem de produto; (4) direção no codebase é hipótese revisável; (5) zero decisão dura de engenharia — o "como" (espaçamento vs primitivos) fica explicitamente para a implementação. diff --git a/docs/plans/sollinha-fechado-por-padrao-no-desktop.md b/docs/plans/sollinha-fechado-por-padrao-no-desktop.md new file mode 100644 index 00000000..6a9e5c71 --- /dev/null +++ b/docs/plans/sollinha-fechado-por-padrao-no-desktop.md @@ -0,0 +1,87 @@ +# Sollinha começa fechado no desktop + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1107 +Priority: P2 +Impeccable: A — N/A sem nova superfície (muda o estado inicial de um painel que já existe; o estado fechado já é desenhado) +Design UI: N/A — sem UI nova +Appetite: ~0,25–0,5 dia eng; um outcome verificável: abrir `/campanha` no desktop em uma sessão nova mostra o conteúdo em largura cheia, com o Sollinha fechado; quem quiser abre pelo botão/FAB como hoje. +Responsável: — + +## Intenção + +Hoje, numa sessão nova no desktop, o painel do Sollinha abre sozinho ocupando ~25% da largura. Quem abre `/campanha` para trabalhar no conteúdo perde largura sem ter pedido: o chat se impõe antes de qualquer intenção de usá-lo. O pedido é inverter o padrão — começar fechado e abrir sob comando. O FAB e o botão no header continuam abrindo normalmente. + +## Persona e fluxo + +- **Persona / contexto:** staff (coordinator, candidate, advisor, communicator) no desktop em `/campanha`, em trabalho de mesa — leitura de dados, edição, navegação entre municípios. +- **Job principal:** ter a tela em largura cheia por padrão e chamar o Sollinha quando precisar. +- **Fluxo desejado:** entra em `/campanha` numa sessão nova → vê o conteúdo em largura cheia, com o Sollinha fechado e o botão/FAB visível → se quiser, abre pelo botão/FAB → a conversa aparece e o painel se comporta como hoje → se fechar, a escolha fica lembrada na sessão; só uma sessão nova volta ao padrão fechado. +- **Anti-goals de produto:** não virar redesenho do painel; não virar onboarding/hint obrigatório do chat; não alterar o comportamento mobile. + +## Objetivo e aceite + +- Sessão nova no desktop inicia com o Sollinha fechado e o conteúdo em largura cheia. +- Abrir pelo FAB/botão funciona como hoje e restaura a conversa. +- A escolha do usuário (aberto/fechado) segue lembrada dentro da sessão — não forçar fechado em cima da escolha de quem já abriu. +- Mobile intocado: drawer continua fechado por padrão e só abre sob comando. +- **Guardrail:** nenhum estado "meio aberto" — fechado é fechado, aberto é aberto, sem largura residual. +- **Guardrail:** o chat não perde conversa por começar fechado; o botão de abrir continua descobrível. +- **Guardrail:** a convenção de largura do painel aberto (B166/B167) e a persistência de sessão (B199) permanecem; a mudança é só o default de sessão nova, não o estado restaurado. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — nenhum número novo na tela. +- **Decisões desbloqueadas:** N/A — o item muda estado inicial de um painel, não habilita leitura de dado. +- **Forma:** _adiada ao plano de implementação_. + +## Dados da decisão (literais) + +- Largura atual do painel: `CHAT_DEFAULT_PCT='25'`. +- Estado inicial de `open`: nasce `false`. +- O efeito de settle (`measured && !isMobile && !restoredSessionRef.current && !panel.isCollapsed()`) é o que força o aberto hoje. +- Sessão guarda `open` com `openBy: 'user'|'settle'`; ausência de sessão é o único sinal de sessão nova. +- O estado fechado é o que já existe quando o usuário fecha — esta entrega reusa esse estado, não cria um novo. +- A mudança é sobre o **default de sessão nova**, não sobre o estado restaurado. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `CampaignAISidebarContext.tsx` (efeito de settle), `CampaignAISidebarShell.tsx` (`CHAT_DEFAULT_PCT` / `chatVisible` / painel), `sollinhaChatSession.ts` (default de sessão nova), e os testes e2e que pinam o comportamento desktop-open. +- **Precedente a olhar:** `docs/plans/largura-padrao-chat-sollinha.md` (B166/B167), `docs/plans/sollinha-close-mid-stream-persist.md` (B199), `docs/plans/ai-chat-sollinha.md`. +- **Risco de acoplamento:** não quebrar o restore de sessão nem o caminho mobile; o frame de hidratação e o desenho de largura já existentes precisam continuar coerentes. + +## Dependências + +- B167/B188/B199 (entregues — sessão, largura, persistência). + +## Fora de escopo + +- Redesenhar o chat do Sollinha. +- Mudar a largura padrão quando aberto. +- Mexer no comportamento mobile. +- Trocar a skill/agente Sollinha. + +## Rabbit holes de produto + +- **Vira "redesenho do painel".** Se alguém "só completar": repensar layout, animação, trigger do FAB, botão do header. **Corte neste item:** a entrega é só o default inicial; nada de novo desenho. +- **Vira "hint de primeira vez".** Se alguém "só completar": tooltip, popover, checklist de descoberta do chat. **Corte neste item:** o FAB/header já é o convite (ver Questões em aberto). +- **Vira "estado meio aberto".** Se alguém "só completar": largura residual, painel colapsado visível, resize persistido no fechado. **Corte neste item:** fechado é fechado, sem resíduo. + +## Questões em aberto (produto) + +- **Manter o painel com largura 25% mas escondido (CSS) vs. nascer colapsado/0?** **Opções:** A) manter largura e esconder; B) nascer colapsado em 0. **Recomendação:** A — menor risco, preserva o restauro e o desenho de largura já entregue (B166/B167). _(assumido — validar com produto)_ +- **Avisar o usuário que o chat existe?** **Opções:** A) não avisar; B) hint de primeira vez. **Recomendação:** A — o FAB e o botão do header já são o convite; um hint é escopo novo. _(assumido — validar com produto se querem hint de primeira vez.)_ + +## Referências + +- Design UI (gate): N/A +- `docs/plans/ai-chat-sollinha.md` +- `docs/plans/sollinha-close-mid-stream-persist.md` (B199) +- `docs/plans/largura-padrao-chat-sollinha.md` (B166/B167) +- `AGENTS.md` / `AGENTS-campaign.md` +- Testes que pinam o default atual: `tests/e2e/campaignAiChatResize.e2e.spec.ts:97-124`, `tests/e2e/campaignSollinhaWidth.e2e.spec.ts:40-56`, `tests/e2e/campaignSollinhaContext.e2e.spec.ts:140-199,283-327`, `tests/unit/sollinhaChatSession.unit.spec.ts:61,82,92,100`, `tests/unit/sollinhaChatPanelWidth.unit.spec.ts` + +## Self-score (shaping) + +5/5 — (1) fatia = um outcome verificável (sessão nova desktop abre com chat fechado e conteúdo em largura cheia); (2) appetite ~0,25–0,5 dia, mexe só no default inicial e no que pina o teste; (3) persona + job + aceite claros; (4) direção no codebase é hipótese, sem contrato/signature; (5) zero decisões duras de engenharia.