17 KiB
Impl: Agenda: modais usáveis no desktop (criação de atividade, sync e feed)
Status: rascunho Atualizado em: 2026-09-12 Issue: #933 Intenção: docs/plans/c148-modais-agenda-desktop.md Appetite restante: herdado — ~0,5–1 dia eng. Correção local de encaixe em 3 componentes + testes; sem migration, sem schema, sem access/Consent.
Leitura da intenção
- Outcome: no desktop, os três modais da agenda (criação/edição, sync Google, feed ICS) abrem com largura/padding coerentes, nunca cortam conteúdo (corpo rola internamente com o rodapé de ações sempre visível) e o par Início/Término do modal de criação fica lado a lado com data+hora na mesma linha. Mobile intocado.
- O que NÃO negociar: (1) mobile dos três modais não regride — o DOM do Drawer/folha atual (overlay
h-[calc(100dvh-1rem)]+ corpomin-h-0 flex-1 overflow-y-auto; sync/feedmax-h-[85vh]+ corpo rolável +DrawerFooter) permanece exatamente o de hoje; (2) lógica de validação/salvamento e dados intocados; (3) nenhum outro modal do app muda —ui/dialog.tsxe seus 10 consumidores intactos; (4) sem migration/Consent (dado interno de staff,activitynão tem Consent). - O que reavaliar: a hipótese central da intenção está confirmada, com mecânica precisa:
cn= clsx + tailwind-merge resolve conflito de mesma variante (grid→flex,p-6→p-0,gap-4→gap-0,max-w-sm→max-w-2xl), mas não derrubasm:max-w-md/sm:p-8da base quando o override não temsm:; no CSS do Tailwind v4 a utility com variante é emitida depois e vence em ≥640px. Correção = espelhar a variante (sm:max-w-3xl sm:p-0), deixando o twMerge apagar as da base — sem guerra de ordem. Dois defeitos vizinhos no mesmo caminho:w-fullno overlay anula o gutter de 2rem da base (w-[calc(100vw-2rem)]), emax-h-[calc(100dvh-14rem)]é número mágico acoplado às alturas de header/footer. O "empilhamento" do par data/hora é sintoma da largura de 28rem (o grid já ésm:grid-cols-2; o componente já renderiza data+hora inline), não falta de estrutura — não há layout novo a inventar. - Fora de escopo reafirmado: a divergência de breakpoint (overlay 640 via
useNarrowMeasured(640)emActivityCreateOverlayHost.tsx:35/ActivityEditOverlayHost.tsx:34vs sync/feed 768 viauseIsMobile()emsrc/hooks/use-mobile.ts:3) não será unificada nesta entrega; as fronteiras atuais são preservadas.
Abordagem recomendada
flowchart LR
A[ActivityOverlay desktop] --> A1["DialogContent: sm:max-w-3xl sm:p-0<br/>remove w-full e max-w-2xl"]
A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto<br/>(morre max-h-[calc(100dvh-14rem)])"]
A2 --> A3["Início/Término em sm:grid-cols-2<br/>data+hora inline por campo"]
B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]<br/>flex-col p-0 sm:max-w-xl sm:p-0"]
B1 --> B2["header shrink-0 / corpo rolável /<br/>footer shrink-0 com as ações"]
B2 --> B3["mobile: composição atual intacta<br/>(body com ações + DrawerFooter)"]
C["ui/dialog.tsx"] -. "não tocar" .-> A
C -. "não tocar" .-> B
Opções consideradas: A) override local com variantes sm: explícitas nos 3 modais | B) mudar a base ui/dialog.tsx (largura/padding/scroll default) | C) extrair um shell compartilhado "modal desktop com corpo rolável" para os 3 call sites.
Recomendação: A — cada modal já tem conteúdo, padding e largura próprios; a correção é de classe, e o padrão a replicar já existe no repo (QuickActions: flex max-h-[min(85dvh,40rem)] … overflow-hidden + filho overflow-y-auto, CampaignQuickActionsOverlay.tsx:166-186; Advisor: max-h-[calc(100dvh-2rem)] overflow-y-auto, AdvisorPermissionBadge.tsx:86). Zero superfície de regressão fora da agenda.
Rejeitadas:
- B — mexer em
ui/dialog.tsx: base de 10 imports (Command,PetitionSuccessDialog,CampaignNotificationBell,CampaignQuickActionsOverlay,MunicipalityV2StatusReasonDialog,CampaignUpdatesCreateModal,AdvisorPermissionBadge+ os 3 da agenda). Mudarsm:max-w-md/sm:p-8/scroll default é regressão silenciosa em telas não auditadas; a própria intenção marca como rabbit hole. - C — shell compartilhado: seria um pass-through raso (classes + 2 slots) para 3 modais heterogêneos (overlay
p-0+ cards; sync/feed padding próprio emax-w-xl), sem conhecimento encapsulado — depth check "pass-through raso → não criar". O repo já resolve isso por receita inline repetida (QuickActions, Advisor, sheet mobile). Gatilho de revisitação: 4º modal da agenda com o mesmo corpo rolável, ou adoção do padrão por outro domínio.
Decisões de engenharia
- Onde a rolagem do overlay desktop acontece. Opções: A)
formmin-h-0 flex-1+ corpomin-h-0 flex-1 overflow-y-auto, herdando omax-hdoDialogContent(padrão do sheet mobile e do QuickActions) | B) mantermax-h-[calc(100dvh-14rem)]e só corrigir largura/padding. Recomendação: A — o magic number é a causa do corte quando header/footer mudam de altura; A torna a geometria derivada (100dvh - 2remno content + header/footershrink-0). Rejeitada: B porque preserva o acoplamento que gerou o defeito. - Sync/feed: rodapé fixo vs conteúdo inteiro rolável. Opções: A) header/body/footer em flex, ações extraídas para footer
shrink-0(draft cena 2; aceite exige "rodapé de ações permanece visível") | B)overflow-y-autonoDialogContentinteiro (padrão Advisor). Recomendação: A — só A satisfaz o aceite; B rola as ações para fora. Rejeitada: B (mais simples, porém falha o aceite explícito). - Disposição Início/Término (questão aberta de produto). Opções: A) dois campos lado a lado, cada um data+hora inline (draft cena 1) | B) um campo por linha com data+hora inline. Recomendação: A — o grid
sm:grid-cols-2(ActivityOverlay.tsx:355) já existe; com a largura recuperada cada coluna ganha ~20rem e o gatilho de data (~10rem) mostra07/08/2026sem truncar. Rejeitada: B (perde a largura recuperada; vira fallback). Gatilho de revisitação: se no gate a faixa 640–767px ficar ilegível, cair para B nessa faixa (não antes). - Larguras. Overlay:
sm:max-w-3xl(48rem, cena 1) vssm:max-w-2xl(42rem, hipótese da intenção). Sync/feed:sm:max-w-xl(36rem, cena 2) vs mantersm:max-w-lg(32rem). Recomendação: 48rem/36rem — o rascunho UI é o gate aprovado; "largura generosa" e "ampliar pouco" estão materializados nas cenas. Rejeitadas: manter 42rem/32rem — a diferença é uma classe e o alvo está no gate. - Como preservar o mobile exatamente. Opções: A) dividir o
bodyemcontent+actionRowe recompor o ramo mobile na mesma ordem de hoje | B) footer desktop composition: stickydentro do corpo atual. Recomendação: A — DOM mobile idêntico (os unit mobile e os e2e C103/C94/C114 são a prova), sem sticky frágil com padding negativo. Rejeitada: B (quebra comspace-y/padding do content; menos explícito que o flex).
Componentes / mudanças
ActivityOverlay.tsx(só caminho desktop):DialogContent(:601):flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0→flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0(removew-fullpara voltar o gutterw-[calc(100vw-2rem)]da base;sm:espelhado derrubasm:max-w-md/sm:p-8via twMerge).DialogHeaderdesktop (:602): +shrink-0(mantémborder-b px-6 py-4).form(:442): ramo desktopflex flex-col→flex min-h-0 flex-1 flex-col(igual ao sheet).- corpo (
:451-456): ramo desktopmax-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6→min-h-0 flex-1 overflow-y-auto overscroll-contain p-6; +data-slot="dialog-scroll-body". - rodapé (
:520-525): classes inalteradas (shrink-0já existe); +data-slot="dialog-footer".
GoogleCalendarSyncDialog.tsx:- separar o
body(:186-286) emcontent(sem as ações) +actionspor estado (not-configured→ nenhuma;disabled→ Reativar;synced/paused→ Abrir Google Calendar quandopaused+ Sincronizar agora + Desativar).actionErrorvai para o rodapé desktop (junto da ação que falhou) e fica na posição atual no mobile (entre content e actions). - mobile (
:296-310): composição idêntica à de hoje —DrawerContent max-h-[85vh]+<div className="overflow-y-auto px-4 pb-2">{content}{actionError}{actionRow}</div>+DrawerFooterFechar. - desktop (
:313-323):DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0";DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left"; corpo<div data-slot="dialog-scroll-body" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"><div className="space-y-4">{content}</div></div>; rodapé<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">{actionError}{actionRow}</div>(não renderiza quando ambos vazios —not-configured).
- separar o
CalendarFeedDialog.tsx:- mesmo split:
content+actions(criado: Fechar desktop + Abrir Google Calendar; senão: Cancelar desktop + Gerar link); erro do form continua no corpo, junto do input. - mobile (
:216-233) idêntico; desktop (:236-248) com o mesmo shell do sync esm:max-w-xl.
- mesmo split:
ActivityDateTimeField.tsx: uma linha — wrapper dos selects (:154)isNarrow ? 'w-16' : 'w-20'→w-16(o draft cena 1 usaw-16; o mobile já eraw-16, então só o desktop muda). Mantém o rótulo completo da data mesmo na faixa 640–767px. Nenhuma outra mudança no componente.src/components/ui/dialog.tsx: sem mudança — base intacta para os 10 consumidores.- Testes:
- unit —
tests/unit/activityOverlay.unit.spec.tsx,googleCalendarSyncDialog.unit.spec.tsx,calendarFeedDialog.unit.spec.tsx: asserções estruturais novas (não de classe): botão de ação dentro de[data-slot="dialog-footer"]e fora de[data-slot="dialog-scroll-body"]; conteúdo no corpo. O caminho desktop é o default dos 3 specs (matchMediamockadomatches:false; overlayisNarrow=false). - e2e —
campaignActivity.e2e.spec.ts(jornada desktop:51): após abrir o modal,boundingBoxda largura> 600(guarda do "estreito"; hoje 448) e|Δy| < 4entre Início e Término — o aceite "não empilha" vira asserção.campaignAgendaGoogleSync.e2e.spec.ts(teste synced:138): encolher o viewport para 1280×560, manter o diálogo aberto, assertarSincronizar agoravisível +[data-slot="dialog-scroll-body"]comscrollHeight > clientHeight+ box do diálogo dentro do viewport. Feed: sem e2e novo (mesmo shell do sync; coberto pelo unit estrutural); os e2e existentes (campaignAgendaFeed.e2e.spec.ts:18e:73) seguem como regressão.
- unit —
- Changelog:
docs/changelog/<data>-c148.md— uma entrada curta da entrega (nunca editar o agregado;pnpm changelog:buildgera). - Migration: sem migration (nenhum schema/field).
- Access / Consent: N/A — dado interno de staff; nenhum write path muda.
- UI: Impeccable B — shape já fixado no rascunho (
docs/plans/c148-modais-agenda-desktop-ui-draft.html, cenas 1–3); craft = classes desta entrega; critique = gate visual nas 3 cenas (1280px e 390px); polish só se o gate pedir. Shells reusados: padrão do Drawer mobile (header/body/footer commin-h-0 flex-1 overflow-y-auto) e o parflex max-h … overflow-hidden+ filho rolável do QuickActions.
Dados → forma (se aplicável)
N/A — a entrega não apresenta dado novo (pergunta 3 de data-presentation não se aplica); é encaixe de modal.
Fases verificáveis
- Tracer — overlay desktop (quota ~40%): classes em
ActivityOverlay.tsx(DialogContent/header/form/corpo) +data-slot+ a linha dow-16emActivityDateTimeField.tsx. Verificação:pnpm test:unitdoactivityOverlay.unit.spec.tsxverde + abrir/campanha/atividadesno browser com edit draft longo (todas as seções) e conferir: largura 48rem, padding contido, corpo rola, rodapé visível, Início/Término na mesma linha. Gate visual da cena 1. - Sync + feed desktop (quota ~40%): split
content/actionsnos dois dialogs + shell header/body/footer +sm:max-w-xl; conferir no browser a cena 2 e o mobile inalterado (cena 3). Verificação: units dos dois dialogs verdes, incluindo as asserções estruturais novas. - Testes + gates (quota ~20%): estender os e2e das duas superfícies (asserções acima); rodar os e2e afetados (
campaignActivity,campaignAgendaFeed,campaignAgendaGoogleSync+ mobile da agenda);pnpm gate:fast;pnpm push(gate:ci completo sem e2e — o e2e é passo explícito da skill).
Rabbit holes / Não escopo (engenharia)
- Não tocar
ui/dialog.tsxnem os outros 7 consumidores; não criarDialogFooterna base. - Não unificar os breakpoints 640 vs 768 (
useNarrowMeasured(640)vsuseIsMobile()). - Não extrair shell/constante compartilhada para os 3 modais (ver Decisão C).
- Não redesenhar o formulário: o draft é ilustrativo (Tipo/Status não existem no form real); só encaixe.
- Não mexer no Popover do calendário (
ActivityDateTimeField.tsx:209,max-h-[22rem]) nem no[data-slot="popover-content"]dos e2e. - Não trocar mais nada em
ActivityDateTimeFieldalém dow-16(sem reestruturar a linha data+hora). - Não guerrear ordem de CSS na mão (
!important, reordenar classes): usarsm:+ twMerge. - Não tocar no mobile (Drawer/
DrawerFooter/nested sheet) — só a recomposição que preserve o DOM.
Riscos e mitigação
- Regressão mobile por refatorar o
body: mitigação — comporcontent + actionError + actionRowna mesma ordem de hoje no ramo mobile e manter unit/e2e mobile (C103/C94/C114) verdes; revisão de diff focada no ramoisMobile/isNarrow. min-h-0faltando em algum elo → corpo não rola e corta: mitigação — cadeiaDialogContent (max-h + flex-col) → form (flex-1 min-h-0) → corpo (flex-1 min-h-0 overflow-y-auto); testar com edit draft cheio (tasks + demandas) numa janela baixa (1280×560).- Asserção de largura do e2e frágil: mitigação — threshold
> 600(não o valor exato) e comentário apontando o aceite; o par usa|Δy| < 4. - e2e de sync em viewport baixo flaky (conteúdo pode não transbordar): mitigação — viewport 560px com o estado
syncedcompleto (link + instruções + edições) garante overflow; assert descrollHeight > clientHeightno corpo marcado pordata-slot. sm:p-0remover padding de que o header dependa: mitigação — header/body/footer ganham padding próprio (px-6 py-4) e o X absoluto (right-3 top-3) é conferido no gate.- Erro de ação sair da vista no desktop: mitigação —
actionErrorvai para o rodapé, ao lado da ação; no mobile fica onde está. - Unit estrutural acoplar a
data-slot: mitigação — os atributos seguem o padrão dedialog-content/drawer-contentjá usado por e2e; sem asserts de classe.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto: 3 modais com largura/padding coerentes; corpo rolável + rodapé visível; Início/Término lado a lado; mobile intocado.
- Invariantes AGENTS/engineering-standards: sem migration, sem access/Consent,
ui/dialog.tsxintacto, contrato mobile preservado, zero warnings, sem dead code (nenhum helper órfão). - Testes de domínio previstos: unit existentes verdes + asserções estruturais novas nos 3 specs; int N/A (nenhuma fronteira Payload/DB muda); e2e afetados rodados antes do push.
Self-score (decision-quality)
| Critério | Nota | Justificativa |
|---|---|---|
| 1. Decisões caras têm rejeitadas? | 5/5 | Onde corrigir (A/B/C), técnica de rolagem, rodapé fixo vs scroll total, larguras e fallback A/B do par — todas com rejeitadas explícitas e gatilho de revisitação. |
| 2. Cabe no appetite? | 5/5 | 3 componentes + testes, sem schema/migration; correção local de classes e composição. |
| 3. Rabbit holes nomeados? | 5/5 | Base global, unificação de breakpoint, shell novo, redesign do form, popover/E2E, sticky. |
| 4. Depth check: reusa shells/helpers? | 5/5 | Reusa o padrão existente (sheet mobile, QuickActions, Advisor); nenhum módulo novo. |
| 5. Intenção permanece satisfeita? | 5/5 | Engenharia não reescreveu o outcome: encaixe, não redesign; mobile preservado por contrato de DOM. |
Total: 5/5 (gate ≥4).