diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc index 20e67454..cb4e82a3 100644 --- a/.agents/rules/codebase-map.mdc +++ b/.agents/rules/codebase-map.mdc @@ -20,8 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`. - `src/utilities//` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`). - `src/components/campaign//` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos). +- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`. Geometria/catálogo puros em `src/lib/card{Models,NameFit,PhotoTransform,Render}.ts`. - `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona). -- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`). +- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`). - `src/migrations/` — história congelada; nunca editar retroativamente. - `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`). diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh index a9d45c67..617acbfb 100644 --- a/.agents/shell/worktree.sh +++ b/.agents/shell/worktree.sh @@ -19,7 +19,7 @@ # Modelo por invocação: `--cheap` (cheapestinference/deepseek-v4-flash), # `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), # `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) no -# mapa fixo `WORKTREE_MODEL_MAP` (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-v4-flash` +# mapa fixo `WORKTREE_MODEL_MAP` (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-flash` # permanece. Sem `exec` de propósito: ao sair do opencode, o terminal volta ao # shell dentro do worktree. Presets são constantes em scripts/lib/worktree.mjs; # o marcador TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md index 93060ed1..5e4f1567 100644 --- a/.agents/skills/worktree-next-issue/SKILL.md +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op **Default-go:** `next`, `plan` e `new` imprimem `cd ` na última linha **por padrão**; `--stay` suprime; modelos por invocação via `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` (mapa fixo `WORKTREE_MODEL_MAP`, at-most-one; sem flag o preset permanece); `--go` remapeado em OPS93 para o provider OpenCode Go e em OPS95 para `opencode-go/hy3` (antes no-op do OPS24). No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd
` no fim (a sessão nunca fica num diretório destruído). -**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model --auto [--prompt "/work-issue --issue "]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue ` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-v4-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.** +**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model --auto [--prompt "/work-issue --issue "]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue ` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.** ## Fluxo quando invocado como skill (agentes que não têm opencode) diff --git a/.opencode/commands/bug-fix.md b/.opencode/commands/bug-fix.md index d7e4fce2..f2a56b1d 100644 --- a/.opencode/commands/bug-fix.md +++ b/.opencode/commands/bug-fix.md @@ -1,6 +1,5 @@ --- description: Corrige um bug de ponta a ponta: diagnóstico → causa-raiz → fix → verificação → prevenção → post-mortem -model: deepseek/deepseek-v4-flash --- Carregue a skill `bug-fix` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/bug-fix/SKILL.md` — não a transcreva nem recrie o fluxo. diff --git a/.opencode/commands/plan-issue.md b/.opencode/commands/plan-issue.md index a0cb74e9..a4bfa1ea 100644 --- a/.opencode/commands/plan-issue.md +++ b/.opencode/commands/plan-issue.md @@ -1,6 +1,5 @@ --- description: Transforma ideias humanas em Issues GitHub + planos de intenção em docs/plans/ (com gate e registro) -model: deepseek/deepseek-v4-flash --- Carregue a skill `plan-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/plan-issue/SKILL.md` — não a transcreva nem recrie o fluxo. diff --git a/.opencode/commands/testing-audit.md b/.opencode/commands/testing-audit.md index 42726c58..a9ffcf05 100644 --- a/.opencode/commands/testing-audit.md +++ b/.opencode/commands/testing-audit.md @@ -1,6 +1,5 @@ --- description: Auditoria autônoma noturna da suíte de testes — inventário e métricas por camada, melhorias seguras aplicadas com prova verde antes/depois, relatório único no PR ready -model: deepseek/deepseek-v4-flash --- Carregue a skill `testing-audit` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta até o fecho noturno completo. A fonte canônica é `.agents/skills/testing-audit/SKILL.md` — não a transcreva nem recrie o fluxo. diff --git a/.opencode/commands/work-issue.md b/.opencode/commands/work-issue.md index ca9724ec..0c619712 100644 --- a/.opencode/commands/work-issue.md +++ b/.opencode/commands/work-issue.md @@ -1,6 +1,5 @@ --- description: Executa uma Issue já claimada de ponta a ponta com supervisão humana (plano de implementação → pausa → execução → simplify → débitos → PR) -model: deepseek/deepseek-v4-flash --- Carregue a skill `work-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/work-issue/SKILL.md` — não a transcreva nem recrie o fluxo. diff --git a/docs/changelog/2026-09-12-b201.md b/docs/changelog/2026-09-12-b201.md new file mode 100644 index 00000000..e93db320 --- /dev/null +++ b/docs/changelog/2026-09-12-b201.md @@ -0,0 +1 @@ +**B201 — Sollinha e título de demanda no nome canônico `deepseek-flash` (DeepSeek V4.1 Flash) (2026-09-12):** o DeepSeek lançou o V4.1 Flash em 2026-09-10 e o nome canônico passou a ser `deepseek-flash`; o legado `deepseek-v4-flash` continua aceito como alias servido pelo próprio V4.1-Flash ao preço Flash, mas o modelo foi aposentado. Os 2 call sites do app passaram ao nome canônico — o chat do Sollinha (`src/app/(campaign)/campanha/api/ai-chat/route.ts:62`) e o gerador automático de título de demanda (`src/utilities/ai/campaignDemandTitle.ts:31`) — com o literal trocado no call site (2 linhas, sem constante compartilhada: DRY < 3 e os módulos são independentes). Comportamento visível idêntico: gate de papel (leader lockdown), tools, prompt, rate limit e sessão do chat intocados; visão do V4.1 continua desligada e o provider segue `api.deepseek.com` (`@ai-sdk/deepseek` 3.0.19, sem bump). Sem migration, sem UI, sem teste novo — nenhuma camada pina o literal (e2e mockam a rota HTTP; unit cobre só o fallback sem `DEEPSEEK_API_KEY`). Verificação: `pnpm gate:fast` verde (lint/format/typecheck/knip/cycles + 284 arquivos/2687 testes unit); CI do PR a preencher; deploy manual pendente. diff --git a/docs/changelog/2026-09-12-c148.md b/docs/changelog/2026-09-12-c148.md new file mode 100644 index 00000000..736ea1ea --- /dev/null +++ b/docs/changelog/2026-09-12-c148.md @@ -0,0 +1 @@ +**C148 — Agenda: modais usáveis no desktop (2026-09-12):** os três modais da agenda (`/campanha/atividades` e `/campanha/agenda`) param de brigar com a janela no desktop. O overlay de criação/edição, o dialog de sync Google e o dialog do feed ICS passam a espelhar as variantes `sm:` da base `ui/dialog.tsx` (`sm:max-w-3xl`/`sm:max-w-xl` + `sm:p-0`) — antes os overrides sem variante perdiam para `sm:max-w-md`/`sm:p-8`, e o modal abria com 28rem de largura e padding dobrado (as "bordas gigantes"). Os três ganham corpo rolável (`min-h-0 flex-1 overflow-y-auto`, `data-slot="dialog-scroll-body"`) com header e rodapé `shrink-0` (`data-slot="dialog-footer"`): o conteúdo nunca corta e as ações ficam sempre visíveis — o sync/feed no desktop ganhou rodapé fixo com as ações (o erro da ação fica junto delas no desktop; no mobile a composição antiga foi preservada). O par Início/Término do overlay deixa de empilhar: data+hora inline na mesma linha e selects de hora/minuto com `w-16` (o grid `sm:grid-cols-2` já existia). `ui/dialog.tsx` e os outros 7 consumidores ficam intocados; o contrato mobile (Drawer/`DrawerFooter`) não muda. Testes: asserções estruturais nos units dos três dialogs (conteúdo no corpo, ações no rodapé) e nos e2e (`campaignActivity` mede largura > 600 e o par na mesma linha; `campaignAgendaGoogleSync` encolhe a janela para 560px e prova scroll interno + rodapé visível). O `E2E_AFFECTED_MANIFEST` passa a acordar `campaignAgendaGoogleSync` e `campaignAgendaFeed` para mudanças em `src/components/campaign/activity`. Sem migration, sem access/Consent. diff --git a/docs/changelog/2026-09-12-deploy-verify-b196-data-vencida.md b/docs/changelog/2026-09-12-deploy-verify-b196-data-vencida.md new file mode 100644 index 00000000..3259cff6 --- /dev/null +++ b/docs/changelog/2026-09-12-deploy-verify-b196-data-vencida.md @@ -0,0 +1 @@ +**Deploy verify destravado — fixture B196 de atividades deriva a data de hoje (2026-09-12):** o verify do deploy manual de 2026-09-11 (run 34652323456, main `3aad110e`) falhou nas 3 tentativas do e2e `campaignMunicipalities.e2e.spec.ts:1492` ("chassis elsewhere … atividades"), com `Expected: > 0` / `Received: 0` em `:1529`, e o job `deploy homeserver (teqo-1313)` ficou skipped — o main não foi para produção nesse dispatch (os outros 198 e2e passaram; 6 flakes de latência passaram no retry). Causa: a fixture B196 (commit `02abd7de`, 2026-08-11) criava 5 atividades com `startAt` fixo em `2026-09-01T13:00:00.000Z`; a aba default `proximos` filtra `status = confirmado AND startAt >= now` (`src/utilities/activityUi.ts:408-409,442-444`; `now = new Date()` injetado em `src/app/(campaign)/campanha/(app)/atividades/page.tsx:57,65`), então a partir de 02/09 a lista virou empty state, o scrollport perdeu overflow e o assert zerou — o deploy de 27/08 (run 33074305879) e o PR CI de agosto passaram porque a data ainda era futura. Fix (PR #943, commit `e846c916`): `startAt` derivado de hoje +7 dias no tempo civil da Bahia (`civilDatePlusDays(formatBahiaCivilDate(new Date()), 7)` + `parseBahiaDateTimeInput`, padrão de `tests/e2e/campaignActivity.e2e.spec.ts:136-137`) e poll do scrollport com `{ timeout: 30_000 }` (classe de latência B196 sob 4 workers), mais guard ESLint `no-restricted-syntax` escopado a `tests/e2e/**/*.{ts,tsx}` que bane ISO literal em propriedades `start*`/`end*`/`date*`, pinado por `tests/unit/eslintConventions.unit.spec.ts` (13 testes, RED sem a regra). Verificação: RED local reproduzido (mesma linha, `Received: 0`) e pelo verificador independente via stash, GREEN 3 passed (2 setups + alvo); `pnpm gate:fast` verde (lint + typecheck + 2687 unit), `pnpm lint` completo verde; spec completo 27 passed / 2 failed (B176, flake conhecido da Issue #882, e B200, determinístico só em dev local, aprovado no verify real em prod-mode). Sem Issue própria — o registro é o post-mortem (`docs/postmortems/2026-09-12-deploy-verify-b196-data-vencida.md`). CI do PR #943 verde (run 34673571340) e prod pendente (deploy manual do humano após o merge). diff --git a/docs/changelog/2026-09-12-ops101.md b/docs/changelog/2026-09-12-ops101.md new file mode 100644 index 00000000..aefb3be5 --- /dev/null +++ b/docs/changelog/2026-09-12-ops101.md @@ -0,0 +1 @@ +**Worktree nasce no nome canônico do DeepSeek V4.1 Flash (`deepseek/deepseek-flash`) (2026-09-12):** o preset sem flag do fluxo `pnpm worktree next/plan/new/fix` ainda apontava para o nome legado `deepseek/deepseek-v4-flash`, e os comandos `/work-issue`, `/plan-issue`, `/bug-fix` e `/testing-audit` pinavam o mesmo nome no frontmatter — o pin sobrescrevia o modelo default da sessão e anulava as flags/override do launch. O fix (Issue #944) troca o fallback em `OPENCODE_PRESET_MODEL` (`scripts/lib/worktree.mjs`) para o nome canônico `deepseek/deepseek-flash` (DeepSeek-V4.1-Flash, lançado em 2026-09-10; o alias legado segue servido ao preço Flash, mas pode sumir sem aviso) e remove a linha `model:` dos 4 commands, que passam a herdar o modelo da sessão; o guard unit de frontmatter (`opencodeCommands.unit.spec.ts`) passa a exigir ausência de pin (reintrodução de qualquer pin falha o teste, com mensagem explicando a origem do modelo). O override `OPENCODE_WORKTREE_MODEL` e as 7 flags do `WORKTREE_MODEL_MAP` ficam intactos, incluindo os aliases de outros providers (`cheapestinference/…`, `alibaba-token-plan/…`) e os usos diretos do app (`ai-chat`, `campaignDemandTitle`) — fora do escopo. Verificação: unit full 2687 passed com e sem `OPENCODE_WORKTREE_MODEL` exportado (RED→GREEN nos dois guards), smoke da diretiva imprimindo `--model deepseek/deepseek-flash --auto --prompt "/work-issue --issue 944"` e override ainda vencendo, `pnpm gate:fast` verde; e2e curado local 18/18 (1,9m — mesmo conjunto do required check do PR), CI do PR e prod a preencher (deploy manual). diff --git a/docs/changelog/2026-09-12-s13-cards-personalizados.md b/docs/changelog/2026-09-12-s13-cards-personalizados.md new file mode 100644 index 00000000..af0ad18e --- /dev/null +++ b/docs/changelog/2026-09-12-s13-cards-personalizados.md @@ -0,0 +1 @@ +**S13 — cards personalizados da campanha no site público (2026-09-12):** o visitante agora cria um card de apoio com a identidade oficial e baixa um PNG sem conta e sem enviar nome/foto — tudo processado no aparelho. A home ganhou a seção `Faça parte / Mostre que você está com Solla` (depois da captura de novidades, antes do rodapé) com os três modelos e o CTA `Criar meu card`; a rota nova `/cards` (grupo `(home)`, herda tema `campaign-site` + MetaPixel) concentra catálogo e edição, com deep-link `?model=` sanitizado server-side (`isCardModelId`; valor inválido cai no catálogo). O compositor é uma ilha única (`CardsStudio`) com um editor (`CardComposer`) em dialog central (desktop) ou bottom drawer (mobile, `useIsMobileMeasured`); o núcleo de composição é puro e unit-testado (`cardModels`/`cardNameFit`/`cardPhotoTransform`/`cardRender`, ctx estrutural injetado): o nome entra na área medida do mestre (centro x≈570, cap 106px, tinta `#ffec01`, shrink/wrap até o mínimo legível e erro acionável em vez de corte silencioso) usando a Brexter 700 (`next/font/local`, `document.fonts.load` antes de medir) e as fotos são desenhadas full-bleed sob o overlay-mestre (mesmos PNGs oficiais, saída 1000×1000 e 1000×1440), com cover na janela transparente e pan/zoom clampados (toque, setas, slider de zoom). Assets originais versionados em `public/cards/`; os três modelos saem do mesmo catálogo em `src/lib/cardModels.ts` (home, catálogo e render). Sem migration, sem collection, sem Consent novo, sem PII em rede/analytics; e2e novo no spec `frontend` cobre home→deep-link, nome→resultado→download PNG real, erro de nome longo fail-closed, foto (decode + erro recuperável) e drawer mobile. Manifest de e2e ganhou os prefixos `src/components/cards`/`src/lib/card` → `frontend` e o prewarm dev inclui `/cards`. Plano: `docs/plans/cards-personalizados-campanha-impl.md`. diff --git a/docs/plans/c148-modais-agenda-desktop-impl.md b/docs/plans/c148-modais-agenda-desktop-impl.md new file mode 100644 index 00000000..079c6562 --- /dev/null +++ b/docs/plans/c148-modais-agenda-desktop-impl.md @@ -0,0 +1,117 @@ +# 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)]` + corpo `min-h-0 flex-1 overflow-y-auto`; sync/feed `max-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.tsx` e seus 10 consumidores intactos; (4) sem migration/Consent (dado interno de staff, `activity` nã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** derruba `sm:max-w-md`/`sm:p-8` da base quando o override não tem `sm:`; 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-full` no overlay anula o gutter de 2rem da base (`w-[calc(100vw-2rem)]`), e `max-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)` em `ActivityCreateOverlayHost.tsx:35`/`ActivityEditOverlayHost.tsx:34` vs sync/feed 768 via `useIsMobile()` em `src/hooks/use-mobile.ts:3`) **não** será unificada nesta entrega; as fronteiras atuais são preservadas. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[ActivityOverlay desktop] --> A1["DialogContent: sm:max-w-3xl sm:p-0
remove w-full e max-w-2xl"] + A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto
(morre max-h-[calc(100dvh-14rem)])"] + A2 --> A3["Início/Término em sm:grid-cols-2
data+hora inline por campo"] + B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]
flex-col p-0 sm:max-w-xl sm:p-0"] + B1 --> B2["header shrink-0 / corpo rolável /
footer shrink-0 com as ações"] + B2 --> B3["mobile: composição atual intacta
(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). Mudar `sm: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 e `max-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 + +1. **Onde a rolagem do overlay desktop acontece.** Opções: A) `form` `min-h-0 flex-1` + corpo `min-h-0 flex-1 overflow-y-auto`, herdando o `max-h` do `DialogContent` (padrão do sheet mobile e do QuickActions) | B) manter `max-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 - 2rem` no content + header/footer `shrink-0`). **Rejeitada:** B porque preserva o acoplamento que gerou o defeito. +2. **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-auto` no `DialogContent` inteiro (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). +3. **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) mostra `07/08/2026` sem 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). +4. **Larguras.** Overlay: `sm:max-w-3xl` (48rem, cena 1) vs `sm:max-w-2xl` (42rem, hipótese da intenção). Sync/feed: `sm:max-w-xl` (36rem, cena 2) vs manter `sm: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. +5. **Como preservar o mobile exatamente.** Opções: A) dividir o `body` em `content` + `actionRow` e recompor o ramo mobile na mesma ordem de hoje | B) footer desktop com `position: sticky` dentro 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 com `space-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` (remove `w-full` para voltar o gutter `w-[calc(100vw-2rem)]` da base; `sm:` espelhado derruba `sm:max-w-md`/`sm:p-8` via twMerge). + - `DialogHeader` desktop (`:602`): + `shrink-0` (mantém `border-b px-6 py-4`). + - `form` (`:442`): ramo desktop `flex flex-col` → `flex min-h-0 flex-1 flex-col` (igual ao sheet). + - corpo (`:451-456`): ramo desktop `max-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-0` já existe); + `data-slot="dialog-footer"`. +- **`GoogleCalendarSyncDialog.tsx`:** + - separar o `body` (`:186-286`) em `content` (sem as ações) + `actions` por estado (`not-configured` → nenhuma; `disabled` → Reativar; `synced`/`paused` → Abrir Google Calendar quando `paused` + Sincronizar agora + Desativar). `actionError` vai 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]` + `
{content}{actionError}{actionRow}
` + `DrawerFooter` Fechar. + - 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 `
{content}
`; rodapé `
{actionError}{actionRow}
` (não renderiza quando ambos vazios — `not-configured`). +- **`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 e `sm:max-w-xl`. +- **`ActivityDateTimeField.tsx`:** uma linha — wrapper dos selects (`:154`) `isNarrow ? 'w-16' : 'w-20'` → `w-16` (o draft cena 1 usa `w-16`; o mobile já era `w-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 (`matchMedia` mockado `matches:false`; overlay `isNarrow=false`). + - e2e — `campaignActivity.e2e.spec.ts` (jornada desktop `:51`): após abrir o modal, `boundingBox` da largura `> 600` (guarda do "estreito"; hoje 448) e `|Δy| < 4` entre 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, assertar `Sincronizar agora` visível + `[data-slot="dialog-scroll-body"]` com `scrollHeight > 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:18` e `:73`) seguem como regressão. +- **Changelog:** `docs/changelog/-c148.md` — uma entrada curta da entrega (nunca editar o agregado; `pnpm changelog:build` gera). +- **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 com `min-h-0 flex-1 overflow-y-auto`) e o par `flex 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 + +1. **Tracer — overlay desktop** (quota ~40%): classes em `ActivityOverlay.tsx` (DialogContent/header/form/corpo) + `data-slot` + a linha do `w-16` em `ActivityDateTimeField.tsx`. Verificação: `pnpm test:unit` do `activityOverlay.unit.spec.tsx` verde + abrir `/campanha/atividades` no 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. +2. **Sync + feed desktop** (quota ~40%): split `content`/`actions` nos 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. +3. **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.tsx` nem os outros 7 consumidores; não criar `DialogFooter` na base. +- Não unificar os breakpoints 640 vs 768 (`useNarrowMeasured(640)` vs `useIsMobile()`). +- 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 `ActivityDateTimeField` além do `w-16` (sem reestruturar a linha data+hora). +- Não guerrear ordem de CSS na mão (`!important`, reordenar classes): usar `sm:` + 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 — compor `content + actionError + actionRow` na mesma ordem de hoje no ramo mobile e manter unit/e2e mobile (C103/C94/C114) verdes; revisão de diff focada no ramo `isMobile`/`isNarrow`. +- **`min-h-0` faltando em algum elo** → corpo não rola e corta: mitigação — cadeia `DialogContent (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 `synced` completo (link + instruções + edições) garante overflow; assert de `scrollHeight > clientHeight` no corpo marcado por `data-slot`. +- **`sm:p-0` remover 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 — `actionError` vai 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 de `dialog-content`/`drawer-content` já 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.tsx` intacto, 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).** diff --git a/docs/plans/cards-personalizados-campanha-impl.md b/docs/plans/cards-personalizados-campanha-impl.md new file mode 100644 index 00000000..2fc1a672 --- /dev/null +++ b/docs/plans/cards-personalizados-campanha-impl.md @@ -0,0 +1,199 @@ +# Impl: Cards personalizados da campanha no site público + +Status: em execução +Atualizado em: 2026-09-12 +Issue: #941 +Intenção: docs/plans/cards-personalizados-campanha.md +Appetite restante: herdado — ~3–4 dias eng (fases 1–4 somam ≤4 dias; sem migration) + +## Leitura da intenção + +- **Outcome:** o visitante (majoritariamente no celular) encontra o convite na home com os três modelos, entra em `/cards`, personaliza com nome ou foto, confere a prévia e baixa um PNG com a identidade da campanha — sem conta e sem que nome/foto saiam do aparelho. +- **O que NÃO negociar:** + - Fluxo 100% client-side: sem upload, sem persistência, sem conta, sem analytics de PII; nome/foto nunca saem do aparelho. + - Nenhuma collection/global/migration/CMS novo; artes-mestre intocadas (compor por cima, nunca redesenhar). + - Dimensões reais de saída: nome `1080×1440`; foto quadrada `1000×1000`; foto vertical `1000×1440`. + - Nome nunca cortado silenciosamente: reduzir/quebrar até o mínimo legível; acima disso, erro acionável. + - `/cards` é a única superfície de edição; o download do PNG é o fim (sem API de rede social). + - Copy literais da intenção preservados. +- **O que reavaliar (hipóteses da intenção):** + - “Arquivos originais já estão acessíveis em `/home/fsolla/Documentos/cards/`” — verdade no workstation, **não** em produção: assets e fonte precisam ser provisionados no repo (o Dockerfile copia `src/` e `public/`). + - “overlays responsivos existentes apenas como referência” — o overlay oficial é o próprio PNG mestre; não recriar faixas/contra-formas em CSS/SVG. + - A base do modelo de nome já traz os textos fixos (`#SOU`, `TÔ COM SOLLA`); o compositor desenha **somente o nome** na banda medida — não re-renderizar a frase. + +## Abordagem recomendada + +```mermaid +flowchart LR + H["Home · CampaignCardsSection"] -->|"Criar meu card"| C["/cards"] + H -->|"/cards?model=id"| C + C -->|"await searchParams"| S["CardsStudio (client)"] + S --> G["CardModelGallery (grid / peek)"] + S --> O{"viewport abaixo de 768px?"} + O -->|"não"| M["Dialog (Radix)"] + O -->|"sim"| W["Drawer (base-ui, bottom)"] + M --> K["CardComposer (editor único)"] + W --> K + K -->|"nome"| NF["cardNameFit"] + K -->|"foto"| PF["cardPhotoTransform"] + NF --> R["cardRender"] + PF --> R + R --> P["prévia no canvas"] + K -->|"Criar meu card"| RES["Resultado"] + RES -->|"Baixar meu card"| DL["PNG no aparelho"] + CM["cardModels — catálogo único"] -.-> G + CM -.-> R +``` + +**Opções consideradas:** A (rota dentro de `(home)` herdando o layout da campanha + ilha única de edição + núcleo puro em `src/lib/card*`) | B (rota com layout próprio espelhando a home, estilo `artigos/`) | C (dois compositores independentes, um modal e um drawer) +**Recomendação:** A — a home e `/cards` são o mesmo funil e a mesma pele; o layout `(home)` já é o dono do `data-theme="campaign-site"`, do container de scroll e do MetaPixel, e o núcleo puro em `lib` mantém o editor testável sem DOM. +**Rejeitadas:** B porque duplica o dono do tema/scroll/pixel e cria divergência de pele no meio do funil (`artigos/`/`privacidade/` são outro domínio, o editorial); C porque dois editores divergem — é exatamente o rabbit hole “dois fluxos de criação” da intenção; o que muda entre desktop e mobile é a casca, não o editor. + +### Decisões de engenharia + +1. **Rota/tema — `/cards` dentro de `(home)`.** + Opções: A) `src/app/(frontend)/(home)/cards/page.tsx` herdando `(home)/layout.tsx` (campaign-site + MetaPixel + container `h-dvh overflow-y-auto`) | B) `src/app/(frontend)/cards/` com layout próprio no molde de `artigos/layout.tsx` | C) `(frontend)/cards/` sem layout novo. + Recomendação: A — zero duplicação do que o layout da home já garante; a URL é `/cards` nos três casos e o segmento estático vence o `[type]` dinâmico, sem conflito. + Rejeitadas: B porque reimplementa tema/scroll/pixel (twin do layout) para uma página do mesmo funil; C porque perde os tokens `--pt-*`/`--campaign-*` e o container de scroll interno. + Consequências aceitas: `/cards` dispara o PageView do pixel S10 (sem PII; é o mesmo funil). Portais de Dialog/Drawer montam no `body`, fora do subtree temado: `DialogContent`/`DrawerContent` re-aplicam `data-theme="campaign-site"` (precedente `PetitionSuccessDialog` com `data-theme="petition"`) e não dependem de `--popover` do tema. + +2. **`?model=` — leitura server-side com guard.** + Opções: A) `await searchParams` na page e `initialModelId` para a ilha client | B) `useSearchParams` no client + `` | C) `window.location.search` em efeito. + Recomendação: A — o primeiro render já conhece o modelo e a casca abre direto (sem flash de catálogo-fechado); precedente no repo (`(campaign)/campanha/redefinir-senha/page.tsx:22-34`); a página vira dinâmica e isso é gratuito (não lê dados). + Rejeitadas: B porque exige Suspense e ainda pode pintar o catálogo antes de abrir; C porque é o flash pós-mount que a intenção quer evitar e não funciona no SSR. + Guard: `isCardModelId` (puro) sanitiza; `string[]` → primeiro item; id desconhecido → ignora e mostra só o catálogo (sem 404, sem redirect). A seleção feita em `/cards` não reescreve a URL (o param é entrada/deep-link; revisitar só se alguém pedir link do catálogo). + +3. **Compositor — uma ilha, um editor, dois shells.** + Opções: A) `CardsStudio` (client) dono de `selectedId`/`open`; `CardComposer` único compartilhado entre `Dialog` (desktop) e `Drawer` (mobile), com o modo nome/foto ramificado pelo `kind` do modelo; lógica pura em `src/lib/card*` | B) compositor modal e compositor drawer independentes | C) um componente por modelo (nome/foto) com estado duplicado. + Recomendação: A — um só estado e um só editor; `useIsMobileMeasured` (`src/hooks/use-mobile.ts:31-34`) evita montar a casca errada no primeiro frame (até medir, renderiza só o catálogo); casca desktop = `ui/dialog` (Radix, precedente `PetitionSuccessDialog`), mobile = `ui/Drawer` (`modal`, `swipeDirection='down'`, `showSwipeHandle`). + Rejeitadas: B porque os editores divergem (o estado de nome/foto/transform vazaria para dois lugares); C porque a diferença nome↔foto é um campo de formulário, não dois fluxos — duplicaria fit/transform/render. + Nota de render: prévia desenha em canvas com tamanho limitado (capped, ex. maior aresta ~720px) e o export desenha na dimensão do modelo; ambos usam o mesmo `cardRender`, então não há divergência de spec. + +4. **Fonte — `next/font/local` com o TTF no repo.** + Opções: A) `localFont` em `src/app/(frontend)/fonts.ts` com o arquivo em `src/app/(frontend)/fonts/Brexter-Bold.ttf` | B) `new FontFace(...)` client-side a partir de `public/cards/brexter-700.ttf` | C) fetch do TTF como ArrayBuffer + `FontFace` sem CSS. + Recomendação: A — convenção do repo (`next/font/google` em `(frontend)/layout.tsx:13-17`), self-host + hash + preload, e o arquivo viaja no build (Dockerfile copia `src/`); o client lê `brexterBold.style.fontFamily` e **espera** `document.fonts.load(...)` antes de medir/desenhar (canvas não espera o CSS). + Rejeitadas: B porque carrega manualmente, sem preload/pipeline e divergindo da convenção; C porque reinventa o pipeline do Next sem ganho. + Restrição estrutural: `src/lib` não pode importar `next` (ESLint `no-restricted-imports`), por isso o módulo da fonte vive em `app/` e só a família (string) chega ao núcleo puro. + Fallback documentado: se o build reclamar do formato TTF, converter para WOFF2 na mesma fase (mesma família/permissão), mantendo o path do módulo. + +5. **Slot do nome — reproduzir o mestre, não centralizar.** + Opções: A) seguir a geometria medida do mestre: centro x≈570, cap height 106px, ink ≤714px, banda y[430..536], tinta `#ffec01`; reduzir/quebrar até `MIN_CAP_HEIGHT` e acima disso erro acionável | B) centralizar no canvas (x=540) e simplificar o fit. + Recomendação: A — o aceite exige “sem aproximação visual”; o centro do ink do mestre é ~570 (30px fora do centro do canvas) e centralizar desencaixa o nome dos textos fixos da base. + Rejeitadas: B porque viola o aceite de fidelidade ao mestre em um ponto mensurável. + Mecânica: `resolveFontSizeForCapHeight` calibra o corpo a partir de `actualBoundingBoxAscent` de um probe caixa-alta (não usar 106 como `font-size`); fit tenta 1 linha reduzindo, depois 2 linhas por quebra de espaço; nunca quebra no meio da palavra nem trunca; normalização `trim` + colapso de espaços + caixa alta `pt-BR` (o mestre é caixa alta; acentos preservados). Se o natural não reproduzir os gaps do mestre para `FULANO`, fallback é desenho por caractere com tracking explícito (puro, testável) — decidido na Fase 1 com a comparação com a referência preenchida. + +6. **e2e — estender `tests/e2e/frontend.e2e.spec.ts`.** + Opções: A) novo `test.describe` no spec `frontend` já coletado + prewarm de `/cards` | B) criar `tests/e2e/cards.e2e.spec.ts` + `testMatch` no `playwright.config.ts` + manifest + pins. + Recomendação: A — menor blast radius: o projeto `frontend` já coleta esse arquivo (`playwright.config.ts:176-181`) e o manifest já mapeia `src/app/(frontend)` → `frontend` (`scripts/lib/e2e-affected-manifest.mjs:62-65`); cobre o que unit não cobre (canvas/`toBlob`/download e o fluxo real de arquivo). Adicionar `/cards` à lista de prewarm do `setup.e2e.spec.ts` (só dev; prod pula o setup). + Rejeitadas: B porque muda config + manifest + pins por nenhum ganho de cobertura, e um spec novo sem `testMatch` nem seria coletado. + Cobertura: home (seção/ordem/3 modelos/hrefs), `/cards` direto (catálogo, composer fechado), `?model=` (composer aberto sem clique), nome → resultado → download real (`waitForEvent('download')`, `.png`, bytes > 0), mobile drawer + foto via `setInputFiles` com buffer em memória, buffer inválido → erro recuperável com `Escolher outra foto`. + Manifest: mapear os prefixos novos `src/components/cards` e `src/lib/card` → `['frontend']`, para diffs futuros dessa superfície não caírem no fallback `campaignHomeActions` (que não cobre o site público). + +7. **Assets — `public/cards/` com bytes originais e nomes ASCII.** + Opções: A) copiar os mestres sem re-encode para `public/cards/name-card-base.jpg`, `photo-square-frame.png`, `photo-portrait-frame.png`; catálogo reusa os mesmos arquivos via `next/image` | B) manter os nomes originais (`template-to-com-solla-site-sem-o-nome.jpg.jpeg`) | C) re-encodar/otimizar os mestres. + Recomendação: A — preserva o aceite “sem aproximação visual”, dá paths previsíveis e reusa o mesmo arquivo no catálogo (assets pequenos: ~430KB/142KB/169KB). A fonte vai separada em `src/app/(frontend)/fonts/` (não em `public`). + Rejeitadas: B porque path frágil (extensão dupla, não-ASCII); C porque re-encodar altera a arte-mestre (fora de escopo e risco de perda). + Tile do modelo de nome: `next/image` da base vazia com o placeholder `SEU NOME` sobreposto em HTML posicionado pelos percentuais do slot (centro 52,78%, banda 29,86%); o canvas desenha nome de verdade apenas no compositor. + +### Componentes / mudanças + +- **`src/app/(frontend)/(home)/cards/page.tsx`** (novo): RSC; `searchParams: Promise<{ model?: string | string[] }>`, primeiro item se array, `isCardModelId`; metadata `Crie seu card de apoio | Jorge Solla`; renderiza ``. +- **`src/app/(frontend)/(home)/CampaignCardsSection.tsx`** (novo): seção server da home no molde de `CampaignNewsletterSection.tsx` — `id="cards"`, `aria-labelledby`, `data-home-section="cards"`, `mx-auto max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16`, eyebrow/título/corpo com `.campaign-section-*`, CTA `--pt-red`; usa `CardModelGallery variant="link"` (tiles levam a `/cards?model=`). +- **`src/app/(frontend)/(home)/page.tsx`** (editar): `` logo após ``, dentro do `
`, antes de `
`/`CampaignFooter`. +- **`src/app/(frontend)/fonts.ts`** + **`src/app/(frontend)/fonts/Brexter-Bold.ttf`** (novos): `export const brexterBold = localFont({ src: './fonts/Brexter-Bold.ttf', weight: '700', style: 'normal', display: 'swap' })`; só a família (string) cruza para os componentes. +- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_CARD_SLOT` (centerX 570 / capTop 430 / capHeight 106 / maxInkWidth 714 / maxBlockBottom 630 para 2 linhas / minCapHeight 48 / ink `#ffec01`), janelas de foto em `CARD_MODELS[i].photoWindow` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), dims de saída nos próprios modelos e paths ASCII. Fonte única do catálogo (home + `/cards` + render). +- **`src/lib/cardNameFit.ts`** (novo, puro): `fitCardName(name, measure, slot)` + `resolveFontSizeForCapHeight`; retorno `{ ok: true; lines; fontSize; lineHeight } | { ok: false; reason: 'too-long' }`; `MIN_CAP_HEIGHT` fill-in (~48px, validado no craft); nunca trunca. +- **`src/lib/cardPhotoTransform.ts`** (novo, puro): cover scale na janela, `clampPhotoTransform`, `photoDrawRect`; zoom 1–4; pan clampado para nunca abrir gap na janela (inclui a contra-forma transparente dentro dos limites da janela). +- **`src/lib/cardRender.ts`** (novo, puro): interfaces estruturais mínimas `CardRenderContext`/`CardTextMetrics` (o `CanvasRenderingContext2D` real as satisfaz); `renderNameCard` (base → nome) e `renderPhotoCard` (foto → máscara, nessa ordem); sem dependência de DOM/Next. +- **`src/components/cards/CardsStudio.tsx`** (novo, client): dono de `selectedId`/`open`; escolhe a casca com `useIsMobileMeasured`; fechar mantém a seleção no catálogo; `Criar outro card` volta ao catálogo. +- **`src/components/cards/CardModelGallery.tsx`** (novo, client): grid de 3 no desktop; scroll-snap com peek do próximo no mobile, sem auto-advance; `variant: 'link' | 'select'`; reusa os mesmos assets do catálogo via `next/image`. +- **`src/components/cards/CardComposer.tsx`** (novo, client): editor único (conteúdo compartilhado entre Dialog e Drawer); passos compose → result; modo pelo `kind` (nome: input + fit + erro inline; foto: file picker + pan/zoom + `Trocar foto`); ações `Cancelar`/`Criar meu card`; resultado `Baixar meu card`/`Criar outro card`; erro de foto com `Escolher outra foto`. +- **`src/components/cards/CardPreviewCanvas.tsx`** (novo, client): carrega assets e espera `document.fonts.load` (só desenha com a fonte real), desenha via `cardRender` em canvas de preview limitado. +- **`src/components/cards/cardCanvas.ts`** (novo, client-safe adapter): `loadCardImage` (`createImageBitmap` + fallback `Image.decode`), `toPngBlob`, `downloadBlob` (Blob → object URL → `` → revoke, precedente `SupporterImportWizard.tsx:50-58`); filename sem PII (`card-jorge-solla-.png`). +- **`src/app/(frontend)/styles.css`** (editar só se o craft criar classe própria, `.campaign-cards-*` sob `[data-theme='campaign-site']`; preferir Tailwind + tokens existentes). +- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixos `src/components/cards` e `src/lib/card` → `['frontend']`. +- **`tests/e2e/setup.e2e.spec.ts`** (editar): `'/cards'` no prewarm. +- **`tests/e2e/frontend.e2e.spec.ts`** (editar): novo describe “Cards personalizados” com os cenários da decisão 6. +- **`tests/unit/cardModels.unit.spec.ts`**, **`tests/unit/cardNameFit.unit.spec.ts`**, **`tests/unit/cardPhotoTransform.unit.spec.ts`**, **`tests/unit/cardRender.unit.spec.ts`** (novos): fake ctx/measure (jsdom não tem canvas); catálogo íntegro, fit (shrink/quebra/mínimo/erro/acentos), clamp do pan/zoom e ordem de desenho. +- **`.agents/rules/codebase-map.mdc`** (editar): registrar `src/components/cards/` na seção “Onde vive o quê”. +- **`docs/changelog/2026-09-12-s13-cards-personalizados.md`** (novo): entrada da entrega. +- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado. +- **Access / Consent:** não se aplica — nenhuma superfície Payload; nenhum dado pessoal trafega; sem chave nova de Consent. +- **UI:** Impeccable C — shape (cenas do rascunho) → craft (geometria do mestre, fonte, estados) → critique (a11y, mobile real, copy) → polish. Shells `ui/dialog` (Radix) e `ui/Drawer` (base-ui) com foco preso/ESC/`DialogTitle`+`DialogDescription`; alvos ≥44px; seleção com `aria-current`/`aria-pressed` + marcador (não só cor); zoom e setas cobrem “não depender de arrastar”; aviso de privacidade antes de escolher a foto. + +### Copy (literais da intenção → superfície) + +| Literal | Onde entra | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `Faça parte` | eyebrow da seção da home e de `/cards` | +| `Mostre que você está com Solla` | h2 da seção da home | +| `Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua gente e fortaleça nossa caminhada.` | corpo da seção da home | +| `Criar meu card` | CTA da home e ação primária do composer (compose) | +| `Crie seu card de apoio` | h1 de `/cards` + `` | +| `Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.` | intro de `/cards` | +| `Card com seu nome` / `Moldura quadrada` / `Moldura vertical` | labels dos modelos (catálogo e composer) | +| `Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.` | aviso de privacidade (página e composer, antes da foto) | +| `Trocar foto` | ação do passo de foto | +| `Cancelar` | ação secundária do composer | +| `Seu card está pronto para compartilhar.` | passo resultado | +| `Baixar meu card` / `Criar outro card` | ações do resultado | + +Copy de apoio vinda do rascunho UI (não é literal da intenção; validar no craft/critique, não reescrever ao implementar): `Seu nome` (label), `Personalize com seu nome` / `Enquadre sua foto` (títulos do composer), `Zoom` / `Ajuste fino`, `Não foi possível usar esta foto` / `Escolha outra imagem e tente novamente.` / `Escolher outra foto` (erro de foto), `Voltar para a campanha` (voltar). Copy nova do plano, sujeita ao gate humano/critique: mensagem inline de nome longo (“Esse nome não cabe no card. Use um nome mais curto, como você é chamado.”) e estado de carregamento do preview. + +### Dados → forma (se aplicável) + +Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “forma” é a geometria do próprio card, definida pelas artes-mestre. Nome/foto são insumos locais da imagem, nunca dados apresentados ou coletados. + +## Fases verificáveis + +1. **Tracer ponta a ponta do modelo de nome** — quota ~1,5 dia. Provisiona assets/fonte; `cardModels`/`cardNameFit`/`cardRender` + units; rota `(home)/cards` + `CardsStudio` mínimo + composer de nome + preview + resultado/download; calibra o slot contra a referência preenchida (`FULANO`) no navegador. + Prova: `pnpm gate:fast` verde; `/cards?model=eu-sou-solla` abre o composer direto; download de um PNG `1080×1440` com `João`/`Conceição` legível e encaixado como no mestre. +2. **Catálogo + seção na home** — quota ~1 dia. `CardModelGallery` (grid desktop / peek mobile) e `CampaignCardsSection` depois da newsletter; variantes link/select; a11y e sem overflow horizontal. + Prova: home na ordem certa, 3 modelos visíveis no desktop, 1 + peek no mobile, tiles com `href` `/cards?model=<id>`; e2e de home verde. +3. **Modelos de foto** — quota ~1 dia. `cardPhotoTransform` + unit; file picker, decode, pan/zoom clampados, overlay por cima, erro recuperável, `Trocar foto`; saídas `1000×1000` e `1000×1440`. + Prova: retrato e paisagem enquadram sem gap; arquivo inválido cai no erro sem travar; unit do clamp verde. +4. **Craft/Critique/Polish + e2e + gates** — quota ~0,5 dia. Impeccable C; e2e novo + prewarm + manifest; changelog; `pnpm gate:fast`; e2e local do describe (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Cards personalizados"`); `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Não criar collection/global/migration/CMS para modelos (o catálogo é código). +- Não criar um segundo compositor na home: o tile da home é link e só `/cards` edita. +- Não recriar faixas/contra-formas das artes em CSS/SVG; overlay é o PNG mestre. +- Não implementar Web Share API, share de rede social, upload, conta, galeria ou histórico. +- Não extrair carrossel genérico para uma terceira variante agora (precedente documentado em `CampaignContentCarousel.tsx:13-18`); gatilho de revisitação: uma 4ª superfície de carrossel. +- Não adicionar detecção de rosto, crop automático, filtros, textos livres, múltiplos nomes ou tipografia configurável. +- Não testar pixels no jsdom nem snapshot visual no CI; fidelidade ao mestre é provada no craft + download real no e2e. +- Não usar OffscreenCanvas/worker sem evidência de jank (prévia limitada + export final resolvem). + +## Riscos e mitigação + +- **Fonte não pronta no draw** (medida com fallback desalinha o fit): só desenhar após `document.fonts.load` da família primária (`check` falha pela fallback gerada do next/font); estado de carregamento no preview. +- **Canvas tainted/cross-origin**: assets same-origin em `public/cards/`; nunca URL externa. +- **Foto HEIC/grande/corrompida**: decode em try/catch com erro recuperável (`Escolher outra foto`); formatos suportados pelo browser seguem funcionando; limite defensivo de tamanho sem bloquear caso comum. +- **Gap no pan/zoom**: clamp puro testado; a foto é desenhada antes do overlay, então nem uma borda vazaria. +- **iOS/Safari**: `toBlob` suportado; testar em iPhone físico no craft e registrar no PR; fallback `toDataURL` no catch. +- **Portais fora do tema**: `data-theme="campaign-site"` no conteúdo portaled e cores fixadas explicitamente (não depender de `--popover`). +- **Peso da home**: `next/image` com `sizes` para os tiles; os assets full só são carregados quando o composer abre (lazy). +- **Cold compile do `/cards` no e2e dev**: entrada no prewarm do `setup.e2e.spec.ts`. +- **Formato TTF no `next/font/local`**: se o build reclamar, converter para WOFF2 na mesma fase (família/permissão inalteradas). + +## Débitos diferidos (triage pós-review, 2026-09-12) + +- **Extrair `PhotoControls`/`NameField` do `CardComposer`** (cheap_polish, score 2) — gatilho: próximo toque funcional no composer (novo passo/controle) ou regressão de interação no bloco de foto. +- **Helper único do idioma download Blob → object URL → `<a download>`** (2 call sites hoje) — gatilho: 3º call site. +- **Cobertura de pan por teclado no e2e e dos re-checks do fit de duas linhas no unit** — gatilho: próximo toque em `cardNameFit`/`CardComposer` ou regressão de teclado/duas linhas em UAT. +- Descartados na triagem: união discriminada do `CardModelGallery` e inline do `CardPreviewCanvas` (polish sem gatilho; invariantes já garantidas pelos call sites atuais). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: seção na home após a newsletter; 3 modelos (grid/carrossel com peek); CTA e tiles levam a `/cards`; composer modal desktop/bottom drawer mobile; nome só na banda do mestre, sem corte silencioso; foto com pan/zoom sobre a máscara; aviso de privacidade; resultado + download `PNG`; erro recuperável; toque e teclado. +- [ ] Invariantes AGENTS/engineering-standards: sem schema/migration; sem Consent novo; nenhum PII em rede/analytics; `src/lib` puro (sem Next/Payload); copy pt-BR / identificadores em inglês; sem `as never`; knip sem órfãos; gate `pnpm gate:fast` + `pnpm push` verdes. +- [ ] Testes de domínio previstos: unit dos módulos puros (fit, transform, render, catálogo) obrigatório; e2e no spec `frontend` com download real; **int não se aplica** — não há Payload/DB/access nem escrita multi-collection neste fluxo (100% client-side + assets estáticos). + +## Self-score decision-quality: 5/5 + +1. **Decisões caras com rejeitadas**: rota/tema, `?model=`, estrutura do compositor, fonte, slot do nome, harness de e2e e assets — todas com Opções/Recomendação/Rejeitadas e consequências. +2. **Cabe no appetite**: 4 fases somam ≤4 dias, sem migration, sem infra nova; o tracer do nome vem primeiro. +3. **Rabbit holes nomeados**: coleção/CMS, segundo compositor, redesenho das artes, share social, carrossel genérico, testes de pixel/worker. +4. **Depth check**: reusa shells (`ui/dialog`, `ui/Drawer`, `useIsMobileMeasured`, `next/font`, padrão de seção da home, prewarm/manifest/specs existentes); não cria abstração especulativa. +5. **Intenção preservada**: aceite de produto intacto; engenharia não reescreveu outcome, copy nem geometria do mestre. diff --git a/docs/plans/sollinha-modelo-canonico-deepseek-flash-impl.md b/docs/plans/sollinha-modelo-canonico-deepseek-flash-impl.md new file mode 100644 index 00000000..e3a5d463 --- /dev/null +++ b/docs/plans/sollinha-modelo-canonico-deepseek-flash-impl.md @@ -0,0 +1,68 @@ +# Impl: Sollinha no nome canônico `deepseek-flash` (DeepSeek V4.1 Flash) + +Status: aprovado (gate humano 2026-09-12) +Atualizado em: 2026-09-12 +Issue: #945 +Intenção: docs/plans/sollinha-modelo-canonico-deepseek-flash.md +Appetite restante: herdado — ~0,5 dia eng (troca de literal + verificação) + +## Leitura da intenção + +- **Outcome:** o chat do Sollinha e o gerador automático de título de demanda passam a chamar o nome canônico `deepSeek('deepseek-flash')`; comportamento visível idêntico e nenhuma capacidade nova exposta. +- **O que NÃO negociar:** gate de papel (leader lockdown), tools, prompt, rate limit e sessão do chat intocados; visão do V4.1 continua desligada; provider segue `api.deepseek.com`; SDK fica em `@ai-sdk/deepseek` 3.0.19; docs históricos congelados (`docs/plans/*`, `docs/changelog/*`, `docs/CHANGELOG-AGENTS-HISTORY.md`) não são editados; default de modelo do worktree (OPS101/#944) fora de escopo. +- **O que reavaliar:** a hipótese "nenhum teste pina o literal" foi verificada (só pins de worktree/opencode em `tests/`; e2e mockam a rota) — se algum teste quebrar por pin da string, é achado novo, não ajuste do plano. "Sem migration" confirmado: nenhum schema Payload tocado. + +## Abordagem recomendada + +```mermaid +flowchart LR + A["ai-chat/route.ts:62<br/>deepSeek('deepseek-v4-flash')"] -->|"troca do literal"| B["deepSeek('deepseek-flash')"] + C["campaignDemandTitle.ts:31<br/>deepSeek('deepseek-v4-flash')"] -->|"troca do literal"| D["deepSeek('deepseek-flash')"] + B --> E["gates: lint + typecheck + unit<br/>(+ e2e curado no CI)"] + D --> E +``` + +**Opções consideradas:** A) trocar o literal direto nos 2 call sites | B) centralizar constante de modelo compartilhada | C) ler o modelo de env. +**Recomendação:** A — porque são 2 call sites de módulos independentes, o literal vive no call site por convenção do repo, o SDK recebe a string direto e a troca é reversível numa linha por arquivo. +**Rejeitadas:** B (abstração para 2 call sites de módulos independentes — DRY < 3; criaria acoplamento artificial entre chat e título, e o repo historicamente mantém o literal no call site; dono é o call site + SDK); C (capacidade nova não pedida — mudaria deploy/env sem ganho algum). + +**Decisões acessórias (baratas — registradas, não deliberadas):** não criar teste novo pinando a string (teste de implementação sem comportamento; gatilho de revisitação: se o modelo virar configurável); não tocar worktree default nem docs históricos. + +### Componentes / mudanças + +- **`src/app/(campaign)/campanha/api/ai-chat/route.ts:62`** — única ocorrência no arquivo: `model: deepSeek('deepseek-v4-flash')` → `deepSeek('deepseek-flash')`. Nada mais no arquivo muda; import `deepSeek` de `@ai-sdk/deepseek` já existe. +- **`src/utilities/ai/campaignDemandTitle.ts:31`** — mesma troca; `deriveDemandTitle` com fallback `null` sem `DEEPSEEK_API_KEY` e timeout de 4s intocados. +- **Migration:** sem migration (nenhum schema Payload tocado). +- **Access / Consent:** nenhum helper novo; gate de papel do chat e Consent intocados — sem fail-closed novo porque não há write path novo. +- **UI:** Impeccable A — N/A sem UI. + +## Fases verificáveis + +1. **Tracer / server** — trocar os 2 literais (único entregável de código). Verificação: `grep -rn "deepSeek('deepseek" src/` retorna exatamente 2 matches, ambos `deepseek-flash`. Quota: ~15 min. +2. **UI** — N/A (Impeccable A). +3. **Gates** — `pnpm gate:fast` (lint + typecheck + unit); push via `pnpm push`. No CI, o e2e curado roda conforme `ci-scope.mjs`; a rota é mockada e não há mudança de contrato — se o diff cair em área de risco sem mapping, o gate falha fechado (`unmapped-risk`) e o curado roda mesmo assim. + +## Rabbit holes / Não escopo (engenharia) + +- Habilitar imagem/visão do V4.1 no chat — feature nova, outro item. +- Mexer em prompt, tools, rate limit ou sessão do Sollinha. +- Bump da SDK `@ai-sdk/deepseek` (fica 3.0.19) ou troca de provider. +- Default de modelo do worktree (OPS101/#944). +- Centralizar constante de modelo ou ler de env — sem terceiro call site, sem necessidade. +- Editar planos/changelogs históricos que citam o literal legado. +- Criar teste de string do modelo — não há comportamento a proteger. + +## Riscos e mitigação + +- **Nome canônico rejeitado pela API/SDK:** a SDK repassa a string direto; mitigação é smoke manual do chat e do título no dev (`pnpm dev`); rollback é reverter o literal (1 linha por arquivo). +- **Comportamento diferente entre V4.1 e o alias legado:** a intenção registra que o legado já é servido pelo V4.1-Flash ao preço Flash — a troca não muda comportamento; preço inalterado. +- **Algum consumidor/teste pina o literal:** verificado — nenhum em `src/` além dos 2 call sites; `tests/` só tem pins de worktree/opencode; e2e mockam a rota. +- **Falso ganho de escopo (visão/prompt/tools):** cortado explicitamente nos rabbit holes e no "Fora de escopo". + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (2 call sites no nome canônico; comportamento visível idêntico; nenhuma capacidade nova) +- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem access/Consent, sem twinning, literais PT/URLs intocados) +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A: sem access/write path; a suíte existente roda em `pnpm gate:fast` e no e2e curado do CI + +Self-score decision-quality: 5/5 — decisão cara (A vs B vs C) com rejeitadas explícitas; cabe no appetite de ~0,5 dia; rabbit holes nomeados; depth check feito (não criar abstração/constante para 2 call sites); outcome da intenção satisfeito sem tocar lockdowns nem docs históricos. diff --git a/docs/plans/sollinha-modelo-canonico-deepseek-flash.md b/docs/plans/sollinha-modelo-canonico-deepseek-flash.md new file mode 100644 index 00000000..1e97bbb6 --- /dev/null +++ b/docs/plans/sollinha-modelo-canonico-deepseek-flash.md @@ -0,0 +1,78 @@ +# Sollinha no nome canônico `deepseek-flash` (DeepSeek V4.1 Flash) + +Status: rascunho +Atualizado em: 2026-09-12 +Issue: #945 +Priority: P2 +Impeccable: A — N/A sem UI +Rascunho UI: N/A — sem UI +Appetite: ~0,5 dia eng — troca de literal + verificação +Responsável: — + +## Intenção + +O DeepSeek lançou o V4.1 Flash em 2026-09-10 e o nome canônico na API passou a ser `deepseek-flash` (model version DeepSeek-V4.1-Flash). Os nomes legados `deepseek-v4-flash` e `deepseek-v4-flash-vision-exp` continuam aceitos, mas os modelos foram aposentados: requisições no nome legado são servidas pelo V4.1-Flash ao preço Flash (fonte oficial: https://api-docs.deepseek.com/quick_start/pricing, verificada em 2026-09-12). O humano quer o Sollinha no modelo novo do DeepSeek. Como o alias já entrega o V4.1-Flash, o ganho aqui é ficar no nome canônico antes que o legado suma — não é mudança de comportamento nem economia. + +## Persona e fluxo + +- **Persona / contexto:** equipe de campanha (coordenação, assessoria, candidato) usando o Sollinha no `/campanha`; em paralelo, o gerador automático de título de demanda. +- **Job principal:** continuar conversando com o Sollinha sem se preocupar com qual nome de modelo está por baixo. +- **Fluxo desejado:** abrir o chat → perguntar → receber a mesma resposta de sempre; internamente o servidor passa a chamar o nome canônico. Nada visível muda. +- **Anti-goals de produto:** não habilitar imagem/visão no chat; não mexer em prompt, tools, rate limit ou sessão; não trocar de provider; não mexer no default do worktree. + +## Objetivo e aceite + +- O chat do Sollinha passa a chamar `deepSeek('deepseek-flash')` (nome canônico) no lugar do literal legado. +- O gerador de título de demanda acompanha a mesma troca. +- Comportamento visível do chat idêntico: gate de papel (leader lockdown), tools, prompt e sessão intocados. +- Nenhuma capacidade nova exposta: visão do V4.1 continua desligada no chat; provider segue `api.deepseek.com`. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — troca de literal de modelo em código server-side, sem superfície para o usuário. +- **Decisões desbloqueadas:** N/A. +- **Forma:** N/A. + +## Dados da decisão (literais) + +- `deepseek-flash` — nome canônico novo (model version `DeepSeek-V4.1-Flash`): 1M contexto, 384K output, thinking mode default, tool calls, JSON, visão (imagem disponível, não habilitada aqui). +- `deepseek-v4-flash` — nome legado ainda aceito; modelo aposentado, requisições servidas pelo V4.1-Flash ao preço Flash. +- Trocar `deepSeek('deepseek-v4-flash')` → `deepSeek('deepseek-flash')` em `src/app/(campaign)/campanha/api/ai-chat/route.ts:62` (chat do Sollinha). +- Trocar `deepSeek('deepseek-v4-flash')` → `deepSeek('deepseek-flash')` em `src/utilities/ai/campaignDemandTitle.ts:31` (título de demanda). +- SDK `@ai-sdk/deepseek` 3.0.19 (package.json) e chave `DEEPSEEK_API_KEY` server-side — nenhum dos dois muda. +- Preço inalterado: tabela Flash (input cache-hit US$0,003/M off-peak e US$0,006/M peak; cache-miss US$0,15/M off-peak e US$0,3/M peak; output US$0,6/M off-peak e US$1,2/M peak; peak = 01:00–04:00 e 06:00–10:00 UTC, seg–sex). + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/app/(campaign)/campanha/api/ai-chat/route.ts` e `src/utilities/ai/campaignDemandTitle.ts`. +- **Precedente a olhar:** nenhum específico — troca de string em call site já existente; SDK e fluxo ficam como estão. +- **Risco de acoplamento:** o chat é 100% server-side e o modelo entra só como string; não tocar em gate de papel, tools, prompt ou sessão. +- **Testes:** nenhum teste pina a string do modelo (e2e mockam a rota HTTP; unit/int cobrem só o fallback sem `DEEPSEEK_API_KEY`) — a troca não deve quebrar a suíte. + +## Dependências + +- Nenhuma. + +## Fora de escopo + +- Habilitar envio/aceitação de imagem (visão do V4.1) — feature nova, outro item. +- Qualquer mudança em prompt, tools, rate limit ou sessão do Sollinha. +- Troca de provider ou de SDK; default de modelo do worktree (OPS101). +- Editar planos históricos congelados de IA. + +## Rabbit holes de produto + +- **"O V4.1 tem visão, então já dá para mandar foto".** Se alguém “só completar”: upload, storage, moderação, UI e custo viram um item inteiro. **Corte neste item:** visão continua desligada; só o literal muda. +- **"Modelo novo pede prompt/tools novos".** Se alguém “só completar”: reescrever system prompt e catálogo de tools sem pedido. **Corte neste item:** prompt e tools intocados. +- **"Já que está mexendo, atualiza a SDK".** Se alguém “só completar”: bump de dependência com risco próprio. **Corte neste item:** SDK fica em 3.0.19. + +## Questões em aberto (produto) + +- Nenhuma — a única pergunta (incluir também o `campaignDemandTitle.ts`?) foi decidida no gate de 2026-09-12: **opção A (sim, os dois call sites)**. + +## Referências + +- GitHub Issue #945 (B201, após `pnpm agent:register`) +- Rascunho UI (gate): N/A +- Fonte oficial do modelo/preço: https://api-docs.deepseek.com/quick_start/pricing (verificada em 2026-09-12) +- `src/app/(campaign)/campanha/api/ai-chat/route.ts:62` e `src/utilities/ai/campaignDemandTitle.ts:31` — call sites vivos do nome legado. diff --git a/docs/plans/worktree-modelo-canonico-deepseek-flash-impl.md b/docs/plans/worktree-modelo-canonico-deepseek-flash-impl.md new file mode 100644 index 00000000..15fc9183 --- /dev/null +++ b/docs/plans/worktree-modelo-canonico-deepseek-flash-impl.md @@ -0,0 +1,136 @@ +# Impl: Modelo padrão do worktree → DeepSeek V4.1 Flash (`deepseek/deepseek-flash`) + +Status: aprovado (gate humano 2026-09-12) +Atualizado em: 2026-09-12 +Issue: #944 +Intenção: docs/plans/worktree-modelo-canonico-deepseek-flash.md +Appetite restante: ~0,5 dia eng (herdado; config-only + textos + pins) + +## Leitura da intenção + +- **Outcome:** o fluxo sem flag (`pnpm worktree next/plan/new/fix` → `launch opencode <dir> --model <preset> --auto [--prompt …]`) nasce em `deepseek/deepseek-flash`; os 4 commands do fluxo deixam de pinar modelo no frontmatter; help/docblocks/comentários citam o nome novo; override e as 7 flags intactos; unit de preset/frontmatter verde com os literais novos. +- **O que NÃO negociar:** + - `OPENCODE_WORKTREE_MODEL` segue como override (precedência env > fallback) e as 7 flags (`--cheap/--pro/--zen/--go/--alibaba/--glm/--free`) + `WORKTREE_MODEL_MAP` intactos — incluindo os aliases legados de outros providers (`cheapestinference/deepseek-v4-flash`, `alibaba-token-plan/deepseek-v4-flash`). + - Nada além do nome do modelo muda: sem `--variant`/variantes/Ctrl+T, sem config global, sem `opencode.json`, sem mudança de lógica no CLI. + - Usos diretos do app (`src/app/(campaign)/campanha/api/ai-chat/route.ts:62`, `src/utilities/ai/campaignDemandTitle.ts:31`) fora — plano irmão Sollinha; docs históricos congelados. + - É future-proofing de nome canônico, **não** economia (o alias legado já é servido pelo V4.1 ao preço Flash). +- **O que reavaliar:** + - Remover o pin `model:` dos 4 commands muda a semântica desejada: o comando passa a **herdar o modelo da sessão** (o `--model` do launch) em vez de forçá-lo por cima — é isso que faz flags/override valerem dentro da sessão. Precisa de guard de teste para não regredir (decisão 1). + - `tests/unit/worktree.unit.spec.ts:46` (`presetInEffect()`) duplica o literal do fallback — sem trocar junto, o unit quebra no CI (env não exportado). Trocar na mesma fase da constante. + - `tests/unit/opencodeCommands.unit.spec.ts:36-38` é o único guard do frontmatter dos 4 commands — tem de mudar na mesma entrega (decisão 1). + - O docblock do preset (`scripts/lib/worktree.mjs:22-35`) cita `.forgejo/worktree.env` como caminho de override por repo — referência stale (nenhum código lê, pós-cutover GitHub). Fica **fora** desta entrega (não é o nome do modelo); registrar como follow-up. + - `scripts/worktree.mjs:877` (help) interpola `${OPENCODE_PRESET_MODEL}` — auto-atualiza; não há literal para editar ali (só o docblock `:30` tem o literal). + +## Estado atual verificado (explorador) + +- **Fonte executável única:** `OPENCODE_PRESET_MODEL` em `scripts/lib/worktree.mjs:36-37` (`process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash'`); consumida em `resolveWorktreeModel` (`:74`) e `opencodeLaunchDirective` (`:214`). `WORKTREE_MODEL_MAP:49` (`cheapestinference/...`) e `:53` (`alibaba-token-plan/...`) ficam intactos. +- **Superfícies com o literal legado do preset (fora de docs):** `scripts/lib/worktree.mjs:37` (constante), `scripts/worktree.mjs:30` (docblock; help `:877` interpola a constante, sem literal), `.agents/shell/worktree.sh:22`, `.agents/skills/worktree-next-issue/SKILL.md:34`, `.opencode/commands/work-issue.md:3` / `plan-issue.md:3` / `bug-fix.md:3` / `testing-audit.md:3`, `tests/unit/worktree.unit.spec.ts:46` (espelho) e `:245` (título), `tests/unit/opencodeCommands.unit.spec.ts:37` (regex). +- **Ausência de pin já é padrão do repo:** `.opencode/commands/issue.md` e `worktree.md` não têm `model:` no frontmatter. +- **Diretiva cobre os 4 propósitos:** `cmdNext` (`scripts/worktree.mjs:619`), `cmdNamespaceBranch` (`:706`, usado por `plan`/`new`/`fix` via `cmdFix:762`) chamam `printLaunchDirective` → `opencodeLaunchDirective`. +- **Blast radius:** `scripts/worktree.mjs` está em `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:36`) → diff nele = unit/int full + e2e curado no PR (OPS86). `scripts/lib/worktree.mjs` não é high-risk. +- **Fora do fluxo (intocados):** usos do app em `ai-chat/route.ts:62` e `campaignDemandTitle.ts:31`; `opencode.json`, README, `.husky`, workflows e pool sem refs; `deepseek-v4-flash-vision-exp` sem ocorrência viva. + +## Abordagem recomendada + +```mermaid +flowchart LR + CLI["pnpm worktree next/plan/new/fix\nsem flag"] --> LIB["scripts/lib/worktree.mjs\nOPENCODE_PRESET_MODEL = env || deepseek/deepseek-flash"] + LIB --> DIR["opencodeLaunchDirective\n--model deepseek/deepseek-flash --auto --prompt"] + DIR --> SH[".agents/shell/worktree.sh\ncd + launch"] + SH --> TUI["TUI opencode\nsessão no nome canônico"] + CMD[".opencode/commands/*.md\nfrontmatter SEM model:"] -->|herda a sessão| TUI + GUARD["opencodeCommands.unit.spec.ts\nausência de pin de modelo"] -.->|protege| CMD + TEST["worktree.unit.spec.ts\npresetInEffect canônico"] -.->|protege| LIB + ENV["OPENCODE_WORKTREE_MODEL"] -.->|override intacto| LIB + FLAGS["7 flags do WORKTREE_MODEL_MAP"] -.->|mapa intacto| LIB +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — trocar o fallback na constante única + remover o pin `model:` dos 4 commands + trocar o guard do frontmatter para proibir qualquer pin + sincronizar os textos vivos e os pins de teste. É o menor diff que cumpre o aceite integral e ataca a causa (o pin que sobrescreve a sessão), sem tocar mapa, override ou config. +**Rejeitadas:** + +- **B — preset canônico + pin trocado para `deepseek/deepseek-flash` nos 4 commands:** cumpre o nome, mas mantém o acoplamento que anula as flags/override dentro da sessão e recria o débito na próxima renomeação; a intenção manda "deixar de pinar". +- **C — preset canônico apenas (commands intocados):** deixaria o frontmatter pinando o legado — falha o aceite (2) e mantém o override do pin sobre a sessão. + +### Decisões de engenharia (caras, com rejeitadas) + +1. **Guard do frontmatter após remover o pin (decisão cara desta entrega)** + - Hoje: `tests/unit/opencodeCommands.unit.spec.ts:36-38` exige `model: deepseek/deepseek-v4-flash` no frontmatter de cada um dos 4 commands. + - Opções: **A)** remover a linha `model:` e trocar a asserção por **ausência de qualquer pin** (`expect(frontmatter![1]).not.toMatch(/^model:/m)`); **B)** remover a linha e apagar a asserção; **C)** manter o pin, só trocando o valor para o canônico. + - **Recomendação: A** — única que preserva a intenção ("deixam de pinar") **e** evita regressão silenciosa: se alguém reintroduzir um pin (legado ou canônico), o teste falha e obriga a decisão explícita. Ausência de pin é o padrão já praticado no repo (`.opencode/commands/issue.md` e `worktree.md` vivem sem `model:`), e o comando herda o modelo da sessão — que o launch define via `--model` (preset/flag/override). Forma: remover a linha dos 4 arquivos (frontmatter fica só com `description`); a asserção continua dentro do `it.each(commands)` e cobre os 4 de uma vez, com mensagem explicando que o modelo vem da sessão. + - **Rejeitadas:** **B** — apagar a asserção deixa o frontmatter sem guard: um pin reintroduzido passa em silêncio e volta a anular flags/override (exatamente a classe de bug desta entrega). **C** — contradiz o aceite (2) e recria o acoplamento: com pin, `/work-issue` numa sessão `--cheap`/`--pro` roda no modelo pinado, não no da sessão; a próxima renomeação repetiria a OPS101. + +2. **Espelho do fallback no teste (`presetInEffect()`)** + - Opções: **A)** trocar o literal nos dois pontos (constante e espelho); **B)** exportar um `DEFAULT_PRESET_MODEL` e importar no teste; **C)** o teste comparar `OPENCODE_PRESET_MODEL` consigo mesmo. + - **Recomendação: A** — o espelho é deliberado (comentário `:41-45`: "o lib resolve exatamente nessa precedência") e é o que detecta deriva; sem env no CI, espelho = fallback e a comparação em `:246` falha se um dos dois ficar para trás. Menor diff, sem superfície nova. + - **Rejeitadas:** **B** — abstração prematura para um literal com 2 usos (engineering-standards: sem abstração especulativa); **C** — tautologia; teste que sempre passa não protege nada. + +3. **Escopo da sincronização de textos** + - Opções: **A)** trocar o literal do preset nas superfícies vivas levantadas (docblock de `scripts/lib/worktree.mjs`, `scripts/worktree.mjs:30`, `.agents/shell/worktree.sh:22`, `SKILL.md:34`, título do teste `:245`); **B)** só a constante; **C)** varrer o repo inteiro por `deepseek-v4-flash`. + - **Recomendação: A** — o aceite pede "textos/help/docblocks refletem o nome novo"; B deixa a doc mentindo; C atinge aliases de outros providers e os usos do app (fora de escopo). + - **Rejeitadas:** **B** — os 3 textos fixos ficariam errados (só o help auto-atualiza); **C** — quebra o anti-goal dos aliases e invade o plano irmão do app. + +### Componentes / mudanças + +- **`OPENCODE_PRESET_MODEL`** (`scripts/lib/worktree.mjs:36-37`): fallback `'deepseek/deepseek-v4-flash'` → `'deepseek/deepseek-flash'`; `process.env.OPENCODE_WORKTREE_MODEL ||` intacto. Docblock `:22-35`: 1 frase registrando o nome canônico (OPS101, V4.1 Flash) — sem tocar no caminho stale `.forgejo/worktree.env` (fora de escopo). `WORKTREE_MODEL_MAP:49/:53` intactos. +- **`scripts/worktree.mjs:30`** (docblock): literal → nome novo. Help `:877` não muda (interpola a constante). Nenhuma mudança de lógica. +- **`.agents/shell/worktree.sh:22`** (comentário): literal → nome novo; aliases `:19`/`:21` intactos. +- **`.agents/skills/worktree-next-issue/SKILL.md:34`**: prosa do preset → nome novo; aliases da mesma linha intactos. +- **`.opencode/commands/work-issue.md:3`, `plan-issue.md:3`, `bug-fix.md:3`, `testing-audit.md:3`**: remover a linha `model: deepseek/deepseek-v4-flash` (frontmatter fica só com `description`). +- **`tests/unit/worktree.unit.spec.ts:46`**: `presetInEffect()` → literal novo; `:245` título do teste → nome novo (cosmético; evita doc mentirosa); `:294`/`:298` aliases intactos. +- **`tests/unit/opencodeCommands.unit.spec.ts:36-38`**: asserção → ausência de pin (decisão 1). +- **Migration:** sem migration — tooling/config-only, nenhuma collection/global. +- **Access / Consent:** N/A — sem coleção, sem PII, sem write path. +- **UI:** Impeccable A — N/A sem UI (verificação = linha de launch + unit). + +### Dados → forma (se aplicável) + +N/A — config-only de tooling; nenhum dado de negócio modelado (pergunta 3 de data-presentation não se aplica). + +## Fases verificáveis + +1. **TDD do preset** — `tests/unit/worktree.unit.spec.ts:46/:245` primeiro (vermelho: constante ainda legada), depois `scripts/lib/worktree.mjs` (constante + docblock); `pnpm test:unit -t worktree` verde, com e sem `OPENCODE_WORKTREE_MODEL` exportado. Quota ~30%. +2. **TDD do frontmatter** — trocar o guard em `opencodeCommands.unit.spec.ts:36-38` para ausência de pin (vermelho com os pins atuais), depois remover a linha `model:` dos 4 `.opencode/commands/*.md`; `pnpm test:unit -t opencodeCommands` verde; `grep -n "^model:" .opencode/commands/*.md` vazio. Quota ~30%. +3. **Textos vivos + gates + entrega** — `scripts/worktree.mjs:30`, `.agents/shell/worktree.sh:22`, `SKILL.md:34`; smoke seguro (sem claim) `node -e "import('./scripts/lib/worktree.mjs').then(m => console.log(m.opencodeLaunchDirective({ dir: '/tmp/wt', purpose: 'next', terminal: true })))"` → `--model deepseek/deepseek-flash`; grep final de `deepseek/deepseek-v4-flash` fora de `docs/` deve sobrar só os 2 usos do app (Sollinha); changelog NOVA entrada `docs/changelog/2026-09-12-ops101.md`; `pnpm gate:fast`; `pnpm push` → PR `Closes #944`. Nota: `scripts/worktree.mjs` é `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:36`) → PR roda unit/int full + e2e curado (OPS86), mesmo com diff só de docblock. Quota ~40%. + +## Rabbit holes / Não escopo (engenharia) + +- NÃO trocar o cardápio/mapa (`WORKTREE_MODEL_MAP`, chaves ou values) nem os aliases de outros providers. +- NÃO mexer em variante/thinking/`--variant`/Ctrl+T/config global/`opencode.json` do repo. +- NÃO "vender economia" — registrar como nome canônico/future-proofing. +- NÃO trocar os usos diretos do app (plano irmão Sollinha: `ai-chat/route.ts:62`, `campaignDemandTitle.ts:31`). +- NÃO corrigir o caminho stale `.forgejo/worktree.env` no docblock do preset (follow-up; não é o nome do modelo). +- NÃO editar docs históricos (`docs/plans/*` antigos, `docs/changelog/*`, `docs/CHANGELOG-AGENTS*`). +- NÃO criar constante/abstração nova para o fallback (2 usos; espelho do teste é o guard). +- NÃO varrer o repo inteiro por `deepseek-v4-flash` (atinge app e aliases). +- NÃO usar `pnpm worktree next` como smoke (claimaria Issue real) — o smoke é a chamada direta da lib. + +## Triage de débitos (simplify, 2026-09-12) + +6 colhidos / 2 já resolvidos na sessão / 2 descartados / 2 deferidos com gatilho / 0 registrados (nenhum score ≥3). + +- **Já resolvido (não reabrir):** gramática do docblock (`scripts/lib/worktree.mjs`, "passou a usar o nome canônico") e fragilidade da regex do guard (`opencodeCommands.unit.spec.ts:39` → `/^\s*["']?model["']?\s*:/m`). +- **Descartado:** espelho `presetInEffect()` inerte com `OPENCODE_WORKTREE_MODEL` exportada (deliberado e documentado em `worktree.unit.spec.ts:41-45`; o CI exercita o literal) e docblock stale `.forgejo/worktree.env` (pré-existente, doc-only, parkeado como follow-up). +- **Adiado com gatilho:** + - Literais do preset em comentários vivos (`scripts/worktree.mjs:30`, `.agents/shell/worktree.sh:22`, `.agents/skills/worktree-next-issue/SKILL.md:34`) sem guard de sincronização — **gatilho:** próxima troca de preset deixar um dos textos divergente. + - Guard de frontmatter cobre os 4 commands do array (`opencodeCommands.unit.spec.ts:13`), não os 6 arquivos de `.opencode/commands/` — **gatilho:** novo command de execução (ou pin em command fora do array). + +## Riscos e mitigação + +- **Sessões manuais que invocam `/work-issue` fora do launch passam a herdar o modelo da sessão** (antes o pin forçava o legado) → é o comportamento desejado (o launch/flags/override mandam); registrar como mudança intencional no PR. +- **HIGH_RISK_EXACT (`scripts/worktree.mjs`)** → PR roda unit/int full + e2e curado; mitigação: tocar só docblock (`:30`), sem claim/provision/PORT/env. +- **Deriva entre constante e `presetInEffect()`** → CI sem env quebra; mitigação: trocar os dois na mesma fase (Fase 1). +- **Pin reintroduzido por engano** → guard de ausência (Fase 2) falha com mensagem explicando a origem do modelo. +- **Varredura ampla apagar aliases do mapa** → mitigação: troca só do literal `deepseek/deepseek-v4-flash` (com namespace), nunca dos aliases; pins `:294/:298` cobrem o mapa. +- **Usos do app trocados por engano** → mitigação: grep final mostra os 2 `deepSeek('deepseek-v4-flash')` intocados. +- **Override `OPENCODE_WORKTREE_MODEL`** → intacto (só o fallback muda); teste cobre a precedência env > fallback. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: preset sem flag = `deepseek/deepseek-flash`; 4 commands sem pin; help/docblocks/comentários com o nome novo; override + 7 flags intactos; unit verde com literais novos. +- [ ] Invariantes AGENTS/engineering-standards: sem collection/global/migration; sem twin (constante única editada no dono); Consent/LGPD intocados; `push:false` preservado; `pnpm gate:fast` verde; identificadores em inglês. +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: N/A (sem access/write path) — unit de preset (`worktree.unit.spec.ts`) e de frontmatter (`opencodeCommands.unit.spec.ts`) atualizados e verdes; smoke da diretiva registrado no PR. + +--- + +Self-score decision-quality: 5/5 — decisão cara (guard do frontmatter) deliberada com rejeitadas e forma exata; cabe no appetite (constante + 4 remoções de linha + 3 textos + 2 specs); rabbit holes nomeados com gatilhos; depth check reusa o dono (`OPENCODE_PRESET_MODEL`) e os guards existentes sem criar twin; outcome da intenção preservado (incluindo o não-ganho de economia). diff --git a/docs/plans/worktree-modelo-canonico-deepseek-flash.md b/docs/plans/worktree-modelo-canonico-deepseek-flash.md new file mode 100644 index 00000000..d8580d66 --- /dev/null +++ b/docs/plans/worktree-modelo-canonico-deepseek-flash.md @@ -0,0 +1,85 @@ +# Modelo padrão do worktree → DeepSeek V4.1 Flash (`deepseek/deepseek-flash`) + +Status: rascunho +Atualizado em: 2026-09-12 +Issue: #944 +Priority: P2 +Impeccable: A — N/A sem UI +Rascunho UI: N/A — sem UI +Appetite: ~0,5 dia eng — config-only + textos + pins +Responsável: — + +## Intenção + +O DeepSeek lançou o V4.1 Flash em 2026-09-10 e o nome canônico do modelo na API passou a ser `deepseek-flash` (DeepSeek-V4.1-Flash). Os nomes legados `deepseek-v4-flash` e `deepseek-v4-flash-vision-exp` seguem aceitos, mas os modelos correspondentes foram aposentados: as requisições já são servidas pelo V4.1-Flash ao preço Flash (fonte oficial: https://api-docs.deepseek.com/quick_start/pricing, verificada em 2026-09-12). Hoje o fluxo `pnpm worktree next/plan/new/fix` sem flag abre o TUI com `--model deepseek/deepseek-v4-flash`, e os comandos `/work-issue`, `/plan-issue`, `/bug-fix`, `/testing-audit` pinam o nome legado no frontmatter — que sobrescreve o modelo default da sessão (docs oficiais do opencode). O humano quer o fluxo de worktree no nome canônico novo por padrão. + +Isto é **future-proofing de nome canônico**, não economia: o alias legado já é cobrado ao preço Flash. O nome legado pode sumir sem aviso. + +## Persona e fluxo + +- **Persona / contexto:** o próprio mantenedor/agente operando o fluxo de worktrees no terminal; sem tela, sem interface. +- **Job principal:** disparar o worktree sem flag e a sessão opencode nascer já no modelo canônico `deepseek/deepseek-flash`, sem depender de flag ou config global. +- **Fluxo desejado:** o humano roda `pnpm worktree next` (ou `plan`/`new`/`fix`); o TUI abre com `/work-issue` (ou o comando do propósito) já no modelo novo; quem quiser outro modelo continua usando as flags existentes ou o override `OPENCODE_WORKTREE_MODEL`. +- **Anti-goals de produto:** não é "mais barato"; não é redesenhar o menu de flags; não é mudar comportamento dos comandos além do nome do modelo; não é mexer na config global da máquina. + +## Objetivo e aceite + +- O preset do fluxo de worktree (sem flag) passa a apontar para `deepseek/deepseek-flash`. +- Os quatro comandos do fluxo deixam de pinar o nome legado no frontmatter. +- Textos, help e docblocks que citam o preset refletem o nome novo; nada além do nome do modelo muda. +- O override `OPENCODE_WORKTREE_MODEL` e as 7 flags (`--cheap/--pro/--zen/--go/--alibaba/--glm/--free`) seguem intactos. +- A suíte unitária que pina o preset e o frontmatter passa com os literais novos. + +## Dados (intenção) + +- **Vou apresentar dados?** Não. +- **Decisões desbloqueadas:** N/A — decisão única de configuração, sem ator/consumidor de dado. +- **Forma:** N/A — item config-only, sem superfície de dados. + +## Dados da decisão (literais) + +- Modelo canônico novo: `deepseek/deepseek-flash` (DeepSeek-V4.1-Flash). +- Modelo legado hoje no preset: `deepseek/deepseek-v4-flash`; variante legada também aposentada: `deepseek-v4-flash-vision-exp`. +- Frontmatter hoje nos 4 comandos: `model: deepseek/deepseek-v4-flash`. +- Fallback do preset: `process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash'`. +- Regex que pina o frontmatter: `/^model: deepseek\/deepseek-v4-flash$/m`. +- Capacidades do canônico: 1M contexto, 384K output, thinking mode default, tool calls, JSON, visão (imagem). +- Preço Flash (referência, **não** ganho): cache-hit $0.003/M off-peak e $0.006/M peak; cache-miss $0.15/M off-peak e $0.3/M peak; output $0.6/M off-peak e $1.2/M peak (peak = 01:00–04:00 e 06:00–10:00 UTC, seg–sex). +- `opencode models` local lista `deepseek/deepseek-flash` (2026-09-12); models.dev descreve com reasoning options low/high/max. +- Superfícies com o literal (levantadas em 2026-09-12): `scripts/lib/worktree.mjs:36-37` (fonte única) e docblocks :43/:64/:74/:214; `scripts/worktree.mjs:29-30` e help :877; `.agents/shell/worktree.sh:22`; `.agents/skills/worktree-next-issue/SKILL.md:34`; `.opencode/commands/work-issue.md:3`, `plan-issue.md:3`, `bug-fix.md:3`, `testing-audit.md:3`; `tests/unit/worktree.unit.spec.ts:46` e :245; `tests/unit/opencodeCommands.unit.spec.ts:37`. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `scripts/lib/worktree.mjs` (constante do preset e docblocks), os 4 `.opencode/commands/*.md` (frontmatter), textos de `scripts/worktree.mjs`, `.agents/shell/worktree.sh`, `.agents/skills/worktree-next-issue/SKILL.md` e os dois specs unitários. +- **Precedente a olhar:** OPS93 (menu de flags), OPS95 (removeu `--variant`), OPS100 (`--glm`/`--free`) — mesmo fluxo, mesmas superfícies. +- **Risco de acoplamento:** o preset é a única fonte do fallback; flags, override e aliases legados de outros providers (`cheapestinference/deepseek-v4-flash`, `alibaba-token-plan/deepseek-v4-flash`) ficam intocados. + +## Dependências + +- Nenhuma. + +## Fora de escopo + +- Menu de flags (`WORKTREE_MODEL_MAP`), o override `OPENCODE_WORKTREE_MODEL` e aliases legados de outros providers. +- Config global da máquina, `opencode.json` do repo, variantes/Ctrl+T, `--variant` na diretiva (OPS95). +- Sollinha (B201) e pool/Cursor. +- Docs históricos/congelados (`docs/plans/*`, `docs/changelog/*`, HISTORY). + +## Rabbit holes de produto + +- **Trocar o "cardápio" inteiro de modelos.** Se alguém "só completar": revisar preço/capacidade de todas as flags e reescrever o mapa. **Corte neste item:** só o preset sem flag + o pin dos 4 comandos. +- **Adicionar variante/thinking config ao preset.** Se alguém "só completar": emitir `--variant` (proibido pelo OPS95) ou mexer no config global. **Corte neste item:** sem flag nova, sem variante. +- **Vender economia.** Se alguém "só completar": justificar a troca como redução de custo — falso, o alias legado já é servido pelo V4.1-Flash ao preço Flash. **Corte neste item:** registrar como nome canônico/future-proofing. + +## Questões em aberto (produto) + +- Nenhuma — a única pergunta (incluir o frontmatter dos 4 comandos + o pin do `opencodeCommands.unit.spec.ts`?) foi decidida no gate de 2026-09-12: **opção A (sim, todos)**. + +## Referências + +- GitHub Issue #944 (OPS101, após `pnpm agent:register`) +- Rascunho UI (gate): N/A +- `scripts/lib/worktree.mjs` — preset e mapa de flags (fonte única do fallback) +- `tests/unit/worktree.unit.spec.ts` e `tests/unit/opencodeCommands.unit.spec.ts` — pins que o executor atualiza +- `docs/AGENT-OPS.md` — operação do paradigma de agentes paralelos +- https://api-docs.deepseek.com/quick_start/pricing — nomes canônicos e preço (verificada em 2026-09-12) diff --git a/docs/postmortems/2026-09-12-deploy-verify-b196-data-vencida.md b/docs/postmortems/2026-09-12-deploy-verify-b196-data-vencida.md new file mode 100644 index 00000000..556fbd4b --- /dev/null +++ b/docs/postmortems/2026-09-12-deploy-verify-b196-data-vencida.md @@ -0,0 +1,77 @@ +# Post-mortem: fixture B196 com data fixa vencida bloqueia o verify do deploy + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ------------------------------------------------------------------------------------ | +| Data do post-mortem | 2026-09-12 | +| Severidade | alta (bloqueou o deploy; nenhum dado/PII afetado; produção seguiu no build anterior) | +| Ambiente | CI | +| Issue(s) | sem Issue (fluxo `/bug-fix`; relacionada: #882, flake B176 — não é este bug) | +| PR do fix | #943 | +| Detectado por | teste (E2E do job `verify` do `deploy.yml`) | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Início provável | 2026-08-11 | commit `02abd7de` (B196) cria a fixture com `startAt: '2026-09-01T13:00:00.000Z'` hardcoded; o PR CI de agosto era verde porque 01/09 ainda era futuro — a data venceu em 02/09/2026 | +| Detecção | 2026-09-11 22:04–22:24 UTC | run 34652323456 do `deploy.yml` (job `verify`, main `3aad110e`): o E2E `tests/e2e/campaignMunicipalities.e2e.spec.ts:1492` falha nas 3 tentativas (11.8s / 19.4s / 18.4s) em `:1529` (`Expected: > 0`, `Received: 0`) e o job `deploy homeserver (teqo-1313)` fica skipped — o main não vai para produção nesse dispatch | +| Correção mergeada | 2026-09-12 | commit `e846c916` no PR #943 — CI verde (run 34673571340); merge pelo auto-merge nativo após este documento | +| Deploy | pendente | dispatch manual do `deploy.yml` pelo humano após o merge | +| Verificado em prod | pendente | confirmação do humano após o deploy | + +## O bug + +O deploy manual de 2026-09-11 não chegou a produção: o job `verify` do `deploy.yml` falhou no E2E e o job `deploy homeserver (teqo-1313)` foi skipped — o código de main (`3aad110e`) não foi para produção nesse dispatch. A falha foi o teste `[campaign] tests/e2e/campaignMunicipalities.e2e.spec.ts:1492` "chassis elsewhere: the actions row keeps its breathing room and the bar glues on scroll (atividades)", que falhou nas 3 tentativas (11.8s / 19.4s / 18.4s) em `:1529` com `expect(received).toBeGreaterThan(expected)`, `Expected: > 0`, `Received: 0` ("Timeout 10000ms exceeded while waiting on the predicate"). No cenário do teste, `/campanha/atividades` renderizava "Nenhuma atividade encontrada", o scrollport não tinha overflow e a asserção nunca passava. Dos demais testes E2E, 198 passaram e 6 ficaram flaky (campaignActivity:131, campaignLeaderships:59, campaignMunicipalities:650, campaignPeople:27, campaignPeople:53, campaignSollinhaContext:115), todos aprovados no retry — classe de latência sob 4 workers, sem relação com este bug. **Sintoma — não a causa.** + +## Causa-raiz + +A fixture do teste B196 (commit `02abd7de`, 2026-08-11) criava 5 atividades com `startAt: '2026-09-01T13:00:00.000Z'` hardcoded. A aba default `proximos` filtra `status = confirmado` **e** `startAt >= now` (`src/utilities/activityUi.ts:408-409` e o `where` em `:442-444`), com `now = new Date()` injetado pela página (`src/app/(campaign)/campanha/(app)/atividades/page.tsx:57,65`). A partir de 02/09/2026 os fixtures ficaram no passado → `result.docs = []` → empty state → scrollport sem overflow → a asserção `scrollHeight - clientHeight > 0` recebia 0. O deploy de 27/08 (run 33074305879) passou porque 01/09 ainda era futuro, e o PR CI de agosto também era verde no instante da autoria. + +5-whys: + +1. **Por que o teste falhou?** A lista de atividades veio vazia e o scrollport não rolava — a data fixa da fixture venceu. +2. **Por que a data venceu?** A fixture tratava o relógio como dado estático: `2026-09-01` hardcoded, sem derivar de `now`. +3. **Por que o verde do PR não pegou?** O resultado refletia o instante da execução (agosto), não uma invariante temporal — o teste só passava enquanto a data estivesse no futuro. +4. **Por que não havia proteção?** Não existia helper/convenção de "start futuro" no fixture layer E2E nem gate de lint contra ISO literal em fixtures. +5. **Por que a classe inteira não estava coberta?** Faltava um contrato de fixture invariante no tempo — o padrão relativo a `now` já existia em specs de agenda (`tests/e2e/campaignActivity.e2e.spec.ts:136-137`), mas não era exigido. + +**Evidência:** RED reproduzido localmente em 2026-09-12 (mesma linha `:1529`, `Received: 0`) e também pelo verificador independente via stash; GREEN com o fix (3 passed = 2 setups + alvo). + +## Correção + +- Commit `e846c916` (PR #943), em `tests/e2e/campaignMunicipalities.e2e.spec.ts`: o `startAt` da fixture B196 passa a derivar de hoje +7 dias no tempo civil da Bahia (`civilDatePlusDays(formatBahiaCivilDate(new Date()), 7)` + `parseBahiaDateTimeInput`, mesmo padrão de `tests/e2e/campaignActivity.e2e.spec.ts:136-137`). +- O poll do scrollport ganha `{ timeout: 30_000 }` — orçamento da classe de latência B196 já documentada no arquivo sob 4 workers. +- Prevenção barata: regra `no-restricted-syntax` em `eslint.config.mjs` (escopo `tests/e2e/**/*.{ts,tsx}`, selector em propriedades `start*`/`end*`/`date*` com literal `/20\d\d-\d\d-\d\dT/`) e teste em `tests/unit/eslintConventions.unit.spec.ts` (RED sem a regra / GREEN com; 13 testes). + +Resolve a causa: a fixture deixa de depender de uma data absoluta e o guard pega a classe inteira na autoria. Sem migration, sem mudança de access. + +## Verificação + +- Teste de regressão: `tests/e2e/campaignMunicipalities.e2e.spec.ts:1492` "chassis elsewhere… (atividades)" — falha sem o fix (RED local em 2026-09-12, mesma linha `:1529`, `Received: 0`; reproduzido também pelo verificador independente via stash) e passa com o fix (GREEN 3 passed = 2 setups + alvo) +- Suíte: `pnpm gate:fast` verde (lint + typecheck + 2687 unit); `pnpm lint` completo verde (zero falso positivo da regra nova); spec completo `campaignMunicipalities.e2e.spec.ts` 27 passed / 2 failed — B176 (flake conhecido, Issue #882) e B200 (falha determinística só em dev local, `87 > 48`, reproduzida também no estado pré-fix e aprovada no verify real de 2026-09-11 em prod-mode) +- CI: verde no PR #943 (run 34673571340, job `checks`) +- Prod: pendente — deploy manual pendente de dispatch do humano após o merge; confirmação do humano pendente + +## Prevenção + +| Estratégia | Custo | Estado | +| ------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | +| Fixture B196 deriva `startAt` de hoje +7 dias no tempo civil da Bahia + poll do scrollport com 30s | barata | implementada agora (commit `e846c916`, PR #943) | +| Regra ESLint `no-restricted-syntax` banindo ISO literal em propriedades `start*`/`end*`/`date*` de `tests/e2e` + teste unit RED/GREEN | barata | implementada agora (commit `e846c916`, PR #943) | +| Injeção de relógio no fixture layer E2E (Playwright clock ou `now` do server) para fixtures determinísticas no tempo | cara | documentada — não implementada neste fluxo | +| Canário periódico da aba default `proximos` | cara | documentada — não implementada neste fluxo | +| Regra ampla "zero ISO literal em `tests/e2e`" com allowlist por arquivo | cara | documentada — não implementada neste fluxo; candidata a Issue futura | + +**Estratégia implementada:** fixture deriva o `startAt` de `now` (fim da data absoluta), poll do scrollport com o orçamento de 30s da classe de latência B196 e guard ESLint escopado a `tests/e2e` que bane ISO literal em campos de data, pinado por teste unit RED/GREEN — a classe inteira passa a ser barrada na autoria, não no calendário. + +**Estratégia documentada (cara):** injeção de relógio no fixture layer E2E (Playwright clock ou `now` do server) para fixtures determinísticas no tempo; canário periódico da aba default `proximos`; regra ampla "zero ISO literal em `tests/e2e`" com allowlist por arquivo. Candidatas a Issue futura — não implementadas neste fluxo. + +## Lições + +- **"Verde no instante" ≠ invariante no tempo:** o PR de agosto e o deploy de 27/08 passaram; ambos só provavam que a data ainda era futura naquele dia. Teste com data absoluta tem prazo de validade embutido. +- **Fixture relativa ao relógio precisa derivar de `now`:** o padrão já existia nos specs de agenda — a fixture B196 ficou para trás. O conserto não foi "atualizar a data", foi remover a data absoluta. +- **Guard de autoria barato pega a classe inteira:** a regra ESLint escopada a `tests/e2e` bane ISO literal em campos de data no momento de escrever o teste, sem esperar o calendário virar. diff --git a/eslint.config.mjs b/eslint.config.mjs index d97eff4b..280d6b12 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,22 @@ const noHardcodedConsentId = { 'Never hardcode a Consent document id — resolve it by stable key via requireConsentByKey (keys in src/lib/campaignConsentKeys.ts).', } +// E2E fixtures must derive dates from "today": a hardcoded ISO instant +// silently expires once it stops matching a time-windowed default view. The +// B196 activities fixture (`startAt: '2026-09-01...'`) emptied the default +// `proximos` tab and blocked the 2026-09-11 deploy verify. Derive with +// `civilDatePlusDays(formatBahiaCivilDate(new Date()), days)` + +// `parseBahiaDateTimeInput` (tests/e2e/helpers/agendaPeriodLabels.ts, +// src/lib/campaignTime.ts). Deliberately narrow (start*/end*/date* keys): +// state timestamps like `disabledAt`/`lastSyncedAt` are the scenario under +// test, not a time window. +const noHardcodedE2eFixtureDate = { + selector: + "Property[key.name=/^(start|end|date)/][value.type='Literal'][value.raw=/20[0-9]{2}-[0-9]{2}-[0-9]{2}T/]", + message: + 'Never hardcode a fixture date in e2e specs — derive it from today (civilDatePlusDays + parseBahiaDateTimeInput); a literal expires and empties time-windowed lists (B196 deploy verify, 2026-09-11).', +} + const eslintConfig = [ ...compat.extends('next/core-web-vitals', 'next/typescript'), { @@ -212,6 +228,15 @@ const eslintConfig = [ '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], }, }, + { + // E2E fixture dates must derive from "today" (B196 deploy verify, + // 2026-09-11): a hardcoded ISO instant expires and empties the + // time-windowed default view. Keeps the global as-never ban too. + files: ['tests/e2e/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': ['error', noAsNeverCast, noHardcodedE2eFixtureDate], + }, + }, prettier, // Override default ignores of eslint-config-next. globalIgnores([ diff --git a/public/cards/name-card-base.jpg b/public/cards/name-card-base.jpg new file mode 100644 index 00000000..dc2967d6 Binary files /dev/null and b/public/cards/name-card-base.jpg differ diff --git a/public/cards/photo-portrait-frame.png b/public/cards/photo-portrait-frame.png new file mode 100644 index 00000000..eaf97aec Binary files /dev/null and b/public/cards/photo-portrait-frame.png differ diff --git a/public/cards/photo-square-frame.png b/public/cards/photo-square-frame.png new file mode 100644 index 00000000..ad5ecc22 Binary files /dev/null and b/public/cards/photo-square-frame.png differ diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 8d19ee55..b34b2007 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -63,6 +63,13 @@ export const E2E_AFFECTED_MANIFEST = [ prefixes: ['src/app/(frontend)'], specs: ['frontend'], }, + { + // S13 — the personalized-cards studio lives in shared cards components and + // pure card* modules; a future diff there must wake the frontend spec (the + // home section is already covered by the src/app/(frontend) prefix). + prefixes: ['src/components/cards', 'src/lib/card'], + specs: ['frontend'], + }, { prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'], specs: ['campaignAuth'], @@ -99,7 +106,10 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/utilities/activityOmnibox', 'src/lib/activityQuickActions', ], - specs: ['campaignActivity'], + // C148 — the activity components host the agenda's three modals: the + // create/edit overlay (campaignActivity), the Google sync dialog and the + // iCal feed dialog. A change there must wake all three surfaces. + specs: ['campaignActivity', 'campaignAgendaGoogleSync', 'campaignAgendaFeed'], }, { // C149 — the Google Calendar OAuth connection surface. The engine/client diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 34e74c7d..aae602b2 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -31,10 +31,13 @@ export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL' * `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` o mapa `WORKTREE_MODEL_MAP` * escolhe o modelo por invocação; sem flag o preset permanece. OPS95: sem * `--variant` na diretiva (o TUI rejeita o flag) — variantes ficam na config - * global da máquina, via Ctrl+T. + * global da máquina, via Ctrl+T. OPS101: o fallback passou a usar o nome + * canônico `deepseek/deepseek-flash` (DeepSeek V4.1 Flash, lançado em + * 2026-09-10; o alias legado segue servido ao preço Flash, mas pode sumir + * sem aviso). */ export const OPENCODE_PRESET_MODEL = - process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash' + process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-flash' /** * Per-invocation model map — fixed menu of named flags (OPS93; values diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index f6696b46..a419a203 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -27,7 +27,7 @@ * hy3, alibaba=alibaba-token-plan/deepseek-v4-flash, * glm=opencode-go/glm-5.3-flash, free=openrouter/ * openrouter/free; OPS95 + OPS100) — sem flag o preset - * `deepseek/deepseek-v4-flash` permanece. + * `deepseek/deepseek-flash` permanece. * Chamado do terminal interativo (com * `TEQO_WORKTREE_TERMINAL=1`, que só a função shell * seta), imprime também a diretiva `launch diff --git a/src/app/(campaign)/campanha/api/ai-chat/route.ts b/src/app/(campaign)/campanha/api/ai-chat/route.ts index 9f83e4bf..65e14007 100644 --- a/src/app/(campaign)/campanha/api/ai-chat/route.ts +++ b/src/app/(campaign)/campanha/api/ai-chat/route.ts @@ -59,7 +59,7 @@ export async function POST(req: Request) { }) const result = streamText({ - model: deepSeek('deepseek-v4-flash'), + model: deepSeek('deepseek-flash'), system: AI_SYSTEM_PROMPT, messages: strippedMessages, tools: buildAITools({ user, payload }), diff --git a/src/app/(frontend)/(home)/CampaignCardsSection.tsx b/src/app/(frontend)/(home)/CampaignCardsSection.tsx new file mode 100644 index 00000000..5f69ac3f --- /dev/null +++ b/src/app/(frontend)/(home)/CampaignCardsSection.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link' + +import { CardModelGallery } from '@/components/cards/CardModelGallery' + +/** + * S13 — the home invitation to the card funnel: short copy + the three model + * tiles, positioned after the newsletter capture (which stays the first + * conversion of the page end) and before the footer. Each tile deep-links into + * `/cards` with its model selected; the general CTA opens the plain catalog. + */ +export const CampaignCardsSection = () => ( + <section + id="cards" + aria-labelledby="cards-title" + data-home-section="cards" + className="border-b border-(--campaign-line) bg-(--campaign-cream)" + > + <div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16"> + <div className="mx-auto max-w-2xl text-center"> + <p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"> + Faça parte + </p> + <h2 + id="cards-title" + className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance" + > + Mostre que você está com Solla + </h2> + <p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)"> + Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua + gente e fortaleça nossa caminhada. + </p> + </div> + + <CardModelGallery variant="link" ariaLabel="Modelos de card" /> + + <div className="mt-8 text-center"> + <Link + href="/cards" + className="inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-(--pt-red) px-6 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none sm:w-auto" + > + Criar meu card + </Link> + </div> + </div> + </section> +) diff --git a/src/app/(frontend)/(home)/cards/page.tsx b/src/app/(frontend)/(home)/cards/page.tsx new file mode 100644 index 00000000..c0e16153 --- /dev/null +++ b/src/app/(frontend)/(home)/cards/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +import { brexterBold } from '@/app/(frontend)/fonts' +import { CampaignFooter } from '@/components/CampaignFooter' +import { CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy' +import { CardsStudio } from '@/components/cards/CardsStudio' +import { isCardModelId } from '@/lib/cardModels' + +const intro = + 'Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.' + +export const metadata: Metadata = { + title: 'Crie seu card de apoio | Jorge Solla', + description: intro, +} + +type CardsPageProps = { + searchParams: Promise<{ model?: string | string[] }> +} + +export default async function CardsPage({ searchParams }: CardsPageProps) { + const params = await searchParams + const rawModel = Array.isArray(params.model) ? params.model[0] : params.model + const initialModelId = isCardModelId(rawModel) ? rawModel : undefined + + return ( + <> + <main className={`w-full bg-white text-black ${brexterBold.variable}`}> + <div className="mx-auto w-full max-w-[1160px] px-5 pt-6 sm:px-8 lg:px-10"> + <Link + href="/" + className="inline-flex min-h-11 items-center text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" + > + ← Voltar para a campanha + </Link> + </div> + + <section + aria-labelledby="cards-page-title" + className="mx-auto w-full max-w-[1160px] px-5 pt-6 pb-12 sm:px-8 lg:px-10 lg:pt-10 lg:pb-16" + > + <div className="mx-auto max-w-2xl text-center"> + <p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"> + Faça parte + </p> + <h1 + id="cards-page-title" + className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance" + > + Crie seu card de apoio + </h1> + <p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">{intro}</p> + <p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> + </div> + + <CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} /> + </section> + </main> + <CampaignFooter /> + </> + ) +} diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 9527a720..c424c05e 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -7,6 +7,7 @@ import { CampaignProblemCard } from '@/components/CampaignProblemCard' import { CampaignStorySection } from '@/components/CampaignStorySection' import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking' import type { ReactNode } from 'react' +import { CampaignCardsSection } from './CampaignCardsSection' import { CampaignNewsletterSection } from './CampaignNewsletterSection' /** @@ -236,6 +237,7 @@ export default async function HomePage() { <CampaignStorySection /> <CampaignNewsletterSection pixelId={pixelId ?? undefined} /> + <CampaignCardsSection /> </main> <CampaignFooter /> diff --git a/src/app/(frontend)/fonts.ts b/src/app/(frontend)/fonts.ts new file mode 100644 index 00000000..776e5822 --- /dev/null +++ b/src/app/(frontend)/fonts.ts @@ -0,0 +1,18 @@ +import localFont from 'next/font/local' + +/** + * S13 — Brexter 700, the display face of the official card masters. Web use is + * licensed for the campaign (regular DEMO face is deliberately excluded). The + * font is only consumed by the card canvas, so the family string travels to the + * client and `ensureCardFont` awaits `document.fonts.load` before measuring. + */ +export const brexterBold = localFont({ + src: './fonts/Brexter-Bold.ttf', + weight: '700', + style: 'normal', + display: 'swap', + variable: '--font-brexter', + // Canvas-only face: preloading it on every page would be waste (the studio + // loads it on demand via `document.fonts.load`). + preload: false, +}) diff --git a/src/app/(frontend)/fonts/Brexter-Bold.ttf b/src/app/(frontend)/fonts/Brexter-Bold.ttf new file mode 100644 index 00000000..9877a8d3 Binary files /dev/null and b/src/app/(frontend)/fonts/Brexter-Bold.ttf differ diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 097d2c4e..ed8c41ef 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -36,7 +36,6 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel' import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker' -import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -79,7 +78,6 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, "./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da, "./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0, - "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/components/campaign/activity/ActivityDateTimeField.tsx b/src/components/campaign/activity/ActivityDateTimeField.tsx index e8dd492d..30f32d27 100644 --- a/src/components/campaign/activity/ActivityDateTimeField.tsx +++ b/src/components/campaign/activity/ActivityDateTimeField.tsx @@ -151,7 +151,7 @@ export const ActivityDateTimeField = ({ ['Minuto', minute, setMinute], ] as const ).map(([part, value, onChange]) => ( - <div key={part} className={cn('flex flex-col gap-1', isNarrow ? 'w-16' : 'w-20')}> + <div key={part} className="flex w-16 flex-col gap-1"> {!isNarrow ? ( <span className="text-xs font-medium text-muted-foreground select-none">{part}</span> ) : null} diff --git a/src/components/campaign/activity/ActivityOverlay.tsx b/src/components/campaign/activity/ActivityOverlay.tsx index 1143bef4..52f94f03 100644 --- a/src/components/campaign/activity/ActivityOverlay.tsx +++ b/src/components/campaign/activity/ActivityOverlay.tsx @@ -436,11 +436,7 @@ const ActivityOverlayForm = ({ const demandsSection = <ActivityDemandFields error={errorFor('demandsJson')} /> return ( - <form - onSubmit={handleSubmit} - noValidate - className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'} - > + <form onSubmit={handleSubmit} noValidate className="flex min-h-0 flex-1 flex-col"> {isEdit ? <input type="hidden" name="id" value={editDraft?.id ?? ''} /> : null} <input type="hidden" name="status" value="confirmado" /> {allDay ? <input type="hidden" name="allDay" value="on" /> : null} @@ -449,10 +445,11 @@ const ActivityOverlayForm = ({ <input type="hidden" name="municipality" value={municipalityValue} /> <div + data-slot="dialog-scroll-body" className={ sheet ? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6' - : 'max-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' } > {sheet ? ( @@ -518,6 +515,7 @@ const ActivityOverlayForm = ({ </div> <div + data-slot="dialog-footer" className={ sheet ? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3' @@ -598,8 +596,8 @@ export const ActivityOverlay = ({ </Drawer> ) : ( <Dialog open onOpenChange={(next) => (next ? undefined : onClose())}> - <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0"> - <DialogHeader className="border-b px-6 py-4"> + <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0"> + <DialogHeader className="shrink-0 border-b px-6 py-4"> <DialogTitle>{isEdit ? 'Editar atividade' : 'Nova atividade'}</DialogTitle> {request.kind === 'create' ? ( <DialogDescription>{formatBahiaDateTimeLabel(request.startAt)}</DialogDescription> diff --git a/src/components/campaign/activity/CalendarFeedDialog.tsx b/src/components/campaign/activity/CalendarFeedDialog.tsx index 7175a6ac..402b13b3 100644 --- a/src/components/campaign/activity/CalendarFeedDialog.tsx +++ b/src/components/campaign/activity/CalendarFeedDialog.tsx @@ -92,127 +92,125 @@ export const CalendarFeedDialog = ({ onOpenChange(false) } - const body = ( + const content = createdUrl ? ( <> - {createdUrl ? ( - <div className="space-y-4"> - <div className="space-y-2"> - <label className="text-sm font-medium" htmlFor="feed-url"> - Link de import - </label> - <div className="flex gap-2"> - <Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" /> - <Button - type="button" - variant="outline" - size="icon" - onClick={handleCopy} - aria-label="Copiar link" - > - <CopyIcon className="h-4 w-4" /> - </Button> - </div> - {copied && <p className="text-xs text-green-600">Link copiado!</p>} - </div> - - <div className="rounded-lg bg-muted p-4 text-sm"> - <p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p> - <ol className="list-inside list-decimal space-y-1 text-muted-foreground"> - <li>Abra o Google Calendar no computador</li> - <li> - No menu lateral, clique em {'"'} - {'+'} - {'"'} ao lado de {'"'}Outras agendas{'"'} - </li> - <li> - Selecione {'"'}Por URL{'"'} - </li> - <li> - Cole o link acima e clique em {'"'}Adicionar agenda{'"'} - </li> - </ol> - </div> - - <div className="flex justify-end gap-2"> - {!isMobile ? ( - <Button type="button" variant="outline" onClick={() => handleClose(false)}> - Fechar - </Button> - ) : null} - <Button asChild> - <a - href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl" - target="_blank" - rel="noopener noreferrer" - > - <ExternalLinkIcon className="mr-2 h-4 w-4" /> - Abrir Google Calendar - </a> - </Button> - </div> + <div className="space-y-2"> + <label className="text-sm font-medium" htmlFor="feed-url"> + Link de import + </label> + <div className="flex gap-2"> + <Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" /> + <Button + type="button" + variant="outline" + size="icon" + onClick={handleCopy} + aria-label="Copiar link" + > + <CopyIcon className="h-4 w-4" /> + </Button> </div> - ) : ( - <div className="space-y-4"> - <div className="space-y-2"> - <label htmlFor="feed-label" className="text-sm font-medium"> - Nome do feed - </label> - <Input - id="feed-label" - value={label} - onChange={(e) => setLabel(e.target.value)} - placeholder="Ex: Só deputado presente" - maxLength={120} - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} - /> - <p className="text-xs text-muted-foreground"> - Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} - Reuniões em Salvador{'"'}) - </p> - </div> + {copied && <p className="text-xs text-green-600">Link copiado!</p>} + </div> - {error && <p className="text-sm text-red-600">{error}</p>} + <div className="rounded-lg bg-muted p-4 text-sm"> + <p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p> + <ol className="list-inside list-decimal space-y-1 text-muted-foreground"> + <li>Abra o Google Calendar no computador</li> + <li> + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} + </li> + <li> + Selecione {'"'}Por URL{'"'} + </li> + <li> + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} + </li> + </ol> + </div> + </> + ) : ( + <> + <div className="space-y-2"> + <label htmlFor="feed-label" className="text-sm font-medium"> + Nome do feed + </label> + <Input + id="feed-label" + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder="Ex: Só deputado presente" + maxLength={120} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + <p className="text-xs text-muted-foreground"> + Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} + Reuniões em Salvador{'"'}) + </p> + </div> - {feeds.length > 0 && ( - <div className="space-y-2"> - <p className="text-sm font-medium">Seus feeds ativos</p> - <ul className="space-y-1"> - {feeds.map((feed) => ( - <li - key={feed.id} - className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm" - > - <span>{feed.label}</span> - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => void onRevokeFeed(feed.id)} - aria-label={`Revogar feed ${feed.label}`} - > - <Trash2Icon className="h-4 w-4 text-muted-foreground" /> - </Button> - </li> - ))} - </ul> - </div> - )} + {error && <p className="text-sm text-red-600">{error}</p>} - <div className="flex justify-end gap-2"> - {!isMobile ? ( - <Button type="button" variant="outline" onClick={() => handleClose(false)}> - Cancelar - </Button> - ) : null} - <Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}> - {isCreating ? 'Gerando...' : 'Gerar link'} - </Button> - </div> + {feeds.length > 0 && ( + <div className="space-y-2"> + <p className="text-sm font-medium">Seus feeds ativos</p> + <ul className="space-y-1"> + {feeds.map((feed) => ( + <li + key={feed.id} + className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm" + > + <span>{feed.label}</span> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => void onRevokeFeed(feed.id)} + aria-label={`Revogar feed ${feed.label}`} + > + <Trash2Icon className="h-4 w-4 text-muted-foreground" /> + </Button> + </li> + ))} + </ul> </div> )} </> ) + const actions = createdUrl ? ( + <div className="flex justify-end gap-2"> + {!isMobile ? ( + <Button type="button" variant="outline" onClick={() => handleClose(false)}> + Fechar + </Button> + ) : null} + <Button asChild> + <a + href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl" + target="_blank" + rel="noopener noreferrer" + > + <ExternalLinkIcon className="mr-2 h-4 w-4" /> + Abrir Google Calendar + </a> + </Button> + </div> + ) : ( + <div className="flex justify-end gap-2"> + {!isMobile ? ( + <Button type="button" variant="outline" onClick={() => handleClose(false)}> + Cancelar + </Button> + ) : null} + <Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}> + {isCreating ? 'Gerando...' : 'Gerar link'} + </Button> + </div> + ) + if (isMobile) { return ( <Drawer open={open} onOpenChange={handleClose} showSwipeHandle> @@ -224,7 +222,12 @@ export const CalendarFeedDialog = ({ Teqo serão refletidas automaticamente. </DrawerDescription> </DrawerHeader> - <div className="overflow-y-auto px-4 pb-2">{body}</div> + <div className="overflow-y-auto px-4 pb-2"> + <div className="space-y-4"> + {content} + {actions} + </div> + </div> <DrawerFooter className="border-t"> <DrawerCloseButton className="w-full">Fechar</DrawerCloseButton> </DrawerFooter> @@ -235,15 +238,23 @@ export const CalendarFeedDialog = ({ return ( <Dialog open={open} onOpenChange={handleClose}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> + <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"> <DialogTitle>Sincronizar com Google Calendar</DialogTitle> <DialogDescription> Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em Teqo serão refletidas automaticamente. </DialogDescription> </DialogHeader> - {body} + <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> + <div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4"> + {actions} + </div> </DialogContent> </Dialog> ) diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index fa515cbb..58a91b1c 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -357,33 +357,34 @@ export const GoogleCalendarSyncDialog = ({ </div> ) - let statusBlock: React.ReactNode = null + let content: React.ReactNode + let actions: React.ReactNode = null - if (state.status === 'disabled') { - statusBlock = ( - <div className="space-y-4"> - <div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground"> - A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é - enviado ao Google enquanto estiver desativada. - </div> - <div className="flex justify-end"> - <Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}> - Reativar - </Button> - </div> - </div> - ) - } else if (state.status === 'not-configured') { - statusBlock = + if (state.status === 'not-configured') { + content = state.connection === 'connected' ? ( <p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground"> A conexão está ativa. O calendário da campanha é escolhido na configuração do Painel. </p> ) : null + } else if (state.status === 'disabled') { + content = ( + <div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground"> + A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é + enviado ao Google enquanto estiver desativada. + </div> + ) + actions = ( + <div className="flex justify-end"> + <Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}> + Reativar + </Button> + </div> + ) } else { const paused = state.status === 'paused' - statusBlock = ( - <div className="space-y-4"> + content = ( + <> {paused ? ( <div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm"> <p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p> @@ -410,44 +411,47 @@ export const GoogleCalendarSyncDialog = ({ {linkBlock} {instructions} {reverseEditBlock} - - <div className="flex justify-end gap-2"> - {paused ? ( - <Button asChild variant="outline"> - <a - href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl" - target="_blank" - rel="noopener noreferrer" - > - <ExternalLinkIcon className="mr-2 h-4 w-4" /> - Abrir Google Calendar - </a> - </Button> - ) : null} - {syncButton} - <Button - type="button" - variant="outline" - onClick={() => void handleSetDisabled(true)} - disabled={isBusy} - > - Desativar + </> + ) + actions = ( + <div className="flex justify-end gap-2"> + {paused ? ( + <Button asChild variant="outline"> + <a + href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl" + target="_blank" + rel="noopener noreferrer" + > + <ExternalLinkIcon className="mr-2 h-4 w-4" /> + Abrir Google Calendar + </a> </Button> - </div> + ) : null} + {syncButton} + <Button + type="button" + variant="outline" + onClick={() => void handleSetDisabled(true)} + disabled={isBusy} + > + Desativar + </Button> </div> ) } + const errorNotice = actionError ? ( + <p role="alert" className="text-sm text-red-600"> + {actionError} + </p> + ) : null + + // C149 — the connection card leads every state; the mirror blocks stay below. const body = ( - <div className="space-y-4"> + <> {connectionBlock} - {statusBlock} - {actionError && ( - <p role="alert" className="text-sm text-red-600"> - {actionError} - </p> - )} - </div> + {content} + </> ) const description = @@ -466,7 +470,13 @@ export const GoogleCalendarSyncDialog = ({ <DrawerTitle>Agenda da Campanha no Google</DrawerTitle> <DrawerDescription>{description}</DrawerDescription> </DrawerHeader> - <div className="overflow-y-auto px-4 pb-2">{body}</div> + <div className="overflow-y-auto px-4 pb-2"> + <div className="space-y-4"> + {body} + {errorNotice} + {actions} + </div> + </div> <DrawerFooter className="border-t"> <DrawerCloseButton className="w-full">Fechar</DrawerCloseButton> </DrawerFooter> @@ -477,12 +487,23 @@ export const GoogleCalendarSyncDialog = ({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> + <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"> <DialogTitle>Agenda da Campanha no Google</DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> - {body} + <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">{body}</div> + </div> + {errorNotice || actions ? ( + <div data-slot="dialog-footer" className="shrink-0 space-y-3 border-t px-6 py-4"> + {errorNotice} + {actions} + </div> + ) : null} </DialogContent> </Dialog> ) diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx new file mode 100644 index 00000000..150682c0 --- /dev/null +++ b/src/components/cards/CardComposer.tsx @@ -0,0 +1,500 @@ +'use client' + +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowRightIcon, + ArrowUpIcon, + MinusIcon, + PlusIcon, +} from 'lucide-react' +import { useEffect, useId, useMemo, useRef, useState, type PointerEvent } from 'react' + +import { + canvasToPngBlob, + cardFileName, + downloadBlob, + ensureCardFont, + loadCardImage, + loadCardPhoto, +} from '@/components/cards/cardCanvas' +import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy' +import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas' +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer' +import type { CardModel } from '@/lib/cardModels' +import type { CardNameFit } from '@/lib/cardNameFit' +import { + CARD_PHOTO_MAX_ZOOM, + CARD_PHOTO_MIN_ZOOM, + centerCardPhotoTransform, + panCardPhotoTransform, + zoomCardPhotoTransform, + type CardPhotoSize, + type CardPhotoTransform, +} from '@/lib/cardPhotoTransform' +import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender' + +const primaryButtonClassName = + 'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50' +const secondaryButtonClassName = + 'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none' +const controlButtonClassName = + 'inline-flex size-11 items-center justify-center rounded-lg border border-(--campaign-line) bg-white text-(--campaign-ink) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none' + +type CardComposerProps = { + model: CardModel + shell: 'dialog' | 'drawer' + fontFamily: string + onClose: () => void +} + +export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposerProps) => { + const [step, setStep] = useState<'compose' | 'result'>('compose') + const [name, setName] = useState('') + const [nameFit, setNameFit] = useState<CardNameFit | null>(null) + const [photo, setPhoto] = useState<HTMLImageElement | null>(null) + const [photoTransform, setPhotoTransform] = useState<CardPhotoTransform | null>(null) + const [photoError, setPhotoError] = useState<string | null>(null) + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') + const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null) + const [downloadError, setDownloadError] = useState<string | null>(null) + const [isDownloading, setIsDownloading] = useState(false) + + const canvasRef = useRef<HTMLCanvasElement>(null) + const fileInputRef = useRef<HTMLInputElement>(null) + const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) + const nameInputId = useId() + const zoomInputId = useId() + + const photoWindow = model.photoWindow + const photoSize = useMemo<CardPhotoSize | null>( + () => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null), + [photo], + ) + + // Assets + font load once per model: typing/panning must not flip the preview + // back into the loading state (flicker) nor re-announce the live region. + useEffect(() => { + let cancelled = false + setLoadState('loading') + setBaseImage(null) + + const load = async () => { + try { + const fontReady = await ensureCardFont(fontFamily) + if (!fontReady) throw new Error('card-font-unavailable') + const image = await loadCardImage(model.assetSrc) + if (cancelled) return + setBaseImage(image) + setLoadState('ready') + } catch { + if (!cancelled) setLoadState('error') + } + } + + void load() + return () => { + cancelled = true + } + }, [model.assetSrc, fontFamily]) + + useEffect(() => { + const ctx = canvasRef.current?.getContext('2d') + if (!ctx || !baseImage || loadState !== 'ready') return + + ctx.clearRect(0, 0, model.width, model.height) + + if (model.kind === 'name') { + setNameFit( + renderNameCard(ctx, model, { + image: baseImage, + name, + fontFamily, + measure: createCardMeasure(ctx, fontFamily), + }), + ) + return + } + + if (photo && photoTransform && photoWindow && photoSize) { + const clamped = renderPhotoCard(ctx, { + photo, + frame: baseImage, + photoSize, + frameSize: { width: model.width, height: model.height }, + window: photoWindow, + transform: photoTransform, + }) + if ( + clamped.zoom !== photoTransform.zoom || + clamped.offsetX !== photoTransform.offsetX || + clamped.offsetY !== photoTransform.offsetY + ) { + setPhotoTransform(clamped) + } + return + } + + if (photoWindow) { + ctx.fillStyle = '#ded8d2' + ctx.fillRect(0, 0, model.width, model.height) + ctx.drawImage(baseImage, 0, 0, model.width, model.height) + } + }, [baseImage, loadState, model, name, fontFamily, photo, photoSize, photoTransform, photoWindow]) + + const handlePhotoFile = async (file: File | undefined) => { + if (!file || !photoWindow) return + setPhotoError(null) + + try { + const image = await loadCardPhoto(file) + setPhoto(image) + setPhotoTransform( + centerCardPhotoTransform( + { width: image.naturalWidth, height: image.naturalHeight }, + photoWindow, + ), + ) + } catch { + setPhoto(null) + setPhotoTransform(null) + setPhotoError('Não foi possível usar esta foto. Escolha outra imagem e tente novamente.') + } finally { + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => { + if (!photoSize || !photoWindow) return + dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => { + const drag = dragRef.current + const canvas = canvasRef.current + if (!drag || drag.pointerId !== event.pointerId || !photoSize || !photoWindow || !canvas) return + + const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width) + const dx = (event.clientX - drag.x) * scale + const dy = (event.clientY - drag.y) * scale + dragRef.current = { ...drag, x: event.clientX, y: event.clientY } + setPhotoTransform((current) => + current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current, + ) + } + + const handlePointerEnd = () => { + dragRef.current = null + } + + const panBy = (dx: number, dy: number) => { + if (!photoSize || !photoWindow) return + setPhotoTransform((current) => + current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current, + ) + } + + const zoomTo = (zoom: number) => { + if (!photoSize || !photoWindow) return + setPhotoTransform((current) => + current ? zoomCardPhotoTransform(current, photoSize, photoWindow, zoom) : current, + ) + } + + const handleDownload = async () => { + const canvas = canvasRef.current + if (!canvas) return + + setIsDownloading(true) + setDownloadError(null) + try { + const blob = await canvasToPngBlob(canvas) + downloadBlob(blob, cardFileName(model.id)) + } catch { + setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.') + } finally { + setIsDownloading(false) + } + } + + const isNameModel = model.kind === 'name' + const nameCanAdvance = nameFit?.ok === true + const canAdvance = isNameModel ? nameCanAdvance : photo !== null + const nameError = isNameModel && name.trim().length > 0 && nameFit !== null && !nameFit.ok + const title = + step === 'result' + ? 'Seu card está pronto para compartilhar.' + : isNameModel + ? 'Personalize com seu nome' + : 'Enquadre sua foto' + const description = + step === 'result' || isNameModel + ? null + : 'Arraste para posicionar e use os controles para aproximar ou ajustar.' + + const header = + shell === 'dialog' ? ( + <DialogHeader className="min-w-0 text-left"> + <DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance"> + {title} + </DialogTitle> + {description ? <DialogDescription>{description}</DialogDescription> : null} + </DialogHeader> + ) : ( + <DrawerHeader className="min-w-0 text-left"> + <DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance"> + {title} + </DrawerTitle> + {description ? <DrawerDescription>{description}</DrawerDescription> : null} + </DrawerHeader> + ) + + return ( + <div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)"> + <div className="flex items-start gap-3 px-5 pt-5"> + {header} + <button + type="button" + onClick={onClose} + aria-label="Fechar" + className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" + > + × + </button> + </div> + + <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 pb-5"> + <p className="sr-only" role="status" aria-live="polite"> + {loadState === 'loading' ? 'Carregando prévia…' : ''} + </p> + + <div + className={`relative mx-auto w-full max-w-[22rem] ${photo ? 'touch-none' : ''}`} + tabIndex={photo ? 0 : undefined} + role={photo ? 'group' : undefined} + aria-label={photo ? 'Ajuste da foto' : undefined} + onKeyDown={ + photo + ? (event) => { + const step = event.shiftKey ? 48 : 12 + const deltas: Record<string, [number, number]> = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, -step], + ArrowDown: [0, step], + } + const delta = deltas[event.key] + if (!delta) return + event.preventDefault() + panBy(delta[0], delta[1]) + } + : undefined + } + > + <CardPreviewCanvas + model={model} + canvasRef={canvasRef} + className={`mx-auto block h-auto max-h-[38dvh] w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${ + loadState === 'loading' ? 'opacity-60' : 'opacity-100' + }`} + onPointerDown={photo ? handlePointerDown : undefined} + onPointerMove={photo ? handlePointerMove : undefined} + onPointerUp={photo ? handlePointerEnd : undefined} + onPointerCancel={photo ? handlePointerEnd : undefined} + /> + </div> + + {loadState === 'error' ? ( + <p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)"> + Não foi possível carregar o modelo. Recarregue a página e tente de novo. + </p> + ) : null} + + {step === 'compose' && isNameModel ? ( + <div className="mt-5"> + <label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)"> + Seu nome + </label> + <input + id={nameInputId} + type="text" + value={name} + onChange={(event) => setName(event.target.value)} + autoComplete="off" + maxLength={60} + className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" + /> + <p className="mt-2 text-xs text-(--campaign-muted)"> + Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você. + </p> + {nameError ? ( + <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> + Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é + chamado. + </p> + ) : null} + <p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> + </div> + ) : null} + + {step === 'compose' && !isNameModel && photoWindow ? ( + <div className="mt-5"> + {photo && photoTransform ? ( + <> + <div className="flex items-center gap-3"> + <label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)"> + Zoom + </label> + <button + type="button" + aria-label="Diminuir zoom" + onClick={() => zoomTo(photoTransform.zoom - 0.1)} + className={controlButtonClassName} + > + <MinusIcon className="size-4" aria-hidden="true" /> + </button> + <input + id={zoomInputId} + type="range" + min={CARD_PHOTO_MIN_ZOOM} + max={CARD_PHOTO_MAX_ZOOM} + step={0.05} + value={photoTransform.zoom} + onChange={(event) => zoomTo(Number(event.target.value))} + className="h-11 w-full flex-1 accent-(--pt-red)" + /> + <button + type="button" + aria-label="Aumentar zoom" + onClick={() => zoomTo(photoTransform.zoom + 0.1)} + className={controlButtonClassName} + > + <PlusIcon className="size-4" aria-hidden="true" /> + </button> + </div> + + <div className="mt-4"> + <p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p> + <div className="mt-2 grid grid-cols-4 gap-2"> + <button + type="button" + aria-label="Mover foto para a esquerda" + onClick={() => panBy(-12, 0)} + className={controlButtonClassName} + > + <ArrowLeftIcon className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + aria-label="Mover foto para cima" + onClick={() => panBy(0, -12)} + className={controlButtonClassName} + > + <ArrowUpIcon className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + aria-label="Mover foto para baixo" + onClick={() => panBy(0, 12)} + className={controlButtonClassName} + > + <ArrowDownIcon className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + aria-label="Mover foto para a direita" + onClick={() => panBy(12, 0)} + className={controlButtonClassName} + > + <ArrowRightIcon className="size-4" aria-hidden="true" /> + </button> + </div> + </div> + </> + ) : ( + <p className="text-sm text-(--campaign-muted)"> + Escolha uma foto vertical ou horizontal para começar. + </p> + )} + + <input + ref={fileInputRef} + type="file" + accept="image/*" + tabIndex={-1} + aria-hidden="true" + className="sr-only" + onChange={(event) => void handlePhotoFile(event.target.files?.[0])} + /> + + {photoError ? ( + <div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3"> + <p className="text-sm font-bold text-(--pt-red)">Não foi possível usar esta foto</p> + <p className="mt-1 text-sm text-(--campaign-muted)"> + Escolha outra imagem e tente novamente. + </p> + </div> + ) : null} + + <div className="mt-4 flex gap-2"> + <button + type="button" + onClick={() => fileInputRef.current?.click()} + className={secondaryButtonClassName} + > + {photoError ? 'Escolher outra foto' : photo ? 'Trocar foto' : 'Escolher foto'} + </button> + </div> + + <p className="mt-3 text-xs text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p> + </div> + ) : null} + + {step === 'result' ? ( + <div className="mt-5"> + <p className="text-sm text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> + {downloadError ? ( + <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> + {downloadError} + </p> + ) : null} + </div> + ) : null} + </div> + + <div className="flex shrink-0 gap-3 border-t border-(--campaign-line) px-5 py-4"> + {step === 'compose' ? ( + <> + <button type="button" onClick={onClose} className={secondaryButtonClassName}> + Cancelar + </button> + <button + type="button" + disabled={!canAdvance || loadState !== 'ready'} + onClick={() => setStep('result')} + className={primaryButtonClassName} + > + Criar meu card + </button> + </> + ) : ( + <> + <button type="button" onClick={onClose} className={secondaryButtonClassName}> + Criar outro card + </button> + <button + type="button" + disabled={isDownloading || loadState !== 'ready'} + onClick={() => void handleDownload()} + className={primaryButtonClassName} + > + {isDownloading ? 'Gerando…' : 'Baixar meu card'} + </button> + </> + )} + </div> + </div> + ) +} diff --git a/src/components/cards/CardModelGallery.tsx b/src/components/cards/CardModelGallery.tsx new file mode 100644 index 00000000..85e6b4c8 --- /dev/null +++ b/src/components/cards/CardModelGallery.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link' + +import { CardModelTile } from '@/components/cards/CardModelTile' +import { CARD_MODELS, cardModelHref, type CardModel, type CardModelId } from '@/lib/cardModels' + +type CardModelGalleryProps = { + variant: 'link' | 'select' + ariaLabel: string + selectedId?: CardModelId | null + onSelect?: (id: CardModelId) => void +} + +const tileClassName = + 'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)' + +/** + * S13 — the three models side by side on desktop and a one-item-with-peek + * snap track on mobile. `link` renders deep links (home funnel), `select` + * renders buttons for the `/cards` studio; both come from the same catalog. + */ +export const CardModelGallery = ({ + variant, + ariaLabel, + selectedId = null, + onSelect, +}: CardModelGalleryProps) => { + const renderItem = (model: CardModel) => { + const selected = variant === 'select' && selectedId === model.id + const content = ( + <> + <CardModelTile model={model} selected={selected} /> + <span className="mt-auto block pt-3 text-center text-sm font-bold text-(--campaign-ink) sm:text-base"> + {model.label} + </span> + </> + ) + + if (variant === 'select') { + return ( + <button + type="button" + aria-pressed={selected} + aria-label={`${model.label}${selected ? ' (selecionado)' : ''}`} + data-card-model-tile={model.id} + onClick={() => onSelect?.(model.id)} + className={tileClassName} + > + {content} + </button> + ) + } + + return ( + <Link + href={cardModelHref(model.id)} + aria-label={`${model.label} — criar meu card`} + data-card-model-tile={model.id} + className={tileClassName} + > + {content} + </Link> + ) + } + + return ( + <div className="mt-8"> + <ul + aria-label={`${ariaLabel} (desktop)`} + className="m-0 hidden list-none grid-cols-3 gap-5 p-0 sm:gap-6 lg:grid" + > + {CARD_MODELS.map((model) => ( + <li key={model.id} className="m-0"> + {renderItem(model)} + </li> + ))} + </ul> + + <ul + aria-label={`${ariaLabel} (mobile)`} + className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-5 p-0 pb-2 lg:hidden" + > + {CARD_MODELS.map((model) => ( + <li key={model.id} className="m-0 flex w-[78%] shrink-0 snap-start sm:w-[58%]"> + {renderItem(model)} + </li> + ))} + </ul> + + <p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden"> + Deslize para ver os três modelos + </p> + </div> + ) +} diff --git a/src/components/cards/CardModelTile.tsx b/src/components/cards/CardModelTile.tsx new file mode 100644 index 00000000..8db7fa86 --- /dev/null +++ b/src/components/cards/CardModelTile.tsx @@ -0,0 +1,81 @@ +import { CheckIcon, UserRoundIcon } from 'lucide-react' +import Image from 'next/image' + +import { NAME_CARD_SLOT, type CardModel } from '@/lib/cardModels' +import { cn } from '@/lib/utils' + +/** + * S13 — artwork preview of one card model, shared by the home section and the + * `/cards` gallery. The name tile reuses the empty master with an HTML + * `SEU NOME` placeholder (the real name is only drawn by the canvas); the photo + * tiles show the official transparent overlay above a neutral photo slot. + */ +export const CardModelTile = ({ + model, + selected = false, + priority = false, +}: { + model: CardModel + selected?: boolean + priority?: boolean +}) => ( + <span + className={cn( + 'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200', + selected + ? 'border-(--pt-red) ring-2 ring-(--pt-red)' + : 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg', + )} + style={{ aspectRatio: `${model.width} / ${model.height}`, containerType: 'inline-size' }} + > + {model.kind === 'photo' ? ( + <> + <span + aria-hidden="true" + className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]" + /> + {model.photoWindow ? ( + <span + aria-hidden="true" + className="absolute inset-x-0 top-0 flex items-center justify-center" + style={{ + height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`, + }} + > + <UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" /> + </span> + ) : null} + </> + ) : null} + + <Image + src={model.assetSrc} + alt="" + fill + priority={priority} + sizes="(min-width: 1024px) 340px, 78vw" + className="object-cover" + /> + + {model.kind === 'name' ? ( + <span + aria-hidden="true" + className="absolute -translate-x-1/2 font-[family-name:var(--font-exo2)] leading-none font-black tracking-[-0.02em]" + style={{ + left: `${(NAME_CARD_SLOT.centerX / model.width) * 100}%`, + top: `${(NAME_CARD_SLOT.capTop / model.height) * 100}%`, + fontSize: '11.5cqw', + color: NAME_CARD_SLOT.fill, + }} + > + SEU NOME + </span> + ) : null} + + {selected ? ( + <span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md"> + <CheckIcon className="size-4" aria-hidden="true" /> + </span> + ) : null} + </span> +) diff --git a/src/components/cards/CardPreviewCanvas.tsx b/src/components/cards/CardPreviewCanvas.tsx new file mode 100644 index 00000000..ab411dc2 --- /dev/null +++ b/src/components/cards/CardPreviewCanvas.tsx @@ -0,0 +1,26 @@ +import type { HTMLAttributes, RefObject } from 'react' + +/** + * S13 — the canvas element of the card studio. Presentational only: the + * composer owns the ref, the draw effect and the pointer/keyboard controls. + */ +export const CardPreviewCanvas = ({ + model, + canvasRef, + className, + ...props +}: { + model: { width: number; height: number; label: string } + canvasRef: RefObject<HTMLCanvasElement | null> + className?: string +} & HTMLAttributes<HTMLCanvasElement>) => ( + <canvas + ref={canvasRef} + width={model.width} + height={model.height} + role="img" + aria-label={`Prévia do card: ${model.label}`} + className={className} + {...props} + /> +) diff --git a/src/components/cards/CardsStudio.tsx b/src/components/cards/CardsStudio.tsx new file mode 100644 index 00000000..845be10d --- /dev/null +++ b/src/components/cards/CardsStudio.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useState } from 'react' + +import { CardComposer } from '@/components/cards/CardComposer' +import { CardModelGallery } from '@/components/cards/CardModelGallery' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Drawer, DrawerContent } from '@/components/ui/Drawer' +import { useIsMobileMeasured } from '@/hooks/use-mobile' +import { getCardModel, type CardModelId } from '@/lib/cardModels' + +/** + * S13 — the `/cards` studio island: owns which model is selected and whether + * the composer is open, renders the catalog and mounts the single editor in a + * centered dialog (desktop) or a bottom drawer (mobile). Closing returns to the + * catalog with the selection kept; the URL is only read, never rewritten. + */ +export const CardsStudio = ({ + initialModelId, + fontFamily, +}: { + initialModelId?: CardModelId + fontFamily: string +}) => { + const initialModel = initialModelId ? getCardModel(initialModelId) : undefined + const [selectedId, setSelectedId] = useState<CardModelId | null>(initialModel?.id ?? null) + const [open, setOpen] = useState(Boolean(initialModel)) + const { isMobile, measured } = useIsMobileMeasured() + const selectedModel = selectedId ? getCardModel(selectedId) : undefined + + const handleSelect = (id: CardModelId) => { + setSelectedId(id) + setOpen(true) + } + + // Radix restores focus to the element that opened the dialog, but the home + // tile that deep-linked here is unmounted after navigation. Send focus back + // to the catalog tile of the selected model instead (visible list only). + const focusSelectedTile = () => { + if (!selectedId) return + const tiles = document.querySelectorAll<HTMLElement>(`[data-card-model-tile="${selectedId}"]`) + const visibleTile = Array.from(tiles).find((tile) => tile.getClientRects().length > 0) + visibleTile?.focus() + } + + return ( + <div> + <CardModelGallery + variant="select" + ariaLabel="Modelos de card" + selectedId={selectedId} + onSelect={handleSelect} + /> + + {selectedModel && measured ? ( + isMobile ? ( + <Drawer open={open} onOpenChange={setOpen} swipeDirection="down" showSwipeHandle> + <DrawerContent + data-theme="campaign-site" + className="bg-background text-foreground [--drawer-content-max-height:92dvh]" + > + <CardComposer + model={selectedModel} + shell="drawer" + fontFamily={fontFamily} + onClose={() => setOpen(false)} + /> + </DrawerContent> + </Drawer> + ) : ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent + data-theme="campaign-site" + showCloseButton={false} + onCloseAutoFocus={(event) => { + event.preventDefault() + focusSelectedTile() + }} + className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:max-w-lg" + > + <CardComposer + model={selectedModel} + shell="dialog" + fontFamily={fontFamily} + onClose={() => setOpen(false)} + /> + </DialogContent> + </Dialog> + ) + ) : null} + </div> + ) +} diff --git a/src/components/cards/cardCanvas.ts b/src/components/cards/cardCanvas.ts new file mode 100644 index 00000000..dfcd621e --- /dev/null +++ b/src/components/cards/cardCanvas.ts @@ -0,0 +1,81 @@ +/** + * S13 — browser adapters for the card studio. Kept out of `src/lib` because + * they touch DOM APIs; the pure geometry stays in `cardRender`/`cardNameFit`/ + * `cardPhotoTransform`. Imports of the model assets are cached so reopening a + * composer never re-downloads the master. + */ + +const CARD_IMAGE_CACHE = new Map<string, Promise<HTMLImageElement>>() + +export const loadCardImage = (src: string): Promise<HTMLImageElement> => { + const cached = CARD_IMAGE_CACHE.get(src) + if (cached) return cached + + const promise = new Promise<HTMLImageElement>((resolve, reject) => { + const image = new Image() + image.decoding = 'async' + image.onload = () => resolve(image) + image.onerror = () => { + CARD_IMAGE_CACHE.delete(src) + reject(new Error(`card-image-load-failed:${src}`)) + } + image.src = src + }) + + CARD_IMAGE_CACHE.set(src, promise) + return promise +} + +export const loadCardPhoto = (file: File): Promise<HTMLImageElement> => + new Promise<HTMLImageElement>((resolve, reject) => { + const url = URL.createObjectURL(file) + const image = new Image() + image.decoding = 'async' + image.onload = () => { + URL.revokeObjectURL(url) + resolve(image) + } + image.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('card-photo-load-failed')) + } + image.src = url + }) + +/** + * Canvas does not wait for CSS fonts: the family must be loaded before + * measuring. `next/font` hands a family list (primary + generated fallback), + * and `document.fonts.check` fails closed on the unloaded fallback — so only + * the primary family is awaited here. + */ +export const ensureCardFont = async (fontFamily: string): Promise<boolean> => { + if (typeof document === 'undefined' || !document.fonts) return false + + const primaryFamily = fontFamily.split(',')[0]?.trim() || fontFamily + + try { + const faces = await document.fonts.load(`700 100px ${primaryFamily}`) + return faces.length > 0 + } catch { + return false + } +} + +export const canvasToPngBlob = (canvas: HTMLCanvasElement): Promise<Blob> => + new Promise<Blob>((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob) + else reject(new Error('card-png-unavailable')) + }, 'image/png') + }) + +export const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} + +export const cardFileName = (modelId: string): string => `card-jorge-solla-${modelId}.png` diff --git a/src/components/cards/cardCopy.ts b/src/components/cards/cardCopy.ts new file mode 100644 index 00000000..7936f145 --- /dev/null +++ b/src/components/cards/cardCopy.ts @@ -0,0 +1,8 @@ +/** + * S13 — shared card-studio copy. The privacy notes are literals of the intent + * and must stay identical between the `/cards` page and the composer. + */ + +export const CARD_PRIVACY_NOTE = + 'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.' +export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.' diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts new file mode 100644 index 00000000..f12960b0 --- /dev/null +++ b/src/lib/cardModels.ts @@ -0,0 +1,90 @@ +/** + * S13 — the three personalized-card models of the campaign site. Pure data so + * the home section, the `/cards` gallery and the canvas renderer share one + * catalog (no second editor, no CMS): each entry carries the master asset, the + * real output size in pixels and, for photo models, the transparent window the + * visitor's photo must cover. + * + * Geometry comes from the master files (pixel-measured, keep in sync with + * `docs/plans/cards-personalizados-campanha-impl.md`): the photo frames are + * 1000×1000 and 1000×1440 despite the `1080x` in the original file names. + */ + +const CARD_MODEL_IDS = ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular'] as const + +export type CardModelId = (typeof CARD_MODEL_IDS)[number] +type CardModelKind = 'name' | 'photo' + +export type CardRect = { + x: number + y: number + width: number + height: number +} + +export type CardModel = { + id: CardModelId + kind: CardModelKind + label: string + assetSrc: string + width: number + height: number + /** Photo models only: the transparent window the photo must cover. */ + photoWindow?: CardRect +} + +export const CARD_MODELS: readonly CardModel[] = [ + { + id: 'eu-sou-solla', + kind: 'name', + label: 'Card com seu nome', + assetSrc: '/cards/name-card-base.jpg', + width: 1080, + height: 1440, + }, + { + id: 'perfil-quadrado', + kind: 'photo', + label: 'Moldura quadrada', + assetSrc: '/cards/photo-square-frame.png', + width: 1000, + height: 1000, + photoWindow: { x: 0, y: 0, width: 1000, height: 740 }, + }, + { + id: 'perfil-retangular', + kind: 'photo', + label: 'Moldura vertical', + assetSrc: '/cards/photo-portrait-frame.png', + width: 1000, + height: 1440, + photoWindow: { x: 0, y: 0, width: 1000, height: 1044 }, + }, +] + +/** + * Name model slot, pixel-measured from the filled master (`FULANO` example): + * glyph cap band tops at y=430 with a 106px cap height, ink spans x[213..926] + * (714px) centered at x≈570 — not at the canvas center. The visitor's name is + * the only thing drawn over the base; the fixed lines already live in the art. + */ +export const NAME_CARD_SLOT = { + centerX: 570, + capTop: 430, + capHeight: 106, + maxInkWidth: 714, + maxBlockBottom: 630, + minCapHeight: 48, + fill: '#ffec01', + maxLines: 2, +} as const + +export const BRAND_FALLBACK_FONT = 'sans-serif' + +export const isCardModelId = (value: unknown): value is CardModelId => + typeof value === 'string' && (CARD_MODEL_IDS as readonly string[]).includes(value) + +export const getCardModel = (id: CardModelId): CardModel | undefined => + CARD_MODELS.find((model) => model.id === id) + +export const cardModelHref = (id: CardModelId): string => `/cards?model=${id}` diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts new file mode 100644 index 00000000..e03d42e6 --- /dev/null +++ b/src/lib/cardNameFit.ts @@ -0,0 +1,151 @@ +/** + * S13 — pure fit math for the name card. The visitor's name must never be cut + * silently: it starts at the master's cap height (106px), shrinks and — when a + * single line would drop below the readable minimum — wraps into two lines + * before shrinking further. Past `minCapHeight` the fit fails and the composer + * asks for a shorter name. + * + * Text measurement is injected (`CardMeasureText`) so the module stays free of + * DOM and unit-testable with a deterministic fake. + */ + +import { NAME_CARD_SLOT } from './cardModels' + +export type CardTextMetrics = { + width: number + actualBoundingBoxAscent: number + actualBoundingBoxDescent: number +} + +export type CardMeasureText = (text: string, fontSize: number) => CardTextMetrics + +export type CardNameFit = + | { + ok: true + lines: string[] + fontSize: number + lineHeight: number + capHeight: number + } + | { ok: false; reason: 'empty' | 'too-long' } + +export type CardNameSlot = typeof NAME_CARD_SLOT + +const CAP_PROBE = 'X' +const LINE_HEIGHT_RATIO = 1.25 +const MEASURE_PROBE_SIZE = 100 + +export const normalizeCardName = (name: string): string => + name.trim().replace(/\s+/g, ' ').toLocaleUpperCase('pt-BR') + +export const resolveFontSizeForCapHeight = ( + measure: CardMeasureText, + capHeight: number, +): number => { + const probe = measure(CAP_PROBE, MEASURE_PROBE_SIZE) + if (probe.actualBoundingBoxAscent <= 0) return capHeight + + return (capHeight / probe.actualBoundingBoxAscent) * MEASURE_PROBE_SIZE +} + +const widthAt = (measure: CardMeasureText, text: string, fontSize: number): number => + Math.max(0, measure(text, fontSize).width) + +const capAt = (measure: CardMeasureText, fontSize: number): number => + Math.max(0, measure(CAP_PROBE, fontSize).actualBoundingBoxAscent) + +const fitSingleLine = ( + text: string, + measure: CardMeasureText, + slot: CardNameSlot, +): number | null => { + const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight) + const idealWidth = widthAt(measure, text, idealSize) + if (idealWidth <= slot.maxInkWidth) return idealSize + + const shrunk = Math.floor((idealSize * slot.maxInkWidth) / idealWidth) + if (shrunk <= 0) return null + if (widthAt(measure, text, shrunk) > slot.maxInkWidth) return null + + return shrunk +} + +const fitTwoLines = ( + words: string[], + measure: CardMeasureText, + slot: CardNameSlot, +): { fontSize: number; lines: [string, string] } | null => { + const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight) + const maxBlockHeight = slot.maxBlockBottom - slot.capTop + let best: { fontSize: number; lines: [string, string] } | null = null + + for (let split = 1; split < words.length; split++) { + const lines: [string, string] = [words.slice(0, split).join(' '), words.slice(split).join(' ')] + const widest = Math.max( + widthAt(measure, lines[0], idealSize), + widthAt(measure, lines[1], idealSize), + ) + if (widest <= 0) continue + + let size = Math.min(idealSize, (idealSize * slot.maxInkWidth) / widest) + const capHeight = capAt(measure, size) + const blockHeight = size * LINE_HEIGHT_RATIO + capHeight + if (blockHeight > maxBlockHeight) { + size *= maxBlockHeight / blockHeight + } + + size = Math.floor(size) + if (size <= 0) continue + + const lineWidths = [widthAt(measure, lines[0], size), widthAt(measure, lines[1], size)] + const sizeCap = capAt(measure, size) + const sizeBlock = size * LINE_HEIGHT_RATIO + sizeCap + if (lineWidths.some((width) => width > slot.maxInkWidth)) continue + if (sizeBlock > maxBlockHeight) continue + if (best === null || size > best.fontSize) best = { fontSize: size, lines } + } + + return best +} + +/** + * Fits `name` to the master slot honoring `maxLines`. The returned geometry is + * what `renderNameCard` draws: `capHeight` is the measured cap at the chosen + * size and `lineHeight` is the baseline-to-baseline distance. + */ +export const fitCardName = ( + name: string, + measure: CardMeasureText, + slot: CardNameSlot = NAME_CARD_SLOT, +): CardNameFit => { + const normalized = normalizeCardName(name) + if (!normalized) return { ok: false, reason: 'empty' } + + const minFontSize = resolveFontSizeForCapHeight(measure, slot.minCapHeight) + const singleSize = fitSingleLine(normalized, measure, slot) + if (singleSize !== null && singleSize >= minFontSize) { + return { + ok: true, + lines: [normalized], + fontSize: singleSize, + lineHeight: singleSize * LINE_HEIGHT_RATIO, + capHeight: capAt(measure, singleSize), + } + } + + const words = normalized.split(' ') + if (slot.maxLines >= 2 && words.length > 1) { + const twoLines = fitTwoLines(words, measure, slot) + if (twoLines !== null && twoLines.fontSize >= minFontSize) { + return { + ok: true, + lines: twoLines.lines, + fontSize: twoLines.fontSize, + lineHeight: twoLines.fontSize * LINE_HEIGHT_RATIO, + capHeight: capAt(measure, twoLines.fontSize), + } + } + } + + return { ok: false, reason: 'too-long' } +} diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts new file mode 100644 index 00000000..7bd03eae --- /dev/null +++ b/src/lib/cardPhotoTransform.ts @@ -0,0 +1,126 @@ +/** + * S13 — pure cover/pan/zoom math for the photo card models. The visitor photo + * is drawn full-bleed under the master overlay and must always cover the + * transparent window: `clampCardPhotoTransform` keeps the zoom inside + * [1, 4] and pins the offsets so no gap can open at the window edges. + * + * `centerCardPhotoTransform` is the initial framing (cover on the window, not + * on the whole card) so the face lands on the visible area of the frame. + */ + +import type { CardRect } from './cardModels' + +export const CARD_PHOTO_MIN_ZOOM = 1 +export const CARD_PHOTO_MAX_ZOOM = 4 + +export type CardPhotoSize = { + width: number + height: number +} + +export type CardPhotoTransform = { + zoom: number + offsetX: number + offsetY: number +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max) + +export const coverScale = (source: CardPhotoSize, window: CardRect): number => { + if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1 + + return Math.max(window.width / source.width, window.height / source.height) +} + +export const cardPhotoDrawRect = ( + transform: CardPhotoTransform, + source: CardPhotoSize, + window: CardRect, +): CardRect => { + const clamped = clampCardPhotoTransform(transform, source, window) + const scale = coverScale(source, window) * clamped.zoom + + return { + x: clamped.offsetX, + y: clamped.offsetY, + width: source.width * scale, + height: source.height * scale, + } +} + +export const clampCardPhotoTransform = ( + transform: CardPhotoTransform, + source: CardPhotoSize, + window: CardRect, +): CardPhotoTransform => { + const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM) + const scale = coverScale(source, window) * zoom + const width = source.width * scale + const height = source.height * scale + + return { + zoom, + offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x), + offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y), + } +} + +export const centerCardPhotoTransform = ( + source: CardPhotoSize, + window: CardRect, +): CardPhotoTransform => { + const scale = coverScale(source, window) + const width = source.width * scale + const height = source.height * scale + + return { + zoom: CARD_PHOTO_MIN_ZOOM, + offsetX: window.x + (window.width - width) / 2, + offsetY: window.y + (window.height - height) / 2, + } +} + +/** Nudges the offset by a step in card pixels, clamped to the window. */ +export const panCardPhotoTransform = ( + transform: CardPhotoTransform, + source: CardPhotoSize, + window: CardRect, + dx: number, + dy: number, +): CardPhotoTransform => + clampCardPhotoTransform( + { ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy }, + source, + window, + ) + +/** + * Rescales around a window anchor (used by the zoom control): keeps the point + * under `anchor` stable from old to new zoom, then clamps. + */ +export const zoomCardPhotoTransform = ( + transform: CardPhotoTransform, + source: CardPhotoSize, + window: CardRect, + nextZoom: number, + anchor: { x: number; y: number } = { + x: window.x + window.width / 2, + y: window.y + window.height / 2, + }, +): CardPhotoTransform => { + const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM) + if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window) + + const ratio = zoom / transform.zoom + + return clampCardPhotoTransform( + { + zoom, + offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio, + offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio, + }, + source, + window, + ) +} diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts new file mode 100644 index 00000000..68b3dfd5 --- /dev/null +++ b/src/lib/cardRender.ts @@ -0,0 +1,109 @@ +/** + * S13 — the single canvas renderer shared by the preview and the PNG export: + * `renderNameCard` draws the master base then the fitted name; `renderPhotoCard` + * draws the visitor photo full-bleed then the master overlay (the overlay is + * authoritative — never redraw bands or contra-forms). The context is a + * structural subset of `CanvasRenderingContext2D` so unit tests drive a fake + * recorder instead of jsdom canvas. + */ + +import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, type CardModel, type CardRect } from './cardModels' +import { + fitCardName, + type CardMeasureText, + type CardNameFit, + type CardNameSlot, + type CardTextMetrics, +} from './cardNameFit' +import { + cardPhotoDrawRect, + clampCardPhotoTransform, + type CardPhotoSize, + type CardPhotoTransform, +} from './cardPhotoTransform' + +export type CardDrawContext = { + font: string + fillStyle: string | CanvasGradient | CanvasPattern + textAlign: CanvasTextAlign + textBaseline: CanvasTextBaseline + drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void + fillText(text: string, x: number, y: number): void + measureText(text: string): CardTextMetrics +} + +export const createCardMeasure = ( + ctx: Pick<CardDrawContext, 'font' | 'measureText'>, + fontFamily: string, +): CardMeasureText => { + return (text, fontSize) => { + ctx.font = `700 ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}` + const metrics = ctx.measureText(text) + + return { + width: metrics.width, + actualBoundingBoxAscent: metrics.actualBoundingBoxAscent ?? 0, + actualBoundingBoxDescent: metrics.actualBoundingBoxDescent ?? 0, + } + } +} + +export type NameCardRenderArgs = { + image: CanvasImageSource + name: string + fontFamily: string + measure: CardMeasureText + slot?: CardNameSlot +} + +export const renderNameCard = ( + ctx: CardDrawContext, + model: CardModel, + args: NameCardRenderArgs, +): CardNameFit => { + const slot = args.slot ?? NAME_CARD_SLOT + ctx.drawImage(args.image, 0, 0, model.width, model.height) + + const fit = fitCardName(args.name, args.measure, slot) + if (!fit.ok) return fit + + ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}` + ctx.fillStyle = slot.fill + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + + if (fit.lines.length === 1) { + ctx.fillText(fit.lines[0], slot.centerX, slot.capTop + fit.capHeight) + return fit + } + + const availableHeight = slot.maxBlockBottom - slot.capTop + const blockHeight = fit.lineHeight + fit.capHeight + const blockTop = slot.capTop + Math.max(0, (availableHeight - blockHeight) / 2) + + fit.lines.forEach((line, index) => { + ctx.fillText(line, slot.centerX, blockTop + fit.capHeight + index * fit.lineHeight) + }) + + return fit +} + +export type PhotoCardRenderArgs = { + photo: CanvasImageSource + frame: CanvasImageSource + photoSize: CardPhotoSize + frameSize: CardPhotoSize + window: CardRect + transform: CardPhotoTransform +} + +export const renderPhotoCard = ( + ctx: CardDrawContext, + args: PhotoCardRenderArgs, +): CardPhotoTransform => { + const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) + ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) + ctx.drawImage(args.frame, 0, 0, args.frameSize.width, args.frameSize.height) + + return clampCardPhotoTransform(args.transform, args.photoSize, args.window) +} diff --git a/src/utilities/ai/campaignDemandTitle.ts b/src/utilities/ai/campaignDemandTitle.ts index fe68eedf..27b19971 100644 --- a/src/utilities/ai/campaignDemandTitle.ts +++ b/src/utilities/ai/campaignDemandTitle.ts @@ -28,7 +28,7 @@ export const deriveDemandTitle = async ( try { const { text } = await generateText({ - model: deepSeek('deepseek-v4-flash'), + model: deepSeek('deepseek-flash'), system: DEMAND_TITLE_SYSTEM_PROMPT, prompt: kind ? `Tipo: ${campaignDemandKindLabels[kind]}\nPedido: ${description}` diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 7cb0c1b2..97580e72 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -69,6 +69,20 @@ test.describe('Atividades — registro-fundação', () => { // The central modal (desktop) hosts every section of the old full form. const modal = page.getByRole('dialog', { name: 'Nova atividade' }) await expect(modal).toBeVisible() + // C148 — encaixe desktop: largura generosa (não os 28rem que a base + // `sm:max-w-md` impunha) e Início/Término lado a lado na mesma linha. + const modalBox = await modal.boundingBox() + expect(modalBox?.width ?? 0).toBeGreaterThan(600) + const pairBoxes = await modal.evaluate((dialog) => { + const start = dialog.querySelector('#overlay-startAt')?.getBoundingClientRect() + const end = dialog.querySelector('#overlay-endAt')?.getBoundingClientRect() + return start && end + ? { startY: start.top, endY: end.top, startX: start.left, endX: end.left } + : null + }) + if (!pairBoxes) throw new Error('overlay start/end boxes unavailable') + expect(Math.abs(pairBoxes.startY - pairBoxes.endY)).toBeLessThan(4) + expect(pairBoxes.endX).toBeGreaterThan(pairBoxes.startX) await page.getByLabel('Título *').fill(activityTitle) // The municipality filter prefills the field; the times come from the slot. await expect(page.getByLabel('Município *')).toHaveValue(municipality.name) diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts index 3ddc9cb0..eb4d00e4 100644 --- a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -218,6 +218,21 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => { await expect(dialog.getByRole('button', { name: 'Copiar link' })).toBeVisible() await expect(dialog.getByText(/Como adicionar ao Google Calendar:/)).toBeVisible() await expect(dialog.getByRole('button', { name: 'Desativar' })).toBeVisible() + + // C148 — na janela baixa o corpo rola dentro do modal e o rodapé de ações + // permanece visível (antes o conteúdo vazava para fora da tela sem scroll). + await page.setViewportSize({ width: 1280, height: 560 }) + // O `100dvh` reflui no frame seguinte ao resize; poll até o box caber. + await expect + .poll(async () => { + const box = await dialog.boundingBox() + return box ? Math.round(box.y + box.height) : Number.POSITIVE_INFINITY + }) + .toBeLessThanOrEqual(560) + const body = dialog.locator('[data-slot="dialog-scroll-body"]') + await expect(body).toBeVisible() + expect(await body.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(0) + await expect(dialog.getByRole('button', { name: 'Sincronizar agora' })).toBeVisible() }) test.describe('mobile (FAB)', () => { diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 4abba4ad..f19284db 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test' import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js' +import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' import { fallbackDemandTitle } from '../../src/lib/demandTitle.js' import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { @@ -24,6 +25,7 @@ import { assertThreeColumnActionGrid, collectActionBoundingBoxes, } from './helpers/actionGridGeometry.js' +import { civilDatePlusDays } from './helpers/agendaPeriodLabels.js' const showAllMunicipalityColumns = (page: Page, url: string) => page.context().addCookies([ @@ -1496,6 +1498,14 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { name: fixtures.value('Coordenadora B196 Ativ'), }) const municipality = await fixtures.claimMunicipality() + // The default tab (`proximos`) only lists `confirmado` activities with + // `startAt >= now`, so the fixture start must be derived from "today" — + // a hardcoded date empties the list once it expires (deploy verify + // 2026-09-11: `scrollHeight - clientHeight` stuck at 0 on all retries). + const startAt = parseBahiaDateTimeInput( + `${civilDatePlusDays(formatBahiaCivilDate(new Date()), 7)}T13:00`, + ) + if (!startAt) throw new Error('Falha ao montar horário da fixture de atividades.') // Enough rows to make the scrollport scrollable once the top bar takes over. for (let i = 0; i < 5; i++) { await fixtures.payload.create({ @@ -1503,7 +1513,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { data: hookFilledCreateData<'activity'>({ title: `Atividade B196 ${fixtures.value('x')}`, status: 'confirmado', - startAt: '2026-09-01T13:00:00.000Z', + startAt, municipality: municipality.id, responsible: [{ relationTo: 'campaignUser', value: coordinator.id }], }), @@ -1524,10 +1534,14 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { const headerBottomValue = await headerBottom(topBar) expect((await form.boundingBox())!.y).toBeGreaterThan(headerBottomValue) - // The scrollport must actually scroll for the sticky to engage. + // The scrollport must actually scroll for the sticky to engage. Same B196 + // stream-latency class as the sibling polls: the list chunk can land late + // under 4-worker load, so use the 30s budget instead of the 10s default. const scrollport = contentScroll(page) await expect - .poll(() => scrollport.evaluate((el) => el.scrollHeight - el.clientHeight)) + .poll(() => scrollport.evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 30_000, + }) .toBeGreaterThan(0) await scrollport.evaluate((el) => el.scrollTo(0, el.scrollHeight)) await expect.poll(async () => (await form.boundingBox())!.y).toBe(headerBottomValue) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 4dc3445c..4b409e02 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,10 +1,19 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' +import { readFileSync } from 'node:fs' import { adminHeaders } from '../helpers/adminApi' import { seedTestUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +// 4×3 opaque PNG — enough to exercise decode/cover/clamp without a binary fixture. +const TEST_PHOTO_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGOQqzjxHxkzEBQAAMHJHF0PRQWkAAAAAElFTkSuQmCC', + 'base64', +) + const swipeLeft = async ( page: Page, box: { x: number; y: number; width: number; height: number }, @@ -1646,3 +1655,134 @@ test.describe('Campaign home content section', () => { } }) }) + +test.describe('Cards personalizados (S13)', () => { + test('home invites to the card funnel right after the newsletter capture', async ({ page }) => { + await page.goto('/') + + const order = await page + .locator('[data-home-section]') + .evaluateAll((sections) => + sections.map((section) => section.getAttribute('data-home-section')), + ) + expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') + 1) + + const section = page.locator('section#cards') + await expect( + section.getByRole('heading', { name: 'Mostre que você está com Solla' }), + ).toBeVisible() + for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) { + await expect(section.locator(`a[href="/cards?model=${model}"]`).first()).toBeAttached() + } + + const trigger = section.getByRole('link', { name: /Moldura quadrada/ }) + await trigger.click() + await expect(page).toHaveURL(/\/cards\?model=perfil-quadrado$/) + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect( + page.locator('[data-card-model-tile="perfil-quadrado"]:visible').first(), + ).toBeFocused() + }) + + test('name card fits the master, reaches the result and downloads a real PNG', async ({ + page, + }) => { + await page.goto('/cards') + await expect(page.getByRole('dialog')).toHaveCount(0) + + await page.getByRole('button', { name: 'Card com seu nome' }).click() + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() + + const primary = dialog.getByRole('button', { name: 'Criar meu card' }) + await expect(primary).toBeDisabled() + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('João') + await expect(primary).toBeEnabled() + + const canvas = dialog.locator('canvas') + await expect(canvas).toHaveAttribute('width', '1080') + await expect(canvas).toHaveAttribute('height', '1440') + await expect + .poll(() => + canvas.evaluate((element) => { + const node = element as HTMLCanvasElement + const band = node.getContext('2d')!.getImageData(0, 420, node.width, 120).data + let yellow = 0 + for (let index = 0; index < band.length; index += 4) { + if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) yellow += 1 + } + return yellow + }), + ) + .toBeGreaterThan(1000) + + await primary.click() + await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + dialog.getByRole('button', { name: 'Baixar meu card' }).click(), + ]) + expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true) + }) + + test('a name that cannot fit fails closed with an actionable message', async ({ page }) => { + await page.goto('/cards?model=eu-sou-solla') + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') + await expect(dialog.getByRole('alert')).toContainText('nome mais curto') + await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() + }) + + test('photo card decodes the file, keeps the overlay and recovers from a bad image', async ({ + page, + }) => { + await page.goto('/cards?model=perfil-quadrado') + const dialog = page.getByRole('dialog') + const canvas = dialog.locator('canvas') + await expect(canvas).toHaveAttribute('width', '1000') + await expect(canvas).toHaveAttribute('height', '1000') + + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'foto-e2e.png', + mimeType: 'image/png', + buffer: TEST_PHOTO_PNG, + }) + await expect(dialog.getByRole('button', { name: 'Trocar foto' })).toBeVisible() + await expect + .poll(() => + canvas.evaluate((element) => { + const node = element as HTMLCanvasElement + return node.getContext('2d')!.getImageData(5, 5, 1, 1).data[3] + }), + ) + .toBe(255) + + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'nao-e-foto.txt', + mimeType: 'text/plain', + buffer: Buffer.from('not an image'), + }) + await expect(dialog.getByRole('alert')).toContainText('Não foi possível usar esta foto') + await expect(dialog.getByRole('button', { name: 'Escolher outra foto' })).toBeVisible() + }) + + test('mobile opens the composer in the bottom drawer', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/cards?model=eu-sou-solla') + + const drawer = page.locator('[data-slot="drawer-popup"]') + await expect(drawer).toBeVisible() + await expect(drawer.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(drawer).toHaveCount(0) + }) +}) diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index bb50980c..231e1549 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -61,6 +61,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => { '/campanha/sw.js', '/campanha/convite/e2e-prewarm', '/', + '/cards', ]) { await prewarmGet(path) } diff --git a/tests/unit/activityOverlay.unit.spec.tsx b/tests/unit/activityOverlay.unit.spec.tsx index bd50be39..71c99266 100644 --- a/tests/unit/activityOverlay.unit.spec.tsx +++ b/tests/unit/activityOverlay.unit.spec.tsx @@ -317,6 +317,20 @@ describe('ActivityOverlay — criação (modal central)', () => { expect(onClose).toHaveBeenCalledTimes(1) expect(mocks.createOverlay).not.toHaveBeenCalled() }) + + it('no modal desktop, mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => { + renderOverlay({ agendaState: { municipality: 12 } }) + + const dialog = screen.getByRole('dialog', { name: 'Nova atividade' }) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByLabelText('Título *'))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(true) + expect(footer!.contains(screen.getByRole('button', { name: 'Cancelar' }))).toBe(true) + }) }) describe('ActivityOverlay — edição (modal central)', () => { diff --git a/tests/unit/calendarFeedDialog.unit.spec.tsx b/tests/unit/calendarFeedDialog.unit.spec.tsx index 1b69adaf..ce94a6ae 100644 --- a/tests/unit/calendarFeedDialog.unit.spec.tsx +++ b/tests/unit/calendarFeedDialog.unit.spec.tsx @@ -6,6 +6,8 @@ import { TooltipProvider } from '@/components/ui/tooltip' type DialogProps = Parameters<typeof CalendarFeedDialog>[0] +const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret' + const renderDialog = (overrides: Partial<DialogProps> = {}) => { const onCreateFeed = vi.fn() const onRevokeFeed = vi.fn() @@ -72,7 +74,6 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => { }) it('creates the filterless feed through onCreateFeed with the chosen label', async () => { - const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret' const { onCreateFeed } = renderDialog() onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL }) @@ -85,4 +86,35 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => { await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa')) await within(dialog).findByDisplayValue(FEED_URL) }) + + it('mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => { + renderDialog() + + const dialog = screen.getByRole('dialog') + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByLabelText('Nome do feed'))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(true) + }) + + it('no estado criado, o link fica no corpo e as ações no rodapé (C148)', async () => { + const { onCreateFeed } = renderDialog() + onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL }) + + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByLabelText('Nome do feed'), { + target: { value: 'Agenda completa' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Gerar link' })) + + const link = await within(dialog).findByDisplayValue(FEED_URL) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body!.contains(link)).toBe(true) + expect(body!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(false) + expect(footer!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(true) + }) }) diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts new file mode 100644 index 00000000..784ba1a8 --- /dev/null +++ b/tests/unit/cardModels.unit.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { + CARD_MODELS, + NAME_CARD_SLOT, + cardModelHref, + getCardModel, + isCardModelId, +} from '@/lib/cardModels' + +describe('card model catalog (S13)', () => { + it('exposes the three shipped models with unique ids', () => { + expect(CARD_MODELS.map((model) => model.id)).toEqual([ + 'eu-sou-solla', + 'perfil-quadrado', + 'perfil-retangular', + ]) + expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length) + }) + + it('serves every master from the local /cards asset folder', () => { + for (const model of CARD_MODELS) { + expect(model.assetSrc.startsWith('/cards/')).toBe(true) + } + }) + + it('pins the real output dimensions of the masters (not the 1080 in the file names)', () => { + expect(getCardModel('eu-sou-solla')).toMatchObject({ width: 1080, height: 1440, kind: 'name' }) + expect(getCardModel('perfil-quadrado')).toMatchObject({ + width: 1000, + height: 1000, + kind: 'photo', + }) + expect(getCardModel('perfil-retangular')).toMatchObject({ + width: 1000, + height: 1440, + kind: 'photo', + }) + }) + + it('keeps the photo window measured from the transparent masters', () => { + expect(getCardModel('perfil-quadrado')?.photoWindow).toEqual({ + x: 0, + y: 0, + width: 1000, + height: 740, + }) + expect(getCardModel('perfil-retangular')?.photoWindow).toEqual({ + x: 0, + y: 0, + width: 1000, + height: 1044, + }) + expect(getCardModel('eu-sou-solla')?.photoWindow).toBeUndefined() + }) + + it('guards the name slot geometry measured from the filled master', () => { + expect(NAME_CARD_SLOT).toMatchObject({ + centerX: 570, + capTop: 430, + capHeight: 106, + maxInkWidth: 714, + }) + }) + + it('sanitizes query-param values through isCardModelId', () => { + expect(isCardModelId('eu-sou-solla')).toBe(true) + expect(isCardModelId('perfil-quadrado')).toBe(true) + expect(isCardModelId('perfil-retangular')).toBe(true) + expect(isCardModelId('modelo-inventado')).toBe(false) + expect(isCardModelId(['perfil-quadrado'])).toBe(false) + expect(isCardModelId(undefined)).toBe(false) + }) + + it('builds the deep-link used by the home tiles', () => { + expect(cardModelHref('perfil-quadrado')).toBe('/cards?model=perfil-quadrado') + }) +}) diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts new file mode 100644 index 00000000..214707bb --- /dev/null +++ b/tests/unit/cardNameFit.unit.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { NAME_CARD_SLOT } from '@/lib/cardModels' +import { + fitCardName, + normalizeCardName, + resolveFontSizeForCapHeight, + type CardMeasureText, +} from '@/lib/cardNameFit' + +/** Deterministic fake: 0.6em per char, 0.72em cap, 0.2em descent. */ +const measure: CardMeasureText = (text, fontSize) => ({ + width: text.length * fontSize * 0.6, + actualBoundingBoxAscent: fontSize * 0.72, + actualBoundingBoxDescent: fontSize * 0.2, +}) + +const inkWidth = (text: string, fontSize: number) => measure(text, fontSize).width +const minFontSize = resolveFontSizeForCapHeight(measure, NAME_CARD_SLOT.minCapHeight) + +describe('normalizeCardName', () => { + it('trims, collapses spaces and uppercases with the pt-BR locale', () => { + expect(normalizeCardName(' joão conceição ')).toBe('JOÃO CONCEIÇÃO') + }) +}) + +describe('resolveFontSizeForCapHeight', () => { + it('calibrates the font size from the measured cap, not the raw cap value', () => { + expect(resolveFontSizeForCapHeight(measure, 106)).toBeCloseTo(106 / 0.72, 5) + }) + + it('falls back to the cap value when the probe has no ascent', () => { + const broken: CardMeasureText = () => ({ + width: 0, + actualBoundingBoxAscent: 0, + actualBoundingBoxDescent: 0, + }) + expect(resolveFontSizeForCapHeight(broken, 106)).toBe(106) + }) +}) + +describe('fitCardName', () => { + it('keeps the master cap height for a short name', () => { + const fit = fitCardName('João', measure) + + expect(fit).toMatchObject({ ok: true, lines: ['JOÃO'] }) + if (fit.ok) { + expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5) + expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) + } + }) + + it('shrinks a long single line to the ink width instead of cutting it', () => { + const fit = fitCardName('Conceição', measure) + + expect(fit).toMatchObject({ ok: true, lines: ['CONCEIÇÃO'] }) + if (fit.ok) { + expect(fit.fontSize).toBeLessThan(resolveFontSizeForCapHeight(measure, 106)) + expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) + expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize) + } + }) + + it('wraps into two balanced lines when single-line would fall under the minimum', () => { + const fit = fitCardName('Jorge Solla da Bahia', measure) + + expect(fit.ok).toBe(true) + if (fit.ok) { + expect(fit.lines).toEqual(['JORGE SOLLA', 'DA BAHIA']) + expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize) + for (const line of fit.lines) { + expect(inkWidth(line, fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) + } + const blockHeight = fit.lineHeight + fit.capHeight + expect(blockHeight).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom - NAME_CARD_SLOT.capTop) + } + }) + + it('does not split a single word in the middle', () => { + const fit = fitCardName('Bartolomeucostajunior', measure) + + expect(fit).toEqual({ ok: false, reason: 'too-long' }) + }) + + it('fails closed for an empty name', () => { + expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' }) + }) + + it('fails closed when shrinking cannot reach the ink width (adversarial metrics)', () => { + const rigid: CardMeasureText = (_text, fontSize) => ({ + width: 5000, + actualBoundingBoxAscent: fontSize * 0.72, + actualBoundingBoxDescent: fontSize * 0.2, + }) + const rigidCase = fitCardName('João', rigid) + + expect(rigidCase.ok).toBe(false) + if (!rigidCase.ok) expect(rigidCase.reason).toBe('too-long') + }) + + it('fails closed when the shrink factor collapses to zero', () => { + const absurd: CardMeasureText = (_text, fontSize) => ({ + width: 1_000_000_000, + actualBoundingBoxAscent: fontSize * 0.72, + actualBoundingBoxDescent: fontSize * 0.2, + }) + const absurdCase = fitCardName('João', absurd) + + expect(absurdCase.ok).toBe(false) + if (!absurdCase.ok) expect(absurdCase.reason).toBe('too-long') + }) + + it('preserves Portuguese accents in the fitted lines', () => { + const fit = fitCardName('Coração', measure) + + expect(fit.ok).toBe(true) + if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO') + }) +}) diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts new file mode 100644 index 00000000..c29d50cc --- /dev/null +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' + +import type { CardRect } from '@/lib/cardModels' +import { + CARD_PHOTO_MAX_ZOOM, + CARD_PHOTO_MIN_ZOOM, + cardPhotoDrawRect, + centerCardPhotoTransform, + clampCardPhotoTransform, + coverScale, + panCardPhotoTransform, + zoomCardPhotoTransform, + type CardPhotoSize, +} from '@/lib/cardPhotoTransform' + +const WINDOW: CardRect = { x: 0, y: 0, width: 1000, height: 740 } +const LANDSCAPE: CardPhotoSize = { width: 2000, height: 1000 } +const PORTRAIT: CardPhotoSize = { width: 900, height: 1600 } + +const coversWindow = (rect: CardRect, window: CardRect) => + rect.x <= window.x && + rect.y <= window.y && + rect.x + rect.width >= window.x + window.width && + rect.y + rect.height >= window.y + window.height + +describe('coverScale', () => { + it('covers the window on the tighter axis', () => { + expect(coverScale(LANDSCAPE, WINDOW)).toBeCloseTo(0.74, 5) + expect(coverScale(PORTRAIT, WINDOW)).toBeCloseTo(1000 / 900, 5) + }) + + it('degrades to 1 for empty sizes instead of dividing by zero', () => { + expect(coverScale({ width: 0, height: 0 }, WINDOW)).toBe(1) + }) +}) + +describe('centerCardPhotoTransform', () => { + it('covers the window and centers the photo on it', () => { + const transform = centerCardPhotoTransform(LANDSCAPE, WINDOW) + const rect = cardPhotoDrawRect(transform, LANDSCAPE, WINDOW) + + expect(transform).toEqual({ zoom: 1, offsetX: -240, offsetY: 0 }) + expect(coversWindow(rect, WINDOW)).toBe(true) + }) + + it('centers a portrait photo with vertical slack', () => { + const transform = centerCardPhotoTransform(PORTRAIT, WINDOW) + const rect = cardPhotoDrawRect(transform, PORTRAIT, WINDOW) + + expect(rect.height).toBeGreaterThan(WINDOW.height) + expect(rect.y).toBeLessThan(0) + expect(coversWindow(rect, WINDOW)).toBe(true) + }) +}) + +describe('clampCardPhotoTransform', () => { + it('clamps the zoom to [1, 4]', () => { + expect( + clampCardPhotoTransform({ zoom: 0.2, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom, + ).toBe(CARD_PHOTO_MIN_ZOOM) + expect( + clampCardPhotoTransform({ zoom: 9, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom, + ).toBe(CARD_PHOTO_MAX_ZOOM) + }) + + it('pins the offsets so no gap can open at the window edges', () => { + const tooFarRight = clampCardPhotoTransform( + { zoom: 1, offsetX: 9999, offsetY: 9999 }, + LANDSCAPE, + WINDOW, + ) + const tooFarLeft = clampCardPhotoTransform( + { zoom: 1, offsetX: -9999, offsetY: -9999 }, + LANDSCAPE, + WINDOW, + ) + + expect(tooFarRight).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 }) + expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 }) + expect(coversWindow(cardPhotoDrawRect(tooFarLeft, LANDSCAPE, WINDOW), WINDOW)).toBe(true) + }) +}) + +describe('panCardPhotoTransform', () => { + it('nudges the offset and stays clamped', () => { + const start = centerCardPhotoTransform(LANDSCAPE, WINDOW) + const panned = panCardPhotoTransform(start, LANDSCAPE, WINDOW, 100, -40) + + expect(panned.offsetX).toBe(-140) + expect(panned.offsetY).toBe(0) + + const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999) + expect(overPanned).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 }) + }) +}) + +describe('zoomCardPhotoTransform', () => { + it('keeps the anchor point stable while zooming', () => { + const start = centerCardPhotoTransform(LANDSCAPE, WINDOW) + const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 }) + const scale = coverScale(LANDSCAPE, WINDOW) * 2 + + expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5) + expect(coversWindow(cardPhotoDrawRect(zoomed, LANDSCAPE, WINDOW), WINDOW)).toBe(true) + }) + + it('clamps above the max zoom and never opens a gap', () => { + const zoomed = zoomCardPhotoTransform( + centerCardPhotoTransform(PORTRAIT, WINDOW), + PORTRAIT, + WINDOW, + 99, + ) + + expect(zoomed.zoom).toBe(CARD_PHOTO_MAX_ZOOM) + expect(coversWindow(cardPhotoDrawRect(zoomed, PORTRAIT, WINDOW), WINDOW)).toBe(true) + }) + + it('returns the clamped transform untouched when the zoom does not change', () => { + const start = centerCardPhotoTransform(LANDSCAPE, WINDOW) + const same = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, start.zoom) + + expect(same).toEqual(start) + }) +}) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts new file mode 100644 index 00000000..28d2b912 --- /dev/null +++ b/tests/unit/cardRender.unit.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' + +import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels' +import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform' +import { + createCardMeasure, + renderNameCard, + renderPhotoCard, + type CardDrawContext, +} from '@/lib/cardRender' + +type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number } +type TextCall = { text: string; x: number; y: number } + +const fontSizeFrom = (font: string): number => { + const match = /(\d+(?:\.\d+)?)px/.exec(font) + return match ? Number(match[1]) : 0 +} + +const createFakeContext = () => { + const drawCalls: DrawCall[] = [] + const textCalls: TextCall[] = [] + + const ctx: CardDrawContext = { + font: '', + fillStyle: '', + textAlign: 'start', + textBaseline: 'alphabetic', + drawImage: (image, dx, dy, dw, dh) => { + drawCalls.push({ image, dx, dy, dw, dh }) + }, + fillText: (text, x, y) => { + textCalls.push({ text, x, y }) + }, + measureText: (text) => { + const size = fontSizeFrom(ctx.font) + return { + width: text.length * size * 0.6, + actualBoundingBoxAscent: size * 0.72, + actualBoundingBoxDescent: size * 0.2, + } + }, + } + + return { ctx, drawCalls, textCalls } +} + +const nameModel = getCardModel('eu-sou-solla')! +const squareModel = getCardModel('perfil-quadrado')! + +describe('renderNameCard', () => { + it('draws the master base first and then the centered name', () => { + const { ctx, drawCalls, textCalls } = createFakeContext() + const image = { id: 'base' } as unknown as CanvasImageSource + const fit = renderNameCard(ctx, nameModel, { + image, + name: 'João', + fontFamily: 'Brexter', + measure: createCardMeasure(ctx, 'Brexter'), + }) + + expect(fit.ok).toBe(true) + expect(drawCalls).toEqual([{ image, dx: 0, dy: 0, dw: 1080, dh: 1440 }]) + expect(textCalls).toHaveLength(1) + expect(textCalls[0]!.text).toBe('JOÃO') + expect(textCalls[0]!.x).toBe(NAME_CARD_SLOT.centerX) + expect(textCalls[0]!.y).toBeCloseTo(NAME_CARD_SLOT.capTop + NAME_CARD_SLOT.capHeight, 5) + expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill) + expect(ctx.textAlign).toBe('center') + }) + + it('wraps long names into two lines with baselines inside the free band', () => { + const { ctx, textCalls } = createFakeContext() + const fit = renderNameCard(ctx, nameModel, { + image: { id: 'base' } as unknown as CanvasImageSource, + name: 'Jorge Solla da Bahia', + fontFamily: 'Brexter', + measure: createCardMeasure(ctx, 'Brexter'), + }) + + expect(fit.ok).toBe(true) + expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA']) + expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y) + expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom) + }) + + it('draws the base but no text when the name cannot fit (never cuts silently)', () => { + const { ctx, drawCalls, textCalls } = createFakeContext() + const fit = renderNameCard(ctx, nameModel, { + image: { id: 'base' } as unknown as CanvasImageSource, + name: 'Bartolomeucostajunior', + fontFamily: 'Brexter', + measure: createCardMeasure(ctx, 'Brexter'), + }) + + expect(fit).toEqual({ ok: false, reason: 'too-long' }) + expect(drawCalls).toHaveLength(1) + expect(textCalls).toHaveLength(0) + }) +}) + +describe('renderPhotoCard', () => { + it('draws the photo full-bleed under the official overlay', () => { + const { ctx, drawCalls } = createFakeContext() + const photo = { id: 'photo' } as unknown as CanvasImageSource + const frame = { id: 'frame' } as unknown as CanvasImageSource + const size: CardPhotoSize = { width: 2000, height: 1000 } + const window: CardRect = squareModel.photoWindow! + const transform = centerCardPhotoTransform(size, window) + + const clamped = renderPhotoCard(ctx, { + photo, + frame, + photoSize: size, + frameSize: { width: squareModel.width, height: squareModel.height }, + window, + transform, + }) + + expect(clamped).toEqual(transform) + expect(drawCalls).toHaveLength(2) + expect(drawCalls[0]!.image).toBe(photo) + expect(drawCalls[1]).toEqual({ image: frame, dx: 0, dy: 0, dw: 1000, dh: 1000 }) + expect(drawCalls[0]!.dx).toBeLessThanOrEqual(window.x) + expect(drawCalls[0]!.dy).toBeLessThanOrEqual(window.y) + expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width) + expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height) + }) +}) diff --git a/tests/unit/eslintConventions.unit.spec.ts b/tests/unit/eslintConventions.unit.spec.ts index 8a0dd22b..fb779cd9 100644 --- a/tests/unit/eslintConventions.unit.spec.ts +++ b/tests/unit/eslintConventions.unit.spec.ts @@ -189,11 +189,30 @@ describe('ESLint conventions', () => { it('allows async function and type-only exports from use server modules', async () => { await expectValid( 'src/app/(campaign)/campanha/actions/futureAction.ts', - `'use server'\n\nexport type Result = { ok: true }\nexport const save = async () => ({ ok: true as const })\nexport async function load() { return 1 }\n`, + `'use server'\nexport type Result = { ok: true }\nexport const save = async () => ({ ok: true as const })\nexport async function load() { return 1 }\n`, ) await expectValid( 'src/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages.ts', `export const municipalityStaffEditSafeMessages = ['x'] as const\n`, ) }) + + it('bans hardcoded ISO fixture dates in e2e specs but not state timestamps', async () => { + await expectInvalid( + 'tests/e2e/futureFixture.e2e.spec.ts', + `const fixture = { startAt: '2026-09-01T13:00:00.000Z' }`, + 'no-restricted-syntax', + ) + await expectInvalid( + 'tests/e2e/futureFixture.e2e.spec.ts', + `const fixture = { endDate: '2026-09-01T13:00:00.000Z' }`, + 'no-restricted-syntax', + ) + // `disabledAt`/`lastSyncedAt` are the state under test, not a time window; + // a computed `startAt` is exactly the sanctioned derivation. + await expectValid( + 'tests/e2e/futureFixture.e2e.spec.ts', + `const fixture = { disabledAt: '2026-08-11T00:00:00.000Z' }\nconst startAt = 'dynamic'\nconst activity = { startAt }`, + ) + }) }) diff --git a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx index 9c49ff7a..95cc5d1c 100644 --- a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx +++ b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx @@ -26,6 +26,27 @@ const syncedState = ( ...overrides, }) +const notConfiguredState = ( + overrides: Partial<GoogleCalendarSyncActionResult> = {}, +): GoogleCalendarSyncActionResult => ({ + ok: true, + canManageConnection: true, + connection: 'not-configured', + oauthAvailable: true, + oauthConnectedAt: null, + oauthError: null, + status: 'not-configured', + calendarId: null, + lastSyncedAt: null, + lastSuccessAt: null, + lastErrorAt: null, + lastError: null, + pushChannelExpiresAt: null, + pushChannelError: null, + addLink: null, + ...overrides, +}) + const renderDialog = ( state: GoogleCalendarSyncActionResult, overrides: { @@ -101,50 +122,37 @@ describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)', }) it('não mostra a seção no estado não configurado', () => { - renderDialog({ - ok: true, - canManageConnection: true, - connection: 'not-configured', - oauthAvailable: true, - oauthConnectedAt: null, - oauthError: null, - status: 'not-configured', - calendarId: null, - lastSyncedAt: null, - lastSuccessAt: null, - lastErrorAt: null, - lastError: null, - pushChannelExpiresAt: null, - pushChannelError: null, - addLink: null, - }) + renderDialog(notConfiguredState()) const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull() }) }) -describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => { - const notConfiguredState = ( - overrides: Partial<GoogleCalendarSyncActionResult> = {}, - ): GoogleCalendarSyncActionResult => ({ - ok: true, - canManageConnection: true, - connection: 'not-configured', - oauthAvailable: true, - oauthConnectedAt: null, - oauthError: null, - status: 'not-configured', - calendarId: null, - lastSyncedAt: null, - lastSuccessAt: null, - lastErrorAt: null, - lastError: null, - pushChannelExpiresAt: null, - pushChannelError: null, - addLink: null, - ...overrides, +describe('GoogleCalendarSyncDialog — encaixe desktop (C148)', () => { + it('mantém o conteúdo no corpo rolável e as ações no rodapé', () => { + renderDialog(syncedState()) + + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByText(/Edições pelo Google/))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(true) + expect(footer!.contains(screen.getByRole('button', { name: 'Desativar' }))).toBe(true) }) + it('não renderiza rodapé quando não há ação (não configurado)', () => { + renderDialog(notConfiguredState()) + + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + expect(dialog.querySelector('[data-slot="dialog-footer"]')).toBeNull() + expect(dialog.querySelector('[data-slot="dialog-scroll-body"]')).toBeTruthy() + }) +}) + +describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => { it('offers the connect button when the OAuth client is configured', () => { renderDialog(notConfiguredState()) const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index 30a5ba76..a98b3890 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -33,8 +33,9 @@ describe('opencode commands couple to their skill by exact name', () => { expect(frontmatter![1], 'frontmatter must declare a TUI description').toMatch( /^description: .+$/m, ) - expect(frontmatter![1], 'frontmatter must pin the preference model').toMatch( - /^model: deepseek\/deepseek-v4-flash$/m, - ) + expect( + frontmatter![1], + 'frontmatter must NOT pin a model — the command inherits the session model (OPS101)', + ).not.toMatch(/^\s*["']?model["']?\s*:/m) }) }) diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 0acab872..32f20802 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -43,7 +43,7 @@ type TestIssue = { * OPENCODE_WORKTREE_MODEL não está exportado (CI), ou o valor exportado * (shell local) — o lib resolve exatamente nessa precedência. */ -const presetInEffect = () => process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash' +const presetInEffect = () => process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-flash' const issue = (over: Partial<TestIssue> = {}): TestIssue => ({ number: 1, @@ -242,7 +242,7 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 ).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto`) }) - it('pins the preset constants — fallback comum deepseek-v4-flash, override via OPENCODE_WORKTREE_MODEL', () => { + it('pins the preset constants — fallback comum deepseek-flash, override via OPENCODE_WORKTREE_MODEL', () => { expect(OPENCODE_PRESET_MODEL).toBe(presetInEffect()) expect(WORKTREE_TERMINAL_ENV).toBe('TEQO_WORKTREE_TERMINAL') expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({