mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge remote-tracking branch 'origin/main' into C149-google-calendar-botao-conectar-google-oauth-substitui-a
# Conflicts: # src/components/campaign/activity/GoogleCalendarSyncDialog.tsx # tests/unit/googleCalendarSyncDialog.unit.spec.tsx
This commit is contained in:
commit
9968432559
64 changed files with 3266 additions and 240 deletions
|
|
@ -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/<domínio>/` — 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/<domínio>/` — 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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
|
|||
|
||||
**Default-go:** `next`, `plan` e `new` imprimem `cd <dir>` 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 <main>` 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 <dir> --model <preset|map> --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` 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 <dir> --model <preset|map> --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-09-12-b201.md
Normal file
1
docs/changelog/2026-09-12-b201.md
Normal file
|
|
@ -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.
|
||||
1
docs/changelog/2026-09-12-c148.md
Normal file
1
docs/changelog/2026-09-12-c148.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
1
docs/changelog/2026-09-12-ops101.md
Normal file
1
docs/changelog/2026-09-12-ops101.md
Normal file
|
|
@ -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).
|
||||
1
docs/changelog/2026-09-12-s13-cards-personalizados.md
Normal file
1
docs/changelog/2026-09-12-s13-cards-personalizados.md
Normal file
|
|
@ -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`.
|
||||
117
docs/plans/c148-modais-agenda-desktop-impl.md
Normal file
117
docs/plans/c148-modais-agenda-desktop-impl.md
Normal file
|
|
@ -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<br/>remove w-full e max-w-2xl"]
|
||||
A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto<br/>(morre max-h-[calc(100dvh-14rem)])"]
|
||||
A2 --> A3["Início/Término em sm:grid-cols-2<br/>data+hora inline por campo"]
|
||||
B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]<br/>flex-col p-0 sm:max-w-xl sm:p-0"]
|
||||
B1 --> B2["header shrink-0 / corpo rolável /<br/>footer shrink-0 com as ações"]
|
||||
B2 --> B3["mobile: composição atual intacta<br/>(body com ações + DrawerFooter)"]
|
||||
C["ui/dialog.tsx"] -. "não tocar" .-> A
|
||||
C -. "não tocar" .-> B
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) override local com variantes `sm:` explícitas nos 3 modais | B) mudar a base `ui/dialog.tsx` (largura/padding/scroll default) | C) extrair um shell compartilhado "modal desktop com corpo rolável" para os 3 call sites.
|
||||
**Recomendação:** A — cada modal já tem conteúdo, padding e largura próprios; a correção é de classe, e o padrão a replicar já existe no repo (QuickActions: `flex max-h-[min(85dvh,40rem)] … overflow-hidden` + filho `overflow-y-auto`, `CampaignQuickActionsOverlay.tsx:166-186`; Advisor: `max-h-[calc(100dvh-2rem)] overflow-y-auto`, `AdvisorPermissionBadge.tsx:86`). Zero superfície de regressão fora da agenda.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — mexer em `ui/dialog.tsx`:** base de 10 imports (`Command`, `PetitionSuccessDialog`, `CampaignNotificationBell`, `CampaignQuickActionsOverlay`, `MunicipalityV2StatusReasonDialog`, `CampaignUpdatesCreateModal`, `AdvisorPermissionBadge` + os 3 da agenda). 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]` + `<div className="overflow-y-auto px-4 pb-2">{content}{actionError}{actionRow}</div>` + `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 `<div data-slot="dialog-scroll-body" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"><div className="space-y-4">{content}</div></div>`; rodapé `<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">{actionError}{actionRow}</div>` (não renderiza quando ambos vazios — `not-configured`).
|
||||
- **`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/<data>-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).**
|
||||
199
docs/plans/cards-personalizados-campanha-impl.md
Normal file
199
docs/plans/cards-personalizados-campanha-impl.md
Normal file
|
|
@ -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 + `<Suspense>` | 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 `<CardsStudio initialModelId={…} />`.
|
||||
- **`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=<id>`).
|
||||
- **`src/app/(frontend)/(home)/page.tsx`** (editar): `<CampaignCardsSection />` logo após `<CampaignNewsletterSection … />`, dentro do `<main>`, antes de `</main>`/`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 → `<a download>` → revoke, precedente `SupporterImportWizard.tsx:50-58`); filename sem PII (`card-jorge-solla-<modelId>.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` + `<title>` |
|
||||
| `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.
|
||||
68
docs/plans/sollinha-modelo-canonico-deepseek-flash-impl.md
Normal file
68
docs/plans/sollinha-modelo-canonico-deepseek-flash-impl.md
Normal file
|
|
@ -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.
|
||||
78
docs/plans/sollinha-modelo-canonico-deepseek-flash.md
Normal file
78
docs/plans/sollinha-modelo-canonico-deepseek-flash.md
Normal file
|
|
@ -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.
|
||||
136
docs/plans/worktree-modelo-canonico-deepseek-flash-impl.md
Normal file
136
docs/plans/worktree-modelo-canonico-deepseek-flash-impl.md
Normal file
|
|
@ -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).
|
||||
85
docs/plans/worktree-modelo-canonico-deepseek-flash.md
Normal file
85
docs/plans/worktree-modelo-canonico-deepseek-flash.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
BIN
public/cards/name-card-base.jpg
Normal file
BIN
public/cards/name-card-base.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
BIN
public/cards/photo-portrait-frame.png
Normal file
BIN
public/cards/photo-portrait-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
public/cards/photo-square-frame.png
Normal file
BIN
public/cards/photo-square-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
47
src/app/(frontend)/(home)/CampaignCardsSection.tsx
Normal file
47
src/app/(frontend)/(home)/CampaignCardsSection.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
63
src/app/(frontend)/(home)/cards/page.tsx
Normal file
63
src/app/(frontend)/(home)/cards/page.tsx
Normal file
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
18
src/app/(frontend)/fonts.ts
Normal file
18
src/app/(frontend)/fonts.ts
Normal file
|
|
@ -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,
|
||||
})
|
||||
BIN
src/app/(frontend)/fonts/Brexter-Bold.ttf
Normal file
BIN
src/app/(frontend)/fonts/Brexter-Bold.ttf
Normal file
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
500
src/components/cards/CardComposer.tsx
Normal file
500
src/components/cards/CardComposer.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
94
src/components/cards/CardModelGallery.tsx
Normal file
94
src/components/cards/CardModelGallery.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
81
src/components/cards/CardModelTile.tsx
Normal file
81
src/components/cards/CardModelTile.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
26
src/components/cards/CardPreviewCanvas.tsx
Normal file
26
src/components/cards/CardPreviewCanvas.tsx
Normal file
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
93
src/components/cards/CardsStudio.tsx
Normal file
93
src/components/cards/CardsStudio.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
81
src/components/cards/cardCanvas.ts
Normal file
81
src/components/cards/cardCanvas.ts
Normal file
|
|
@ -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`
|
||||
8
src/components/cards/cardCopy.ts
Normal file
8
src/components/cards/cardCopy.ts
Normal file
|
|
@ -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.'
|
||||
90
src/lib/cardModels.ts
Normal file
90
src/lib/cardModels.ts
Normal file
|
|
@ -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}`
|
||||
151
src/lib/cardNameFit.ts
Normal file
151
src/lib/cardNameFit.ts
Normal file
|
|
@ -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' }
|
||||
}
|
||||
126
src/lib/cardPhotoTransform.ts
Normal file
126
src/lib/cardPhotoTransform.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
109
src/lib/cardRender.ts
Normal file
109
src/lib/cardRender.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/sw.js',
|
||||
'/campanha/convite/e2e-prewarm',
|
||||
'/',
|
||||
'/cards',
|
||||
]) {
|
||||
await prewarmGet(path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
78
tests/unit/cardModels.unit.spec.ts
Normal file
78
tests/unit/cardModels.unit.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
119
tests/unit/cardNameFit.unit.spec.ts
Normal file
119
tests/unit/cardNameFit.unit.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
125
tests/unit/cardPhotoTransform.unit.spec.ts
Normal file
125
tests/unit/cardPhotoTransform.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
129
tests/unit/cardRender.unit.spec.ts
Normal file
129
tests/unit/cardRender.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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/ })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue