feat(B187): links com aparência de link nas respostas do Sollinha

Estilo de links no markdown do chat (primária + sublinhado + hover/foco),
externos abrem em aba nova; compõe com o next/link interno do B188.
Sem migration; e2e + unit cobrem a superfície.
This commit is contained in:
Francisco Solla 2026-08-10 07:20:44 -03:00
parent 3ca200e7b0
commit 1016668265
5 changed files with 278 additions and 21 deletions

View file

@ -0,0 +1,95 @@
# Impl: Sollinha: links com aparência de link nas respostas
Status: aprovado
Atualizado em: 2026-08-10
Issue: #528
Intenção: docs/plans/sollinha-links-como-links.md
Appetite restante: ~0,5 dia (herdado)
## Leitura da intenção
- **Outcome:** todo link emitido pelo Sollinha numa resposta aparece sublinhado e com a cor primária do tema, distinguível do texto corrido, com hover e foco por teclado visíveis; links externos abrem em aba nova; o resto do markdown permanece como está.
- **O que NÃO negociar:** sem redesenho do bubble/chat; sem trocar o sistema de markdown; **qualquer** link conta (não só internos); sem instalar `@tailwindcss/typography`; navegação interna/clique é do B188 (fora).
- **O que reavaliar:** a hipótese da intenção ("classes `prose` sem efeito") foi **confirmada** — `@tailwindcss/typography` não está no `package.json` e o entry CSS (`styles.css`) só registra `tailwindcss-animate`/`tw-animate-css`/`shadcn/tailwind.css`. O `[button,a]:focus-visible:ring-*` do `BubbleContent` **não cobre** links aninhados no markdown (variantes `[button,a]:` só se aplicam quando o próprio `bubble-content` É um `<a>`/`<button>` via `asChild`; um `<a>` dentro do markdown não recebe nada). A "dica" de estado escuro da intenção é futurologia: o app está `forcedTheme="light"` no `ThemeProvider` e o shell `/campanha` não alterna `.dark` — a proteção é usar tokens (`text-primary`), que já é o que faremos.
## Abordagem recomendada
```mermaid
flowchart LR
R[ReactMarkdown<br/>assistant message] -->|components.a| X[externalLinkTarget]
R -->|classes escopadas| A["<a> interno: text-primary + underline<br/>+ hover decoration-2 + focus ring"]
X -->|http(s):// | N["target=_blank<br/>rel=noopener noreferrer"]
X -->|relativo/mailto| P["<a> padrão (navega no app)"]
```
**Opções consideradas:** A) classes utilitárias escopadas no contêiner do markdown + renderer mínimo só para links externos | B) classe CSS global (`.sollinha-markdown a {…}`) em `styles.css` | C) renderer `components.a` com as classes inline nele
**Recomendação:** **A** — as variantes arbitrárias `[&_a]:…` seguem o padrão já usado no mesmo elemento (`[&_h1]:text-base`, `[&_table]:text-xs`, …), mantêm o estilo 100% escopado ao chat (sem vazar para outras superfícies) e não trocam o sistema de markdown. O renderer existe **apenas** para o aceite "externo em aba nova" (que o CSS não cobre) e é fino: delega a decisão a uma função pura testável.
**Rejeitadas:** **B** porque espalha o contrato de estilo para o CSS global quando o contêiner já carrega todo o estilo do markdown em JSX (dois lugares para a mesma coisa) e o `prose` morto continuaria lá; **C** porque estilizar via renderer duplica o que o CSS escopado faz (o link renderizado também cai dentro do contêiner) e adiciona manipulação de `node` desnecessária.
### Componentes / mudanças
- **`externalLinkTarget`** (`src/lib/ai/markdownLinks.ts`, novo): função pura `(href: string) => { target: '_blank'; rel: 'noopener noreferrer' } | null` — externo = `^https?://` ou `//` (protocol-relative); tudo mais (relativo `/campanha/…`, `mailto:`, `tel:`, vazio) retorna `null`. Segue o precedente de módulo puro pequeno em `src/lib/` (ex.: `sollinhaChatPanelWidth.ts`).
- **`CampaignAIChat.tsx`** (`src/components/campaign/shell/ai/CampaignAIChat.tsx`):
- `components={{ a }}` no `ReactMarkdown`: renderer que aplica `externalLinkTarget(href)` ao `<a>` e nada mais; `node` é descartado do spread (prop não-DOM do react-markdown v10).
- Classes adicionadas ao div do markdown (junto das existentes):
`[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:decoration-2 [&_a]:focus-visible:outline-none [&_a]:focus-visible:ring-2 [&_a]:focus-visible:ring-ring/50`
- `[&_a]:text-primary` usa o token do tema (`#c51414` no claro campaign; ~4,6:1 sobre `--secondary` `#f5f5f4` — passa AA para texto normal); em dark hipotético os tokens flipam sozinhos.
- **Migration:** sem migration.
- **Access / Consent:** N/A — render de cliente, nenhuma escrita/leitura nova.
- **UI:** Impeccable B (encaixe no bubble). Sem novos shells; sem mudar bubble/padding/background. shape (classes) → craft (hover/focus) → critique (contraste + escopo) → polish só se a inspeção apontar.
### Dados → forma
- N/A — aparência apenas; nenhum dado.
## Fases verificáveis
1. **Core** — `src/lib/ai/markdownLinks.ts` + testes unitários (`tests/unit/aiMarkdownLinks.unit.spec.ts`): http/https/`//` → target/rel; `/campanha/…`, `mailto:`, `tel:`, `#anchor`, vazio → `null`.
2. **UI** — renderer `a` + classes no `CampaignAIChat.tsx`; e2e `tests/e2e/campaignAiLinks.e2e.spec.ts` (padrão `campaignAiChatResize`/`campaignAiTranscribe`: `mockAiChat` com SSE contendo `[Ilhéus](/campanha/municipios/ilheus)` e um link externo):
- link interno visível, `color: rgb(197, 20, 20)` e `text-decoration-line` contém `underline`;
- hover → `text-decoration-thickness: 2px`;
- foco por teclado (Tab) → `toBeFocused()` + `box-shadow` ≠ `none` (ring visível);
- link externo → `target="_blank"` e `rel` com `noopener`.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`; PR com `Closes #528`.
## Rabbit holes / Não escopo (engenharia)
- Estilizar o bubble inteiro (padding/cor de fundo) — proibido pela intenção.
- Remover as classes `prose` mortas — débito barato, registrar no capture-review-debts (não nesta entrega).
- Navegação interna sem reload / `router.push` — é o B188 (o renderer não intercepta cliques; só decide `target`/`rel`).
- Sanitizar URLs (`javascript:` etc.) — react-markdown v10 já filtra via `defaultUrlTransform`; fora.
- Instalar `@tailwindcss/typography` — explícito fora de escopo.
- Testar dark theme no e2e — o app está `forcedTheme="light"`; a garantia é via token (revisão visual opcional).
## Riscos e mitigação
| Risco | Mitigação |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Renderer `a` quebra props do react-markdown v10 | Descartar `node` do spread; renderer devolve `<a {...props}>` — os testes e2e provam render real |
| Conflito com B188 (mesmo render de mensagens) | B188 já está em main (renderer `next/link` para `/campanha…`); o renderer deste item **compõe** em vez de substituir: interno → `next/link` preservado, externo → `target/_blank`, resto → âncora pura |
| Contraste da cor primária no bubble `secondary` | `#c51414` sobre `#f5f5f4` ≈ 4,6:1 (AA texto normal); hover não muda a cor, engrossa o sublinhado |
| Classes `[&_a]` vazam para mensagens do usuário | Escopo é só o div do markdown das respostas do assistente (usuário renderiza `span` puro) |
## Débitos deferidos (capture-review-debts, gate humano 2026-08-10)
- **D1 → Issue #556 (B194):** mock SSE v7 duplicado em 4 specs + mock legado em `campaignAiTranscribe` — extrair helper compartilhado em `tests/e2e/fixtures/`.
- **D2 (deferido):** classes `prose`/`dark:prose-invert` mortas no `CampaignAIChat` (plugin `@tailwindcss/typography` ausente). **Gatilho:** qualquer toque futuro no div do markdown do chat (B188+, B194) — remover junto.
- **D3 (deferido, absorvido por #556):** migrar o mock legado do `campaignAiTranscribe` para o formato v7 — executar junto do helper do D1.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (underline + primária + hover/foco + externo em aba nova + markdown intacto)
- [x] Invariantes AGENTS/engineering-standards (sem schema/access/Consent; identificadores em inglês; copy pt-BR)
- [x] Testes previstos: unit de `externalLinkTarget` + e2e de aparência/interação do link no chat
## Decisões de engenharia (self-score)
| Decisão | Recomendação | Rejeitadas |
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------- |
| Mecanismo de estilo | Variantes `[&_a]` no contêiner do markdown (padrão do arquivo) | Classe global no CSS; classes no renderer |
| Externo em nova aba | Função pura `externalLinkTarget` + renderer `a` fino | Renderer com toda a lógica; ignorar o aceite |
| Hover | Engrossa o sublinhado (`decoration-2`), cor mantém | Mudar cor (piora contraste) |
| Foco | `ring-2 ring-ring/50` escopado (BubbleContent não cobre `a` aninhado) | Confiar no ring existente (não se aplica) |
**Self-score decision-quality: 4/5** — decisões baratas e reversíveis documentadas; appetite ~0,5 dia respeitado; rabbit holes nomeados; sem schema; seam limpo para o B188.

View file

@ -3,6 +3,7 @@
import { Bot, Mic, Send, Square, User } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Components } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@ -11,37 +12,40 @@ import { InputGroup, InputGroupButton } from '@/components/ui/input-group'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageAvatar, MessageContent } from '@/components/ui/message'
import { Spinner } from '@/components/ui/Spinner'
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
/**
* B188: app-internal destinations — the B162 link catalog only ever emits
* `/campanha…` paths — navigate through `next/link`, so following a Sollinha
* link keeps the layout (and the conversation) mounted instead of reloading
* the page. Anything else keeps the plain anchor's default behavior.
* B187+B188 — links in the assistant's markdown must look like links: brand
* primary color + always-visible underline, hover thickens the underline and
* keyboard focus shows a ring (BubbleContent's own `[button,a]:focus-visible:*`
* only applies when the bubble content ITSELF is the anchor). External http(s)
* links open in a new tab; app-internal destinations — the B162 link catalog
* only ever emits `/campanha…` paths — navigate through `next/link` (B188), so
* following a Sollinha link keeps the layout (and the conversation) mounted
* instead of reloading the page. Anything else keeps the plain anchor.
*/
const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/
const MarkdownLink = ({
href,
node: _node,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => {
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
const markdownComponents: Components = {
a: ({ node: _node, href, children, ...rest }) => {
const external = typeof href === 'string' ? externalLinkTarget(href) : null
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
return (
<Link href={href} {...rest}>
{children}
</Link>
)
}
return (
<Link href={href} {...props}>
<a href={href} {...rest} {...external}>
{children}
</Link>
</a>
)
}
return (
<a href={href} {...props}>
{children}
</a>
)
},
}
export const CampaignAIChat = ({ className }: { className?: string }) => {
@ -126,11 +130,11 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
return (
<div
key={index}
className="prose prose-sm max-w-none dark:prose-invert [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_table]:text-xs [&_th]:text-xs [&_td]:text-xs [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1"
className="prose prose-sm max-w-none dark:prose-invert [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_table]:text-xs [&_th]:text-xs [&_td]:text-xs [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1 [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:decoration-2 [&_a]:focus-visible:outline-none [&_a]:focus-visible:ring-2 [&_a]:focus-visible:ring-ring"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ a: MarkdownLink }}
components={markdownComponents}
>
{part.text}
</ReactMarkdown>

View file

@ -0,0 +1,18 @@
export type ExternalLinkTarget = {
target: '_blank'
rel: 'noopener noreferrer'
}
/**
* Decides whether a markdown href rendered by the Sollinha chat should open in
* a new tab. Only absolute http(s) URLs (and protocol-relative ones) qualify —
* everything the assistant produces today (`/campanha/...` paths) stays as a
* same-tab anchor, and non-web protocols (`mailto:`, `tel:`, `#anchor`) render
* untouched.
*/
export const externalLinkTarget = (href: string): ExternalLinkTarget | null => {
if (/^https?:\/\//i.test(href) || href.startsWith('//')) {
return { target: '_blank', rel: 'noopener noreferrer' }
}
return null
}

View file

@ -0,0 +1,92 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B187 — links in the Sollinha's markdown answers must look like links: brand
* primary color + always-visible underline, hover thickens the underline,
* keyboard focus shows a ring, and external http(s) URLs open in a new tab.
* The AI endpoint is mocked with a fixed SSE stream whose text carries one
* internal (`/campanha/...`) and one external link — the internal one is
* deliberately LAST so a backward Tab from the input lands on it deterministically.
*/
const ASSISTANT_LINKS =
'Confira [o portal da saúde](https://www.saude.ba.gov.br/) e [o município de Ilhéus](/campanha/municipios/ilheus).'
const mockAiChat = (page: Page) =>
page.route('**/campanha/api/ai-chat', async (route) => {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
// SDK v7 UI-message-stream wire format (start / text-start / text-delta /
// text-end / finish) — a bare legacy `{"type":"text"}` chunk fails the
// client schema and the chat would never settle.
body: [
'data: {"type":"start"}\n\n',
'data: {"type":"text-start","id":"t1"}\n\n',
`data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(ASSISTANT_LINKS)}}\n\n`,
'data: {"type":"text-end","id":"t1"}\n\n',
'data: {"type":"finish","finishReason":"stop"}\n\n',
].join(''),
})
})
const openChatAndSend = async (page: Page) => {
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' })
// Dev-mode defense: a first-hit route compile can trigger a full-page reload
// that wipes the just-typed draft mid-test (production/CI never does), so
// re-send until the answer actually lands.
await expect(async () => {
await input.fill('Mostre um link')
await input.press('Enter')
await expect(page.getByRole('link', { name: 'o município de Ilhéus' })).toBeVisible({
timeout: 10_000,
})
}).toPass({ timeout: 60_000 })
}
test.describe('B187 — links com aparência de link nas respostas do Sollinha', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockAiChat(page)
await page.setViewportSize({ width: 1280, height: 800 })
})
test('links internos e externos ficam visíveis como link e têm hover e foco', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Links Coordenador'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await openChatAndSend(page)
const internalLink = page.getByRole('link', { name: 'o município de Ilhéus' })
const externalLink = page.getByRole('link', { name: 'o portal da saúde' })
// The link is styled like a link: brand primary + always-visible underline.
await expect(internalLink).toHaveCSS('color', 'rgb(197, 20, 20)')
await expect(internalLink).toHaveCSS('text-decoration-line', 'underline')
// Hover reinforces the underline (thicker, decoration-2).
await internalLink.hover()
await expect(internalLink).toHaveCSS('text-decoration-thickness', '2px')
// Keyboard focus is visible: Shift+Tab from the input lands on the last
// link in the conversation and paints the focus ring (box-shadow).
await page.keyboard.press('Shift+Tab')
await expect(internalLink).toBeFocused()
await expect(internalLink).toHaveCSS('box-shadow', /2px/)
// External links open in a new tab with noopener; internal ones stay
// plain same-tab anchors (their click behavior is B188's concern).
await expect(externalLink).toHaveAttribute('target', '_blank')
await expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer')
await expect(internalLink).not.toHaveAttribute('target', '_blank')
})
})

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
/**
* B187: only absolute web URLs get the new-tab treatment in the Sollinha chat;
* every internal `/campanha/...` path and non-web protocol stays a plain
* same-tab anchor (internal navigation behavior belongs to B188).
*/
describe('externalLinkTarget', () => {
it('opens absolute http and https URLs in a new tab with noopener', () => {
expect(externalLinkTarget('https://saude.ba.gov.br')).toEqual({
target: '_blank',
rel: 'noopener noreferrer',
})
expect(externalLinkTarget('http://example.com')).toEqual({
target: '_blank',
rel: 'noopener noreferrer',
})
expect(externalLinkTarget('HTTPS://EXAMPLE.COM')).toEqual({
target: '_blank',
rel: 'noopener noreferrer',
})
})
it('opens protocol-relative URLs in a new tab', () => {
expect(externalLinkTarget('//saude.ba.gov.br')).toEqual({
target: '_blank',
rel: 'noopener noreferrer',
})
})
it('leaves internal campaign paths as same-tab anchors', () => {
expect(externalLinkTarget('/campanha/municipios/ilheus')).toBeNull()
expect(externalLinkTarget('/campanha')).toBeNull()
})
it('leaves non-web protocols and anchors untouched', () => {
expect(externalLinkTarget('mailto:contato@jorgesolla.com.br')).toBeNull()
expect(externalLinkTarget('tel:+557132323232')).toBeNull()
expect(externalLinkTarget('#topo')).toBeNull()
})
it('leaves empty hrefs untouched', () => {
expect(externalLinkTarget('')).toBeNull()
})
})