build(tailwind): migra para o Tailwind 4 com config em CSS

O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rafael César Rocha Monteiro
2026-08-26 01:48:00 -03:00
co-authored by Claude Opus 5
parent 623ae8d686
commit 72d2ed4c35
91 changed files with 1558 additions and 742 deletions
+12
View File
@@ -99,7 +99,19 @@ loop/run-session.sh
# graphify (análise local de knowledge graph)
graphify-out/
# Lockfiles de OUTRO gerenciador. O `pnpm-lock.yaml` é o único versionado, e
# não é preferência de estilo: os pisos de segurança do projeto vivem em
# `pnpm.overrides` do package.json, que o npm e o bun NÃO leem. Um `bun install`
# aqui move `overrides` para o topo do package.json (formato bun/npm), o pnpm
# deixa de enxergá-lo, e o `pnpm-lock.yaml` perde o bloco `overrides` inteiro —
# `path-to-regexp@6.1.0` volta para a árvore com `pnpm audit` ainda verde.
# Aconteceu em 2026-08-26. Ignorar o lock não impede o estrago; impede que ele
# viaje no commit sem ninguém notar.
package-lock.json
bun.lock
bun.lockb
yarn.lock
.e2e-invite.json
.e2e-owner.json
.e2e-invite-url.json
+2 -1
View File
@@ -7,5 +7,6 @@
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./app/globals.css"
}
+1 -1
View File
@@ -19,7 +19,7 @@ quebra no clone fresco é um bug de produto, não um detalhe de ambiente.
## Stack (CONFIRMADO em `package.json`)
Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 3 ·
Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 4 ·
shadcn/ui · Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis ·
Vercel AI Gateway (`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) ·
Zod 4 · Vitest 4 · Playwright 1 · Sentry 10.
+2 -1
View File
@@ -20,7 +20,7 @@ DeskcommCRM é um sistema operacional de vendas open source com agentes de IA na
## Stack canônica
- **Frontend:** Next.js 16 App Router (Turbopack) + React 19 + TypeScript 6 estrito + Tailwind + shadcn/ui (style: `new-york`, neutral)
- **Frontend:** Next.js 16 App Router (Turbopack) + React 19 + TypeScript 6 estrito + Tailwind 4 (config em CSS — ver abaixo) + shadcn/ui (style: `new-york`, neutral)
- **Backend:** Next.js Route Handlers (mesmo repo); workers via `event_log` table + cron
- **DB:** Supabase (Postgres). RLS em toda tabela tenant-aware. Extensions: `uuid-ossp`, `pgcrypto`, `vector`
- **Auth:** Supabase Auth via `@supabase/ssr`. Cookie SameSite=Strict, HttpOnly, Secure
@@ -181,6 +181,7 @@ DeskcommCRM é um sistema operacional de vendas open source com agentes de IA na
| `docs/business-rules/` | Regras de negócio fora do código |
| `docs/research/reference-synthesis.md` | Arquitetura herdada do curso WAHA |
| `tasks/todo.md` | Workflow de construção atual |
| `app/globals.css` | **Tailwind 4 é CSS-first: não existe `tailwind.config.ts`.** Tokens em `:root` / `[data-theme]`, ponte token → utilitário no `@theme inline`, alcance do scanner nos `@source`. Vigiado por `tests/unit/tailwind-tokens.test.ts` |
| `lib/api/wrappers.ts` | `ok()`, `fail()`, tipos `ApiSuccess<T>` / `ApiError` |
| `lib/api/errors.ts` | Códigos de erro canônicos |
| `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) |
@@ -44,7 +44,7 @@ export function AdminSidePanel({ data }: Props) {
<div className="text-xs text-muted-foreground">{maskEmail(contact.email)}</div>
)}
{contact.is_blocked && (
<span className="inline-block rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive">
<span className="inline-block rounded-md bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive">
Bloqueado
</span>
)}
@@ -164,7 +164,7 @@ export function IncidentDetailClient({ id }: IncidentDetailClientProps) {
key={entry.id}
className="flex items-start gap-2 text-xs"
>
<span className="mt-0.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/60 mt-1.5" />
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60 mt-1.5" />
<div className="min-w-0 flex-1">
<span className="font-mono text-muted-foreground">
{entry.action}
@@ -92,7 +92,7 @@ export default async function TenantDetailLayout({
{org?.display_name ?? id}
</h1>
{org?.slug && (
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
<code className="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
{org.slug}
</code>
)}
+1 -1
View File
@@ -246,7 +246,7 @@ export function UserDetailClient({ id }: UserDetailClientProps) {
<div className="space-y-3 max-h-96 overflow-auto pr-1">
{recent_audit.map((entry) => (
<div key={entry.id} className="flex items-start gap-2 text-xs">
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/50" />
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-muted-foreground">
@@ -58,11 +58,11 @@ export function FollowupFlowPicker({ value, onChange, disabled }: Props) {
return (
<label
key={f.id}
className="flex cursor-pointer items-start gap-2 rounded p-1 hover:bg-muted/40"
className="flex cursor-pointer items-start gap-2 rounded-md p-1 hover:bg-muted/40"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-border accent-primary"
className="mt-1 h-4 w-4 rounded-md border-border accent-primary"
checked={checked}
onChange={() => toggle(f.id)}
disabled={disabled}
@@ -89,7 +89,7 @@ export function FunisDoAgente({ funis, value, onChange, cobertura, disabled = fa
id={`funil-${f.id}`}
data-testid={`funil-${f.id}`}
type="checkbox"
className="h-4 w-4 shrink-0 rounded border-border accent-primary"
className="h-4 w-4 shrink-0 rounded-md border-border accent-primary"
checked={marcados.has(f.id)}
onChange={(e) => alternar(f.id, e.target.checked)}
disabled={disabled}
@@ -32,13 +32,13 @@ export function HandoffKeywordsInput({ value, onChange, disabled }: Props) {
return (
<div className="space-y-2">
<Label htmlFor="handoff_kw">Palavras que chamam uma pessoa na hora</Label>
<div className="flex flex-wrap gap-1 rounded border border-border/60 p-2">
<div className="flex flex-wrap gap-1 rounded-md border border-border/60 p-2">
{value.map((kw) => (
<button
key={kw}
type="button"
onClick={() => !disabled && remove(kw)}
className="group flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs hover:bg-destructive/15"
className="group flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs hover:bg-destructive/15"
disabled={disabled}
aria-label={`Remover ${kw}`}
>
@@ -67,7 +67,7 @@ export function HandoffKeywordsInput({ value, onChange, disabled }: Props) {
/>
<button
type="button"
className="rounded border border-border/60 px-3 text-xs hover:bg-muted"
className="rounded-md border border-border/60 px-3 text-xs hover:bg-muted"
onClick={() => add(draft)}
disabled={disabled || draft.trim() === ""}
>
@@ -81,7 +81,7 @@ export function HandoffKeywordsInput({ value, onChange, disabled }: Props) {
type="button"
onClick={() => add(d)}
disabled={disabled}
className="rounded border border-dashed border-border/60 px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted"
className="rounded-md border border-dashed border-border/60 px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted"
>
+ {d}
</button>
@@ -130,7 +130,7 @@ export function RunDetailDrawer({ run, open, onOpenChange }: Props) {
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded border border-border/60 px-2 py-1">
<div className="rounded-md border border-border/60 px-2 py-1">
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="font-mono">{children}</dd>
</div>
@@ -91,20 +91,20 @@ export function RunTrace({
<div className="space-y-3 border-t border-border/60 px-3 py-3 text-xs">
<div>
<p className="mb-1 font-medium text-muted-foreground">Args</p>
<pre className="overflow-x-auto rounded bg-muted/40 p-2 font-mono leading-relaxed">
<pre className="overflow-x-auto rounded-md bg-muted/40 p-2 font-mono leading-relaxed">
{clip(fmtJson(s.args))}
</pre>
</div>
<div>
<p className="mb-1 font-medium text-muted-foreground">Result</p>
<pre className="overflow-x-auto rounded bg-muted/40 p-2 font-mono leading-relaxed">
<pre className="overflow-x-auto rounded-md bg-muted/40 p-2 font-mono leading-relaxed">
{clip(fmtJson(s.result))}
</pre>
</div>
{errMsg ? (
<div>
<p className="mb-1 font-medium text-destructive">Error</p>
<pre className="overflow-x-auto rounded bg-destructive/10 p-2 font-mono leading-relaxed text-destructive">
<pre className="overflow-x-auto rounded-md bg-destructive/10 p-2 font-mono leading-relaxed text-destructive">
{clip(errMsg)}
</pre>
</div>
@@ -295,7 +295,7 @@ export function TestPanel({ agent, draft, published, readOnly }: Props) {
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded border border-border/60 px-2 py-1">
<div className="rounded-md border border-border/60 px-2 py-1">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="font-mono">{children}</p>
</div>
@@ -108,7 +108,7 @@ function FichaCapacidade({
>
<input
type="checkbox"
className="mt-1 h-4 w-4 shrink-0 rounded border-border accent-primary"
className="mt-1 h-4 w-4 shrink-0 rounded-md border-border accent-primary"
checked={marcada}
onChange={onToggle}
disabled={disabled || bloqueada}
@@ -88,7 +88,7 @@ export function TriggerEditor({ value, onChange, disabled }: Props) {
return (
<label
key={ev}
className="flex cursor-pointer items-center gap-2 rounded border border-border/60 px-2 py-1 text-xs"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2 py-1 text-xs"
>
<input
type="checkbox"
@@ -224,7 +224,7 @@ export function TriggerEditor({ value, onChange, disabled }: Props) {
type="button"
onClick={() => toggleWeekday(d.id)}
disabled={disabled}
className={`rounded border px-2 py-1 text-xs ${
className={`rounded-md border px-2 py-1 text-xs ${
active
? "border-primary bg-primary/10 text-primary"
: "border-border/60 text-muted-foreground"
+3 -3
View File
@@ -541,13 +541,13 @@ function ExamplesInput({
return (
<div className="space-y-1">
<Label>Frases de exemplo (opcional)</Label>
<div className="flex flex-wrap gap-1 rounded border border-border/60 p-2">
<div className="flex flex-wrap gap-1 rounded-md border border-border/60 p-2">
{value.map((ex) => (
<button
key={ex}
type="button"
onClick={() => !disabled && remove(ex)}
className="group flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs hover:bg-destructive/15"
className="group flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs hover:bg-destructive/15"
disabled={disabled}
aria-label={`Remover exemplo ${ex}`}
>
@@ -576,7 +576,7 @@ function ExamplesInput({
/>
<button
type="button"
className="rounded border border-border/60 px-3 text-xs hover:bg-muted"
className="rounded-md border border-border/60 px-3 text-xs hover:bg-muted"
onClick={() => add(draft)}
disabled={draft.trim() === ""}
>
@@ -207,7 +207,7 @@ export function ExecucoesDeIa() {
{e.error_message && (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Mensagem técnica do provedor</summary>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted p-2">
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2">
{e.error_message}
{e.http_status ? `\n(código ${e.http_status})` : ""}
</pre>
+4 -4
View File
@@ -73,10 +73,10 @@ export default async function NuvemshopIntegrationPage() {
<CardHeader>
<CardTitle>Integração não configurada</CardTitle>
<CardDescription>
Configure <code className="rounded bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_APP_ID</code>,{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_CLIENT_ID</code> e{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_CLIENT_SECRET</code>{" "}
em <code className="rounded bg-muted px-1 py-0.5 text-xs">.env.local</code> para
Configure <code className="rounded-md bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_APP_ID</code>,{" "}
<code className="rounded-md bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_CLIENT_ID</code> e{" "}
<code className="rounded-md bg-muted px-1 py-0.5 text-xs">NUVEMSHOP_CLIENT_SECRET</code>{" "}
em <code className="rounded-md bg-muted px-1 py-0.5 text-xs">.env.local</code> para
ativar a integração.
</CardDescription>
</CardHeader>
+1 -1
View File
@@ -6,7 +6,7 @@ export default function KanbanLoading() {
<Skeleton className="h-8 w-64 mb-6" />
<div className="flex gap-4 overflow-x-auto">
{Array.from({ length: 5 }).map((_, col) => (
<div key={col} className="w-72 flex-shrink-0 space-y-3">
<div key={col} className="w-72 shrink-0 space-y-3">
<Skeleton className="h-6 w-32" />
{Array.from({ length: 3 }).map((_, card) => (
<Skeleton key={card} className="h-24 w-full" />
+5 -4
View File
@@ -71,7 +71,8 @@ Inter / Geist / Space Grotesk **proibidos** por saturação em training data.
calma operacional sem cair em "saúde mental clichê"; Bricolage tem width axis
útil para hierarquia em headers de inbox.
- **Iconografia recomendada**: Phosphor (duotone). Justificativa na seção Iconografia.
- **CSS variables, não Tailwind classes**: o showcase intencionalmente bypassa
`tailwind.config.ts` para não poluir o config do app antes da decisão final.
Quando a variante for escolhida, migra-se para `theme.extend.colors` com
`var(--accent-N)` e os tokens viram parte do build.
- **CSS variables, não Tailwind classes**: o showcase intencionalmente fica fora
do tema do app para não poluí-lo antes da decisão final. Quando a variante for
escolhida, migra-se para o `@theme inline` de `app/globals.css` com
`var(--accent-N)` e os tokens viram parte do build. (Até o Tailwind 4 o alvo
era `theme.extend.colors` do `tailwind.config.ts`, que não existe mais.)
+215 -10
View File
@@ -1,6 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss" source(none);
/* Alcance do scanner. O v4 acha os arquivos sozinho a partir da raiz do git,
mas isso varreria `docs/` (149 markdowns cheios de exemplo de classe) e
`tests/` — o CSS engordaria com utilitário que nenhuma tela usa, e um
`bg-red-500` escrito numa doc viraria regra publicada. `source(none)` desliga
a descoberta automática e as quatro linhas abaixo repõem EXATAMENTE o
`content` que o `tailwind.config.ts` declarava. Ao criar pasta nova de UI,
acrescente-a aqui — senão as classes dela somem sem erro nenhum. */
@source "../app";
@source "../components";
@source "../lib";
@source "../hooks";
/* Escuro por atributo, não por classe. Equivale ao `darkMode: ["class",
"[data-theme='dark']"]` do v3: a forma de duas casas do v3 NÃO acumula com
`.dark` — ela SUBSTITUI o seletor padrão, e o produto inteiro liga o tema
por `<html data-theme="dark">` (ver `[data-theme="dark"]` mais abaixo e o
`color-scheme` no `@layer base`).
O `:where()` zera a especificidade do seletor de tema, para que `dark:bg-x`
e `bg-x` continuem competindo só pela ordem em que aparecem — sem ele, todo
utilitário `dark:` ganharia um ponto de especificidade a mais e venceria
coisas que no v3 ele não vencia. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* ─────────────────────────────────────────────────────────────────────────
DeskcommCRM — Design System tokens (Sage palette · density Aerada)
@@ -326,8 +348,186 @@
--ring: var(--color-accent-400);
}
/* ─────────────────────────────────────────────────────────────────────────
Ponte token → utilitário (o que era `theme.extend` no `tailwind.config.ts`).
`@theme inline` e não `@theme`: com `inline`, o utilitário recebe o
`var(--color-…)` LITERAL em vez de uma cópia do valor. É o que preserva o
tema — `.bg-bg` vira `background-color: var(--color-bg)` e passa a valer o
que `[data-theme="dark"]` disser naquele ponto da árvore. Com `@theme` sem
`inline`, o valor claro seria assado dentro do utilitário e o tema escuro
pararia de existir.
Os três blocos de token acima (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam onde estão, sem layer e sem renomear. Não é
preferência: `extrairRegua` (lib/branding/contraste.ts) acha o bloco claro
por igualdade EXATA de seletor (`r.seletor === ":root"`), e
`tests/unit/branding-tema-claro-escopavel.test.ts` compara prop a prop os
blocos claro e escuro. Mover qualquer um deles para dentro de `@theme`
quebraria os dois.
Efeito colateral conhecido e inofensivo: como os nomes de token daqui são os
MESMOS do bloco `:root` (o produto já batizava tudo de `--color-*`), o v4
emite, dentro de `@layer theme`, linhas auto-referentes do tipo
`--color-bg: var(--color-bg)`. Elas nunca vencem — declaração sem layer tem
precedência sobre declaração em layer, e o `:root` autoral está fora de
layer. Vigiado por `tests/unit/tailwind-tokens.test.ts`; se um dia alguém
embrulhar o `:root` num `@layer`, o teste reprova e a explicação está aqui.
───────────────────────────────────────────────────────────────────────── */
@theme inline {
/* Foundation */
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-surface-elevated: var(--color-surface-elevated);
--color-overlay: var(--color-overlay);
--color-text: var(--color-text);
--color-text-muted: var(--color-text-muted);
--color-text-subtle: var(--color-text-subtle);
/* Accent — Sage */
--color-accent: var(--color-accent);
--color-accent-foreground: var(--color-accent-fg);
--color-accent-soft: var(--color-accent-soft);
--color-accent-hover: var(--color-accent-hover);
--color-accent-50: var(--color-accent-50);
--color-accent-100: var(--color-accent-100);
--color-accent-200: var(--color-accent-200);
--color-accent-300: var(--color-accent-300);
--color-accent-400: var(--color-accent-400);
--color-accent-500: var(--color-accent-500);
--color-accent-600: var(--color-accent-600);
--color-accent-700: var(--color-accent-700);
--color-accent-800: var(--color-accent-800);
--color-accent-900: var(--color-accent-900);
--color-accent-950: var(--color-accent-950);
/* Neutrals — greige */
--color-neutral-50: var(--color-neutral-50);
--color-neutral-100: var(--color-neutral-100);
--color-neutral-200: var(--color-neutral-200);
--color-neutral-300: var(--color-neutral-300);
--color-neutral-400: var(--color-neutral-400);
--color-neutral-500: var(--color-neutral-500);
--color-neutral-600: var(--color-neutral-600);
--color-neutral-700: var(--color-neutral-700);
--color-neutral-800: var(--color-neutral-800);
--color-neutral-900: var(--color-neutral-900);
--color-neutral-950: var(--color-neutral-950);
/* States */
--color-success: var(--color-success);
--color-success-bg: var(--color-success-bg);
--color-success-fg: var(--color-success-fg);
--color-warning: var(--color-warning);
--color-warning-bg: var(--color-warning-bg);
--color-warning-fg: var(--color-warning-fg);
--color-error: var(--color-error);
--color-error-bg: var(--color-error-bg);
--color-error-fg: var(--color-error-fg);
--color-info: var(--color-info);
--color-info-bg: var(--color-info-bg);
--color-info-fg: var(--color-info-fg);
/* Aliases shadcn (consumers ainda não migrados). Espelham 1:1 o que o
`tailwind.config.ts` mapeava — inclusive `ring`, que apontava para a
parada 500 fixa e NÃO para `--ring` (que muda no escuro). Mantido igual de
propósito: trocar aqui mudaria o anel de foco de toda a aplicação, e isso
é decisão de design, não de migração. */
--color-border: var(--color-border);
--color-border-strong: var(--color-border-strong);
--color-input: var(--color-border);
--color-ring: var(--color-accent-500);
--color-background: var(--color-bg);
--color-foreground: var(--color-text);
--color-primary: var(--color-accent);
--color-primary-foreground: var(--color-accent-fg);
--color-secondary: var(--color-surface-elevated);
--color-secondary-foreground: var(--color-text);
--color-destructive: var(--color-error);
--color-destructive-foreground: #ffffff;
--color-muted: var(--color-surface-elevated);
--color-muted-foreground: var(--color-text-muted);
--color-popover: var(--color-surface);
--color-popover-foreground: var(--color-text);
--color-card: var(--color-surface);
--color-card-foreground: var(--color-text);
/* Tipografia */
--font-sans: var(--font-atkinson), ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, sans-serif;
--font-mono: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, monospace;
/* Espaçamento. Os valores são px LITERAIS (`--space-6` = 24px), não rem — e
essa diferença é carregada por quem consome: ver o cálculo de altura em
`components/inbox/InboxLayout.tsx`, que soma `var(--space-6)` a um `3.5rem`
justamente porque as duas parcelas NÃO estão na mesma unidade.
As paradas fora desta lista (`p-7`, `gap-1.5`, `mt-14`…) continuam
existindo: o v4 as calcula pelo multiplicador `--spacing` (0.25rem), do
mesmo jeito que a escala default do v3 as fornecia. */
--spacing-0: var(--space-0);
--spacing-1: var(--space-1);
--spacing-2: var(--space-2);
--spacing-3: var(--space-3);
--spacing-4: var(--space-4);
--spacing-5: var(--space-5);
--spacing-6: var(--space-6);
--spacing-8: var(--space-8);
--spacing-10: var(--space-10);
--spacing-12: var(--space-12);
--spacing-16: var(--space-16);
--spacing-20: var(--space-20);
--spacing-24: var(--space-24);
--spacing-32: var(--space-32);
/* Raio */
--radius-none: var(--radius-none);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-full: var(--radius-full);
/* Sombra */
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
/* Motion */
--ease-out: var(--ease-out);
--ease-in-out: var(--ease-in-out);
--ease-spring: var(--ease-spring);
}
/* `duration-*` não tem espaço de tema no v4 (o `transitionDuration` do v3 não
tem equivalente em `@theme`), então o apelido vira utilitário explícito. */
@utility duration-fast {
transition-duration: var(--duration-fast);
}
@utility duration-base {
transition-duration: var(--duration-base);
}
@utility duration-slow {
transition-duration: var(--duration-slow);
}
@layer base {
* {
/* O preflight do v4 não pinta mais borda por padrão (`border: 0 solid`, cor
= `currentColor`), então os ~300 `class="border"` sem cor desta base
dependem inteiramente desta regra. Ela já existia no v3 pelo mesmo motivo;
o que mudou é que os pseudo-elementos entraram na lista — o preflight do
v4 os zera junto com o elemento, e sem eles um `::before` com `border`
desenharia na cor do texto. */
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border);
}
@@ -396,15 +596,20 @@
}
/* Modo de alto contraste do sistema (Windows High Contrast / forced-colors).
Medido antes de escrever: 0 ocorrências de `forced-colors` no repo inteiro
e, em `app/ components/ lib/`, 18 arquivos que matam `outline`
(`outline-none` / `outline: none` / outline transparente) — 14 deles
desenhando o foco com `ring-*`, que é `box-shadow`, e o forced-colors APAGA
box-shadow. (Contagem com este arquivo de fora: incluí-lo devolvia 19/15,
porque o grep achava as palavras deste próprio comentário.) Nesses 14, o
O problema: arquivos que matam o `outline` e desenham o foco com `ring-*`
— que é `box-shadow`, e o forced-colors APAGA box-shadow. Neles o
indicador de foco some por completo naquele modo: quem navega por teclado
perde a posição do cursor. É WCAG 2.4.7 nível A, não preferência.
Quantos são hoje (comando, e não número — número envelhece; a migração
para o Tailwind 4 já invalidou o que estava escrito aqui, porque
`outline-none` virou `outline-hidden` e o grep antigo passou a devolver
zero):
grep -rlE 'outline-hidden|outline:\s*none|outline-\[?transparent' \
app components lib --include='*.tsx' --include='*.ts' | wc -l
# …| xargs grep -lE 'ring-[a-z0-9]' | wc -l ← os que dependem de ring
`Highlight` é cor de sistema (a paleta do usuário), a única que o modo não
substitui. `!important` porque o que precisa ser vencido é justamente o
`outline: none`/`outline: transparent` desses 14 arquivos. */
+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
+1 -1
View File
@@ -98,7 +98,7 @@ export function ImpersonateButton({
Você está prestes a entrar como o tenant{" "}
<span className="font-semibold text-foreground">{displayName}</span>.
Toda ação será registrada com a flag{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
<code className="rounded-md bg-muted px-1 py-0.5 text-xs">
acting_as_platform_admin
</code>
. A sessão expira em 1 hora. Confirma?
+2 -2
View File
@@ -86,7 +86,7 @@ function MultiSelectPopover({
{selected.length > 0 && (
<button
onClick={() => { onClear(); setSearch(""); }}
className="w-full rounded px-2 py-1 text-left text-xs text-muted-foreground hover:bg-accent"
className="w-full rounded-md px-2 py-1 text-left text-xs text-muted-foreground hover:bg-accent"
>
Limpar seleção ({selected.length})
</button>
@@ -101,7 +101,7 @@ function MultiSelectPopover({
<button
key={o.value}
onClick={() => onToggle(o.value)}
className={`w-full rounded px-2 py-1.5 text-left text-sm transition-colors ${
className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
selected.includes(o.value)
? "bg-accent font-medium"
: "hover:bg-accent/50"
+1 -1
View File
@@ -46,7 +46,7 @@ export function AlertItem({ alert }: AlertItemProps) {
<button
type="button"
onClick={() => router.push(alert.link)}
className="flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
@@ -15,11 +15,11 @@ export function DBAOnlyNotice() {
</p>
<p className="text-sm leading-relaxed text-blue-800">
Conforme Spec 01 §3.4 T-04: adição, remoção ou alteração de{" "}
<code className="rounded bg-blue-100 px-1 font-mono text-xs">
<code className="rounded-md bg-blue-100 px-1 font-mono text-xs">
platform_admins
</code>{" "}
é feita exclusivamente via SQL pelo DBA, com nota explicativa em{" "}
<code className="rounded bg-blue-100 px-1 font-mono text-xs">
<code className="rounded-md bg-blue-100 px-1 font-mono text-xs">
api_audit_log
</code>
. Esta página é informativa e read-only — nenhum botão de modificação
+1 -1
View File
@@ -73,7 +73,7 @@ export function HealthCard({
{/* Header row */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-muted-foreground">
<span className="w-5 h-5 flex-shrink-0" aria-hidden>
<span className="w-5 h-5 shrink-0" aria-hidden>
{icon}
</span>
<span className="text-xs font-semibold uppercase tracking-wider leading-none">
+1 -1
View File
@@ -59,7 +59,7 @@ export function SystemPromptEditor({ value, onChange, disabled }: Props) {
{SYSTEM_PROMPT_PLACEHOLDERS.map((p) => (
<li key={p.token} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<code className="rounded bg-background px-1.5 py-0.5 text-[11px]">{p.token}</code>
<code className="rounded-md bg-background px-1.5 py-0.5 text-[11px]">{p.token}</code>
<Button
type="button"
size="sm"
+2 -2
View File
@@ -147,14 +147,14 @@ export function MfaEnrollModal({ motivo = "obrigatorio" }: { motivo?: "obrigator
alt="QR code para configurar autenticador"
width={240}
height={240}
className="rounded border border-border bg-white p-2"
className="rounded-md border border-border bg-white p-2"
/>
</div>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">
Não consegue escanear? Digite o código manual
</summary>
<code className="mt-2 block break-all rounded bg-muted p-2 font-mono">
<code className="mt-2 block break-all rounded-md bg-muted p-2 font-mono">
{enrollState.secret}
</code>
</details>
+2 -2
View File
@@ -83,7 +83,7 @@ export function RecoveryCodesPanel({ codes, onAcknowledge }: RecoveryCodesPanelP
{codes.map((c, i) => (
<div
key={i}
className="rounded border border-border bg-background px-3 py-2 text-center font-mono text-sm tracking-widest"
className="rounded-md border border-border bg-background px-3 py-2 text-center font-mono text-sm tracking-widest"
>
{c}
</div>
@@ -105,7 +105,7 @@ export function RecoveryCodesPanel({ codes, onAcknowledge }: RecoveryCodesPanelP
type="checkbox"
checked={acked}
onChange={(e) => setAcked(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border"
className="mt-0.5 h-4 w-4 rounded-md border-border"
/>
<span>Salvei meus códigos em local seguro.</span>
</label>
+1 -1
View File
@@ -120,7 +120,7 @@ export function TOTPInput({
onFocus={(e) => e.currentTarget.select()}
className={cn(
"h-12 w-10 rounded-md border border-input bg-background text-center font-mono text-lg tabular-nums",
"shadow-sm outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/30",
"shadow-sm outline-hidden transition focus:border-ring focus:ring-2 focus:ring-ring/30",
hasError && "border-destructive focus:ring-destructive/30",
disabled && "opacity-50",
)}
@@ -33,7 +33,7 @@ function ParaColar({ rotulo, valor }: { rotulo: string; valor: string | null })
{rotulo}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">{valor}</code>
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-2 py-1.5 text-xs">{valor}</code>
<Button
size="sm"
variant="outline"
@@ -58,7 +58,7 @@ function ParaColar({ rotulo, valor }: { rotulo: string; valor: string }) {
{rotulo}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">{valor}</code>
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-2 py-1.5 text-xs">{valor}</code>
<Button
size="sm"
variant="outline"
+1 -1
View File
@@ -63,7 +63,7 @@ export function PreviaDaDefinicao({
width={320}
height={180}
unoptimized
className="mb-2 h-auto w-full rounded"
className="mb-2 h-auto w-full rounded-md"
/>
)}
{cabecalho && <p className="mb-1 text-sm font-semibold">{cabecalho}</p>}
+1 -1
View File
@@ -39,7 +39,7 @@ function Preview({ preview }: { preview: TemplatePreview }) {
/^\{\{\w+\}\}$/.test(parte) ? (
<span
key={i}
className="rounded bg-primary/10 px-1.5 py-0.5 font-mono text-xs font-medium text-primary ring-1 ring-primary/20"
className="rounded-md bg-primary/10 px-1.5 py-0.5 font-mono text-xs font-medium text-primary ring-1 ring-primary/20"
>
{parte.slice(2, -2)}
</span>
@@ -445,7 +445,7 @@ export function TemplatesParceiroClient() {
<span className="font-mono text-sm">{t.name}</span>
<span className="text-xs text-muted-foreground">{t.language}</span>
{t.category && (
<span className="rounded bg-muted px-1.5 text-[10px] uppercase text-muted-foreground">
<span className="rounded-md bg-muted px-1.5 text-[10px] uppercase text-muted-foreground">
{t.category}
</span>
)}
@@ -490,7 +490,7 @@ export function TemplatesParceiroClient() {
{c.botoes.length > 0 && (
<div className="flex flex-wrap gap-1">
{c.botoes.map((b, i) => (
<span key={i} className="rounded border border-border px-1.5 text-[11px]">
<span key={i} className="rounded-md border border-border px-1.5 text-[11px]">
{b.texto} <span className="text-muted-foreground">({b.tipo})</span>
</span>
))}
+5 -5
View File
@@ -108,26 +108,26 @@ export function ImportContactsDialog({ open, onOpenChange }: Props) {
{resumo && (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-sm">
<span className="rounded bg-surface px-2 py-1">
<span className="rounded-md bg-surface px-2 py-1">
{resumo.total_linhas} linha(s) lidas
</span>
<span className="rounded px-2 py-1 font-medium">
<span className="rounded-md px-2 py-1 font-medium">
{resumo.imported} importado(s)
</span>
{resumo.skipped_duplicates > 0 && (
<span className="rounded bg-surface px-2 py-1">
<span className="rounded-md bg-surface px-2 py-1">
{resumo.skipped_duplicates} já existente(s)
</span>
)}
{resumo.errors.length > 0 && (
<span className="rounded px-2 py-1 font-medium">
<span className="rounded-md px-2 py-1 font-medium">
{resumo.errors.length} com erro
</span>
)}
</div>
{resumo.errors.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-border p-2 text-sm">
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border p-2 text-sm">
{resumo.errors.map((err) => (
<p key={err.linha}>
Linha {err.linha}: {err.motivo}
+1 -1
View File
@@ -154,7 +154,7 @@ function MarcarProximoPasso({ demandaId, onPronto }: { demandaId: string; onPron
placeholder="O que acontece a seguir?"
aria-label="Próximo passo desta demanda"
data-testid="campo-proximo-passo"
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-hidden focus:ring-1 focus:ring-ring"
/>
<div className="flex gap-1.5">
<Button
+2 -2
View File
@@ -272,7 +272,7 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
type="button"
onClick={onCancelarResposta}
aria-label={t("Cancelar resposta")}
className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
className="rounded-md p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
@@ -339,7 +339,7 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
}
className={cn(
"min-h-9 max-h-40 flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring",
"placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring",
)}
disabled={mode === "note" ? isDisabled : respostaBarrada}
aria-label="Mensagem"
+6 -3
View File
@@ -261,9 +261,12 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
// parcialmente abaixo da borda, atrapalhando justo na hora de escrever.
//
// As duas parcelas NÃO estão na mesma unidade, e por isso o padding entra pelo
// token e não como `3rem`: o `tailwind.config.ts` remapeia a escala de spacing
// para `var(--space-N)` — `--space-6` é `24px` LITERAL (app/globals.css) —, mas
// não remapeia o `14`, que segue sendo `3.5rem` de verdade. Escrever a soma como
// token e não como `3rem`: o `@theme inline` de `app/globals.css` remapeia a
// escala de spacing para `var(--space-N)` — `--space-6` é `24px` LITERAL —, mas
// não remapeia o `14`, que o Tailwind 4 calcula pelo multiplicador `--spacing`
// e segue sendo `3.5rem` de verdade. (Até o Tailwind 4 quem remapeava era o
// `tailwind.config.ts`; o arquivo não existe mais, o efeito é o mesmo.)
// Escrever a soma como
// `6.5rem` só acerta enquanto a raiz for 16px; com acessibilidade de fonte maior
// ou menor o composer sai da tela de novo. Pelo token, a conta se auto-corrige
// se a escala de espaçamento mudar.
+1 -1
View File
@@ -141,7 +141,7 @@ export function JanelaFechadaAviso({
aria-label="Modelo aprovado"
className={cn(
"h-9 min-w-[16rem] flex-1 rounded-md border border-input bg-background px-2 text-sm",
"focus:outline-none focus:ring-1 focus:ring-ring",
"focus:outline-hidden focus:ring-1 focus:ring-ring",
)}
>
<option value="">Escolha um modelo aprovado…</option>
+3 -3
View File
@@ -82,7 +82,7 @@ export function MessageBubble({ message, debugCitations, onResponder, citada }:
onClick={() => onResponder(message)}
aria-label="Responder a esta mensagem"
className={cn(
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
"rounded-md p-1 text-muted-foreground transition-opacity hover:bg-muted",
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
//
// A primeira versão era `opacity-0` + `group-hover`, copiando o
@@ -124,7 +124,7 @@ export function MessageBubble({ message, debugCitations, onResponder, citada }:
{citada && (
<div
className={cn(
"mb-1 rounded border-l-2 px-2 py-1 text-xs",
"mb-1 rounded-md border-l-2 px-2 py-1 text-xs",
isOutbound
? "border-primary-foreground/50 bg-primary-foreground/10"
: "border-primary bg-background/60",
@@ -229,7 +229,7 @@ export function MessageBubble({ message, debugCitations, onResponder, citada }:
onClick={() => onResponder(message)}
aria-label="Responder a esta mensagem"
className={cn(
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
"rounded-md p-1 text-muted-foreground transition-opacity hover:bg-muted",
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
//
// A primeira versão era `opacity-0` + `group-hover`, copiando o
+1 -1
View File
@@ -31,7 +31,7 @@ export function ShortcutsHelpDialog({ open, onOpenChange }: Props) {
{BINDINGS.map((b) => (
<li key={b.keys} className="flex items-center justify-between">
<span className="text-muted-foreground">{b.description}</span>
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-xs">
<kbd className="rounded-md border border-border bg-muted px-2 py-0.5 font-mono text-xs">
{b.keys}
</kbd>
</li>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ConversaSlot({ conversa }: { conversa: Lead["conversa"] }) {
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: MouseEvent) => e.stopPropagation()}
className={cn(
"group/conversa mt-1 flex items-center gap-1.5 rounded px-1 py-0.5 text-[11px]",
"group/conversa mt-1 flex items-center gap-1.5 rounded-md px-1 py-0.5 text-[11px]",
"text-text-muted transition-colors hover:bg-muted hover:text-foreground",
)}
title="Abrir esta conversa no Inbox"
+2 -2
View File
@@ -50,7 +50,7 @@ export function NextActionSlot({
// trata — "Aprovar" sozinho, lido fora de contexto, não decide nada.
aria-label={`Aprovar: ${label}`}
className={cn(
"rounded px-1.5 py-0.5 text-[11px] font-medium transition-colors",
"rounded-md px-1.5 py-0.5 text-[11px] font-medium transition-colors",
"bg-accent/10 text-accent hover:bg-accent/20",
"disabled:opacity-50",
)}
@@ -63,7 +63,7 @@ export function NextActionSlot({
onClick={(e) => decide(e, "dismiss")}
aria-label={`Ignorar: ${label}`}
className={cn(
"rounded px-1.5 py-0.5 text-[11px] transition-colors",
"rounded-md px-1.5 py-0.5 text-[11px] transition-colors",
"text-text-muted hover:bg-surface-muted hover:text-text",
"disabled:opacity-50",
)}
+2 -2
View File
@@ -65,7 +65,7 @@ export function ReactivationSlot({
onClick={(e) => decide(e, "accept")}
aria-label="Retomar contato com este negócio"
className={cn(
"rounded px-1.5 py-0.5 text-[11px] font-medium transition-colors",
"rounded-md px-1.5 py-0.5 text-[11px] font-medium transition-colors",
"bg-warning-fg/10 text-warning-fg hover:bg-warning-fg/20",
"disabled:opacity-50",
)}
@@ -81,7 +81,7 @@ export function ReactivationSlot({
// de negócio esquecido.
aria-label="Encerrar: não retomar este negócio"
className={cn(
"rounded px-1.5 py-0.5 text-[11px] transition-colors",
"rounded-md px-1.5 py-0.5 text-[11px] transition-colors",
"text-text-muted hover:bg-surface-muted hover:text-text",
"disabled:opacity-50",
)}
+1 -1
View File
@@ -54,7 +54,7 @@ export function ScoreSlot({ probability, band, reason, factors }: ScoreSlotProps
// O rótulo acessível carrega o número E a faixa: "72%" lido sozinho
// não diz se é bom ou ruim para quem não vê a barra.
aria-label={`Probabilidade ${probability}%, ${bandLabel(band)}. Ver o porquê.`}
className="flex min-w-0 items-center gap-2 rounded text-left"
className="flex min-w-0 items-center gap-2 rounded-md text-left"
>
<span
className="h-[3px] w-16 shrink-0 overflow-hidden rounded-full bg-border"
+1 -1
View File
@@ -110,7 +110,7 @@ function Resultados({ aoEscolher }: { aoEscolher: () => void }) {
onChange={(e) => aoDigitar(e.target.value)}
onKeyDown={aoTeclar}
placeholder="Buscar telas do sistema…"
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className="h-12 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground"
/>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ export function SearchTrigger() {
>
<MagnifyingGlass size={14} aria-hidden />
<span className="hidden md:inline">Buscar...</span>
<kbd className="ml-2 hidden md:inline rounded border bg-muted px-1.5 py-0.5 text-[10px]">⌘K</kbd>
<kbd className="ml-2 hidden md:inline rounded-md border bg-muted px-1.5 py-0.5 text-[10px]">⌘K</kbd>
</Button>
<CommandPalette open={open} onOpenChange={setOpen} />
</>
+1 -1
View File
@@ -13,7 +13,7 @@ const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-full border px-3 py-0.5",
"text-xs font-medium leading-5",
"transition-colors duration-fast ease-out",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2",
"focus:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2",
].join(" "),
{
variants: {
+1 -1
View File
@@ -21,7 +21,7 @@ const buttonVariants = cva(
"rounded-sm font-medium",
"transition-[background-color,border-color,color,box-shadow,transform]",
"duration-fast ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"active:translate-y-px",
+1 -1
View File
@@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
+4 -4
View File
@@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
@@ -84,7 +84,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
@@ -100,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
@@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
+1 -1
View File
@@ -12,7 +12,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
"text-sm text-text placeholder:text-text-muted",
"transition-[border-color,box-shadow] duration-fast ease-out",
"hover:border-border-strong",
"focus-visible:outline-none focus-visible:border-accent-500 focus-visible:ring-2 focus-visible:ring-accent-soft",
"focus-visible:outline-hidden focus-visible:border-accent-500 focus-visible:ring-2 focus-visible:ring-accent-soft",
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-text",
"disabled:cursor-not-allowed disabled:opacity-55",
"aria-[invalid=true]:border-error aria-[invalid=true]:focus-visible:ring-error-bg",
+1 -1
View File
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
+2 -2
View File
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
+1 -1
View File
@@ -64,7 +64,7 @@ const SheetContent = React.forwardRef<
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
+1 -1
View File
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
+2 -2
View File
@@ -39,7 +39,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
+1 -1
View File
@@ -13,7 +13,7 @@ const Textarea = React.forwardRef<
"text-sm leading-relaxed text-text placeholder:text-text-muted",
"transition-[border-color,box-shadow] duration-fast ease-out",
"hover:border-border-strong",
"focus-visible:outline-none focus-visible:border-accent-500 focus-visible:ring-2 focus-visible:ring-accent-soft",
"focus-visible:outline-hidden focus-visible:border-accent-500 focus-visible:ring-2 focus-visible:ring-accent-soft",
"disabled:cursor-not-allowed disabled:opacity-55",
"aria-[invalid=true]:border-error aria-[invalid=true]:focus-visible:ring-error-bg",
className,
+4 -1
View File
@@ -111,7 +111,10 @@ Definidas em `:root` por `app/design/lib/variant-context.tsx` ou `app/globals.cs
### 2. Tailwind classes (preferido em componentes React)
Mapeadas em `tailwind.config.ts` para consumir as mesmas CSS vars:
Mapeadas no bloco `@theme inline` de `app/globals.css` para consumir as mesmas
CSS vars. (Era `tailwind.config.ts` até o Tailwind 4; o arquivo não existe
mais — a ponte token → utilitário vive no próprio CSS, logo abaixo dos blocos
`:root` / `[data-theme]`.)
```tsx
<div className="p-5 rounded-sm border border-border shadow-sm transition-colors duration-150 ease-[cubic-bezier(0.2,0,0,1)]">
+1 -1
View File
@@ -140,7 +140,7 @@ Outro exemplo: **header de view**.
## Como consumir em código
```tsx
// Tailwind (mapeado em tailwind.config.ts → theme.fontSize)
// Tailwind (mapeado no `@theme inline` de app/globals.css)
<h1 className="text-display-md font-bold tracking-tight">Inbox</h1>
<p className="text-body-sm text-muted">42 abertas</p>
<span className="font-mono text-mono-data tabular-nums">#12.443</span>
@@ -19,7 +19,9 @@ date: 2026-04-28
| `tablet` | 768–1023px | "stacked" — layouts mudam |
| `mobile` | 360–767px | atendente em deslocamento; somente leitura/quick-reply em algumas telas |
Tailwind: `lg:` ≥1024, `xl:` ≥1280, `2xl:` ≥1536. Customizar `screens` no `tailwind.config.ts` se necessário pra tablet preciso.
Tailwind: `lg:` ≥1024, `xl:` ≥1280, `2xl:` ≥1536. Para um ponto de corte próprio
de tablet, declare `--breakpoint-<nome>` no `@theme inline` de `app/globals.css`
(no Tailwind 4 não há mais `screens` em config JS).
## Princípios
+36
View File
@@ -848,3 +848,39 @@ Toda spec que usa o helper sobe o teto (240 s em quatro delas, 90 s em uma) —
isso não está escrito em lugar nenhum, e quem adota o helper sem subir o teto vê
dois testes alheios estourarem sem call log de locator. Se você for adotar o
helper numa spec nova: `test.describe.configure({ timeout: 120_000 })`.
## A migração para o Tailwind 4 mudou 252 classes que ninguém sabia estarem mortas (2026-08-26)
Origem: subir `tailwindcss` de 3.4 para 4, com o config saindo do
`tailwind.config.ts` (deletado) para um `@theme inline` em `app/globals.css`.
**O achado que a migração destapou.** No v3, um modificador de opacidade sobre
cor declarada como `var(--…)` sem o marcador `<alpha-value>` fazia o Tailwind
**não emitir a regra** — a classe simplesmente não existia no CSS, em silêncio.
Todo token deste produto é `var(--color-*)`, então **62 classes distintas em 252
usos** eram letra morta: `bg-destructive/10` num aviso de erro não pintava fundo
nenhum, `border-destructive/30` caía na cor neutra da regra global de borda,
`text-muted-foreground/60` herdava a cor do pai. O v4 resolve opacidade por
`color-mix()`, que funciona com qualquer cor — então **as 252 passaram a pintar
o que quem escreveu queria desde o começo**.
Medido com o v3 real, e não deduzido: um `tailwindcss@3.4.19` de descarte,
alimentado com 7 classes, emitiu **4** — nenhuma das 3 com barra.
| caso | prioridade | estado |
|---|---|---|
| Tokens resolvem em claro e escuro depois do `@theme inline` | `[P0]` | **PASS**, medido por `getComputedStyle` em `tests/sonda-tailwind-4.ts`: `--color-bg` = `#faf9f6` claro / `#161510` escuro, e `body` acompanha |
| A auto-referência do `@theme inline` (`--color-bg: var(--color-bg)`) não vence o `:root` autoral | `[P0]` | **PASS.** A teoria é de cascata (sem layer vence layer); a medida é a linha acima. Congelado em `tests/unit/tailwind-tokens.test.ts`, que reprova se alguém embrulhar `:root` num `@layer` |
| `class="border"` sem cor continua na cor de borda do produto, e não em `currentColor` | `[P0]` | **PASS**: `rgb(231,227,218)` (claro) e `rgb(51,49,42)` (escuro) — os dois são o `--color-border` do tema |
| As classes de opacidade revividas pintam de verdade | `[P1]` | **PASS parcial**: `bg-muted/40` medido em elemento real do onboarding, com alfa `0.4` nos dois temas. As outras 61 estão no CSS construído, mas **não foram medidas uma a uma na tela** |
| Onboarding completo (6 passos) em claro e escuro, instalação fresca | `[P0]` | **PASS**, 0 erro de console. Capturas em `evidence/tailwind-4/` |
| Telas internas (`/app`, kanban, inbox, contatos) | — | **NÃO COBERTO.** Numa instalação fresca todas redirecionam para `/onboarding/welcome`; alcançá-las pede concluir o onboarding, o que pede WAHA e chave de IA. A sonda registra o redirecionamento em vez de fingir cobertura |
| O efeito visual das 252 revividas foi *revisto por um designer* | — | **NÃO MEDIDO.** A migração provou que passaram a pintar; não provou que cada uma pinta o que a tela precisa. Onde a intenção original estava errada, o erro agora está visível |
**Armadilha que custou duas medições falsas.** Sonda que injeta `<div
class="p-7">` por JavaScript não mede nada: a classe nunca esteve na fonte, o
scanner nunca a viu, e o zero medido é artefato da sonda. Pelo mesmo motivo, o
primeiro elemento com `class="border"` da tela de login é o `<input>`
autofocado — ele casa `focus-visible:border-accent-500` e devolve a cor do
foco. A sonda só mede elemento real, e pula elemento em foco e elemento que já
traga classe de cor própria.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+430
View File
@@ -0,0 +1,430 @@
{
"tokens/claro": {
"--color-bg": "#faf9f6",
"--color-surface": "#fff",
"--color-surface-elevated": "#f5f3ee",
"--color-text": "#1c1a16",
"--color-text-muted": "#5d594f",
"--color-border": "#e7e3da",
"--color-accent": "#506d48",
"--color-accent-500": "#67885d",
"--color-error": "#a94a3c",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #14120e0f, 0 2px 4px -1px #14120e0a",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(250, 249, 246)",
"__body_color": "rgb(28, 26, 22)"
},
"01-login/claro": {
"url": "http://localhost:3001/login"
},
"01-login/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
}
},
"02-onboarding-welcome/claro": {
"url": "http://localhost:3001/onboarding/welcome",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"02-onboarding-welcome/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"03-onboarding-funil/claro": {
"url": "http://localhost:3001/onboarding/funil",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"03-onboarding-funil/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"04-onboarding-equipe/claro": {
"url": "http://localhost:3001/onboarding/invite-team",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"04-onboarding-equipe/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"05-onboarding-ia/claro": {
"url": "http://localhost:3001/onboarding/setup-ai",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"05-onboarding-ia/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"06-onboarding-whatsapp/claro": {
"url": "http://localhost:3001/onboarding/connect-whatsapp",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
}
},
"06-onboarding-whatsapp/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
}
},
"07-conta/claro": {
"url": "http://localhost:3001/onboarding/welcome",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"07-conta/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"08-seguranca/claro": {
"url": "http://localhost:3001/onboarding/welcome",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
},
"rounded-md": {
"valor": "8px"
}
},
"08-seguranca/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
},
"rounded-md": {
"valor": "8px"
}
},
"09-legal-privacidade/claro": {
"url": "http://localhost:3001/legal/privacy",
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.9643 0.000209212 0.00699568 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(231, 227, 218)",
"corDoToken": "#e7e3da"
}
},
"09-legal-privacidade/escuro": {
"tokens": {
"--color-bg": "#161510",
"--color-surface": "#1d1c17",
"--color-surface-elevated": "#272620",
"--color-text": "#f5f4ef",
"--color-text-muted": "#8e8b7f",
"--color-border": "#33312a",
"--color-accent": "#82a077",
"--color-accent-500": "#67885d",
"--color-error": "#c87263",
"--radius-md": "8px",
"--shadow-md": "0 4px 12px -2px #00000073, 0 2px 4px -1px #0000004d",
"--space-6": "24px",
"--duration-fast": ".12s",
"__body_bg": "rgb(22, 21, 16)",
"__body_color": "rgb(245, 244, 239)"
},
"bg-muted/40": {
"ocorrencias": 1,
"valor": "oklab(0.267546 -0.00175017 0.0107598 / 0.4)",
"pinta": true,
"temAlfa": true
},
"border (sem cor)": {
"ocorrencias": 1,
"valor": "rgb(51, 49, 42)",
"corDoToken": "#33312a"
}
},
"erros-de-console": []
}
+2 -2
View File
@@ -90,6 +90,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
@@ -101,14 +102,13 @@
"@types/react-dom": "^19.2.4",
"@vercel/config": "^0.6.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^9.12.0",
"eslint-config-next": "^16.2.10",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.13",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.12",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
+351 -431
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,8 +1,10 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
// Tailwind v4: um plugin só. O `autoprefixer` saiu porque o v4 já faz o
// prefixo por dentro (Lightning CSS) — mantê-lo aqui é trabalho repetido
// sobre um CSS que já veio prefixado.
"@tailwindcss/postcss": {},
},
};
-198
View File
@@ -1,198 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
// Strategy: attribute selector to allow runtime swap via <html data-theme="dark">.
darkMode: ["class", "[data-theme='dark']"],
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
// Foundation
bg: "var(--color-bg)",
surface: {
DEFAULT: "var(--color-surface)",
elevated: "var(--color-surface-elevated)",
},
overlay: "var(--color-overlay)",
text: {
DEFAULT: "var(--color-text)",
muted: "var(--color-text-muted)",
subtle: "var(--color-text-subtle)",
},
// Accent — Sage scale
accent: {
DEFAULT: "var(--color-accent)",
foreground: "var(--color-accent-fg)",
soft: "var(--color-accent-soft)",
hover: "var(--color-accent-hover)",
50: "var(--color-accent-50)",
100: "var(--color-accent-100)",
200: "var(--color-accent-200)",
300: "var(--color-accent-300)",
400: "var(--color-accent-400)",
500: "var(--color-accent-500)",
600: "var(--color-accent-600)",
700: "var(--color-accent-700)",
800: "var(--color-accent-800)",
900: "var(--color-accent-900)",
950: "var(--color-accent-950)",
},
// Neutrals — greige
neutral: {
50: "var(--color-neutral-50)",
100: "var(--color-neutral-100)",
200: "var(--color-neutral-200)",
300: "var(--color-neutral-300)",
400: "var(--color-neutral-400)",
500: "var(--color-neutral-500)",
600: "var(--color-neutral-600)",
700: "var(--color-neutral-700)",
800: "var(--color-neutral-800)",
900: "var(--color-neutral-900)",
950: "var(--color-neutral-950)",
},
// States
success: {
DEFAULT: "var(--color-success)",
bg: "var(--color-success-bg)",
fg: "var(--color-success-fg)",
},
warning: {
DEFAULT: "var(--color-warning)",
bg: "var(--color-warning-bg)",
fg: "var(--color-warning-fg)",
},
error: {
DEFAULT: "var(--color-error)",
bg: "var(--color-error-bg)",
fg: "var(--color-error-fg)",
},
info: {
DEFAULT: "var(--color-info)",
bg: "var(--color-info-bg)",
fg: "var(--color-info-fg)",
},
// shadcn aliases (compat com componentes ainda não migrados)
border: "var(--color-border)",
"border-strong": "var(--color-border-strong)",
input: "var(--color-border)",
ring: "var(--color-accent-500)",
background: "var(--color-bg)",
foreground: "var(--color-text)",
primary: {
DEFAULT: "var(--color-accent)",
foreground: "var(--color-accent-fg)",
},
secondary: {
DEFAULT: "var(--color-surface-elevated)",
foreground: "var(--color-text)",
},
destructive: {
DEFAULT: "var(--color-error)",
foreground: "#ffffff",
},
muted: {
DEFAULT: "var(--color-surface-elevated)",
foreground: "var(--color-text-muted)",
},
popover: {
DEFAULT: "var(--color-surface)",
foreground: "var(--color-text)",
},
card: {
DEFAULT: "var(--color-surface)",
foreground: "var(--color-text)",
},
},
fontFamily: {
sans: [
"var(--font-atkinson)",
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"sans-serif",
],
mono: [
"var(--font-mono)",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Monaco",
"Consolas",
"monospace",
],
},
spacing: {
0: "var(--space-0)",
1: "var(--space-1)",
2: "var(--space-2)",
3: "var(--space-3)",
4: "var(--space-4)",
5: "var(--space-5)",
6: "var(--space-6)",
8: "var(--space-8)",
10: "var(--space-10)",
12: "var(--space-12)",
16: "var(--space-16)",
20: "var(--space-20)",
24: "var(--space-24)",
32: "var(--space-32)",
},
borderRadius: {
none: "var(--radius-none)",
sm: "var(--radius-sm)",
DEFAULT: "var(--radius-md)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
full: "var(--radius-full)",
},
boxShadow: {
xs: "var(--shadow-xs)",
sm: "var(--shadow-sm)",
DEFAULT: "var(--shadow-sm)",
md: "var(--shadow-md)",
lg: "var(--shadow-lg)",
xl: "var(--shadow-xl)",
none: "none",
},
transitionTimingFunction: {
out: "var(--ease-out)",
"in-out": "var(--ease-in-out)",
spring: "var(--ease-spring)",
},
transitionDuration: {
fast: "var(--duration-fast)",
base: "var(--duration-base)",
slow: "var(--duration-slow)",
},
zIndex: {
base: "0",
raised: "10",
dropdown: "20",
sticky: "30",
modal: "40",
toast: "50",
},
},
},
plugins: [],
};
export default config;
+175
View File
@@ -0,0 +1,175 @@
/**
* Sonda visual da migração Tailwind 3 → 4, em ambiente fresco estilo VPS.
*
* Mede por ferramenta (`getComputedStyle`), nunca a olho. O que prova, em ordem
* de risco:
*
* 1. Os tokens continuam resolvendo. O `@theme inline` emite, dentro de
* `@layer theme`, linhas auto-referentes (`--color-bg: var(--color-bg)`)
* porque o produto já batizava seus tokens de `--color-*`. A teoria diz que
* elas perdem para o `:root` sem layer; isto é a medição que confirma.
* 2. O tema escuro ainda troca os tokens.
* 3. As classes de opacidade sobre token (`bg-muted/40`, `border-border/60`…)
* agora PINTAM. No v3 elas eram descartadas em silêncio — a cor com
* `var()` sem `<alpha-value>` fazia o Tailwind não emitir a regra.
* 4. `border` sem cor continua na cor de borda do produto (regra do
* `@layer base`), e não em `currentColor`, que é o default novo do v4.
*
* ⚠️ Mede só elemento REAL da página. Injetar `<div class="p-7">` por JS não
* prova nada: a classe não estava na fonte, o scanner nunca a viu, e o zero
* medido seria artefato da sonda — não regressão.
*
* Uso: com o app de pé em :3001 contra o Supabase local,
* set -a; . ./.env.e2e; set +a && pnpm exec tsx tests/sonda-tailwind-4.ts
*/
import { chromium, type Page } from "@playwright/test";
import fs from "node:fs";
const BASE = process.env.SONDA_BASE ?? "http://localhost:3001";
const EMAIL = process.env.SONDA_EMAIL ?? "qa-tw4@deskcomm.local";
const SENHA = process.env.SONDA_SENHA ?? "SenhaForte#2026tw4";
const SAIDA = "evidence/tailwind-4";
const TOKENS = [
"--color-bg", "--color-surface", "--color-surface-elevated", "--color-text",
"--color-text-muted", "--color-border", "--color-accent", "--color-accent-500",
"--color-error", "--radius-md", "--shadow-md", "--space-6", "--duration-fast",
];
/** As classes que o v3 descartava: se aparecerem na página, têm que pintar. */
const REVIVIDAS = [
"bg-muted/40", "bg-muted/30", "bg-muted/50", "border-border/60",
"bg-destructive/10", "border-destructive/30", "bg-primary/10",
"text-muted-foreground/60", "text-muted-foreground/70", "bg-accent/50",
];
async function tokens(page: Page) {
return page.evaluate((nomes) => {
const cs = getComputedStyle(document.documentElement);
const out: Record<string, string> = {};
for (const n of nomes) out[n] = cs.getPropertyValue(n).trim();
out["__body_bg"] = getComputedStyle(document.body).backgroundColor;
out["__body_color"] = getComputedStyle(document.body).color;
return out;
}, TOKENS);
}
/** Varre elementos REAIS da página e devolve o que o browser aplicou neles. */
async function medidasReais(page: Page) {
return page.evaluate((revividas) => {
// Sem função nomeada aqui dentro: o esbuild (via tsx) embrulha
// `const f = () => …` num helper `__name()` que não existe no browser, e o
// `page.evaluate` morre com "__name is not defined". O regex fica inline.
const TRANSPARENTE = /^transparent$|rgba\(0,\s*0,\s*0,\s*0\)/;
const achados: Record<string, unknown> = {};
for (const classe of revividas) {
const els = [...document.querySelectorAll<HTMLElement>("*")].filter((e) =>
typeof e.className === "string" && e.className.split(/\s+/).includes(classe),
);
const primeiro = els[0];
if (!primeiro) continue;
const cs = getComputedStyle(primeiro);
const alvo = classe.startsWith("bg-")
? cs.backgroundColor
: classe.startsWith("border-")
? cs.borderTopColor
: cs.color;
achados[classe] = {
ocorrencias: els.length,
valor: alvo,
// O que se quer ver: cor COM canal alfa < 1. `transparent` significa que
// a classe não pintou; cor opaca significa que o alfa se perdeu.
pinta: !TRANSPARENTE.test(alvo),
temAlfa: /\/\s*0?\.\d+\)|,\s*0?\.\d+\)/.test(alvo),
};
}
// `border` sem NENHUMA classe de cor — é o caso que depende inteiramente da
// regra do `@layer base`. Duas exclusões que a primeira versão desta sonda
// não tinha e que a faziam mentir: elemento que TAMBÉM traz `border-<cor>`
// (aí a cor não vem da regra base), e o elemento em foco (o `<input>`
// autofocado do login casa `focus-visible:border-accent-500` e media a cor
// do foco, não a do token).
const semCor = [...document.querySelectorAll<HTMLElement>("*")].find((e) => {
if (typeof e.className !== "string") return false;
const cs = e.className.split(/\s+/);
if (!cs.includes("border")) return false;
if (e === document.activeElement) return false;
return !cs.some((c) => /^border-(?!\d|solid|dashed|dotted|none|t$|b$|l$|r$|[xy]$)/.test(c));
});
if (semCor) {
achados["border (sem cor)"] = {
ocorrencias: 1,
valor: getComputedStyle(semCor).borderTopColor,
corDoToken: getComputedStyle(document.documentElement)
.getPropertyValue("--color-border").trim(),
};
}
// Raio: `rounded-md` tem que valer o `--radius-md` (8px), que é o que o
// `rounded` puro valia no v3 antes do renome.
const arred = document.querySelector<HTMLElement>(".rounded-md");
if (arred) achados["rounded-md"] = { valor: getComputedStyle(arred).borderTopLeftRadius };
return achados;
}, REVIVIDAS);
}
async function retrato(page: Page, nome: string, relatorio: Record<string, unknown>) {
await page.waitForLoadState("networkidle").catch(() => {});
await page.waitForTimeout(400);
relatorio[`${nome}/claro`] = { url: page.url(), ...(await medidasReais(page)) };
await page.screenshot({ path: `${SAIDA}/${nome}-claro.png`, fullPage: true });
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
await page.waitForTimeout(250);
relatorio[`${nome}/escuro`] = { tokens: await tokens(page), ...(await medidasReais(page)) };
await page.screenshot({ path: `${SAIDA}/${nome}-escuro.png`, fullPage: true });
await page.evaluate(() => document.documentElement.removeAttribute("data-theme"));
await page.waitForTimeout(150);
}
(async () => {
fs.mkdirSync(SAIDA, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const relatorio: Record<string, unknown> = {};
const erros: string[] = [];
page.on("console", (m) => { if (m.type() === "error") erros.push(m.text()); });
page.on("pageerror", (e) => erros.push(String(e)));
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
relatorio["tokens/claro"] = await tokens(page);
await retrato(page, "01-login", relatorio);
await page.fill('input[type="email"]', EMAIL);
await page.fill('input[type="password"]', SENHA);
await Promise.all([
page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 30000 }),
page.click('button[type="submit"]'),
]);
// Instalação fresca cai no onboarding — é a primeira impressão, o P0 da
// doutrina de QA Visual. Percorre o que não depende de WAHA/Resend/Nuvemshop.
const PASSOS = [
["02-onboarding-welcome", "/onboarding/welcome"],
["03-onboarding-funil", "/onboarding/funil"],
["04-onboarding-equipe", "/onboarding/invite-team"],
["05-onboarding-ia", "/onboarding/setup-ai"],
["06-onboarding-whatsapp", "/onboarding/connect-whatsapp"],
["07-conta", "/app/settings/profile"],
["08-seguranca", "/app/settings/security"],
["09-legal-privacidade", "/legal/privacy"],
] as const;
for (const [nome, rota] of PASSOS) {
await page.goto(`${BASE}${rota}`, { waitUntil: "domcontentloaded" }).catch(() => {});
await retrato(page, nome, relatorio);
}
relatorio["erros-de-console"] = erros;
fs.writeFileSync(`${SAIDA}/medicoes.json`, JSON.stringify(relatorio, null, 2));
await browser.close();
console.log(`ok — ${erros.length} erro(s) de console`);
})();
+18 -1
View File
@@ -31,8 +31,25 @@ const CSS = fs.readFileSync(path.join(RAIZ, "app/globals.css"), "utf8");
* a catraca deixaria de calibrar contra a régua. Lendo do arquivo, mudar a Sage quebra
* este teste — que é exatamente o aviso que se quer.
*/
/**
* O bloco `:root` sai por casamento de chaves, e não por `slice` entre duas
* `indexOf`. A versão anterior cortava de `indexOf(":root")` até
* `indexOf('[data-theme="dark"]')` e quebrou na migração para o Tailwind 4: o
* `@custom-variant dark (&:where([data-theme="dark"], …))` pôs essa string no
* TOPO do arquivo, antes do `:root`, e a fatia virou vazia. O sintoma foi
* "não achei --color-accent-50 no :root", que aponta para o lugar errado —
* o `:root` estava intacto; quem tinha se mexido era o delimitador.
*/
function blocoRoot(css: string): string {
const i = css.search(/^:root\s*\{/m);
if (i < 0) throw new Error("não achei o bloco `:root` em globals.css");
const fim = css.indexOf("\n}", i);
if (fim < 0) throw new Error("bloco `:root` sem fechamento em globals.css");
return css.slice(i, fim);
}
function stopsSageDoCss(): string[] {
const raiz = CSS.slice(CSS.indexOf(":root"), CSS.indexOf('[data-theme="dark"]'));
const raiz = blocoRoot(CSS);
return GRAUS.map((g) => {
const m = new RegExp(`--color-accent-${g}:\\s*(#[0-9a-f]{6})`, "i").exec(raiz);
if (!m?.[1]) throw new Error(`não achei --color-accent-${g} no :root do globals.css`);
+206
View File
@@ -0,0 +1,206 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Guardas da migração para o Tailwind 4 (config em CSS).
*
* O `tailwind.config.ts` deixou de existir: o que era `theme.extend` virou um
* bloco `@theme inline` dentro de `app/globals.css`. Isso troca um arquivo que
* o TypeScript conferia por um arquivo que ninguém confere — e três coisas
* passam a poder quebrar em silêncio, com build verde e tela errada. Cada uma
* tem um teste aqui.
*/
const RAIZ = process.cwd();
const CSS = fs.readFileSync(path.join(RAIZ, "app/globals.css"), "utf8");
/** Recorta `<seletor> { … }` por casamento de chave, ancorado em início de linha. */
function bloco(seletor: string): string {
const rx = new RegExp(`^${seletor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{`, "m");
const i = CSS.search(rx);
if (i < 0) throw new Error(`não achei o bloco \`${seletor}\` em globals.css`);
const fim = CSS.indexOf("\n}", i);
if (fim < 0) throw new Error(`bloco \`${seletor}\` sem fechamento em globals.css`);
return CSS.slice(i, fim);
}
/** Nomes de custom property declarados dentro de um bloco. */
function propsDe(texto: string): string[] {
return [...texto.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].flatMap((m) => (m[1] ? [m[1]] : []));
}
describe("Tailwind 4 — a ponte token → utilitário", () => {
it("não voltou a existir config em JS nem diretiva `@tailwind`", () => {
// Os dois convivem tecnicamente (`@config` existe no v4), mas conviver é o
// problema: com um `tailwind.config.ts` de volta, metade dos tokens passa a
// vir de um arquivo e metade do outro, e a divergência só aparece na tela.
expect(fs.existsSync(path.join(RAIZ, "tailwind.config.ts"))).toBe(false);
expect(fs.existsSync(path.join(RAIZ, "tailwind.config.js"))).toBe(false);
expect(CSS).not.toMatch(/^@tailwind\s/m);
expect(CSS).toMatch(/^@import "tailwindcss"/m);
});
it("mantém os blocos de token FORA de `@layer` — é o que neutraliza a auto-referência", () => {
// `@theme inline` emite, dentro de `@layer theme`, linhas do tipo
// `--color-bg: var(--color-bg)`, porque os nomes de token do produto já
// ocupam o namespace `--color-*` do Tailwind. Elas são inofensivas por UM
// motivo só: declaração sem layer vence declaração em layer, e o `:root`
// autoral está sem layer. Embrulhar `:root` num `@layer` inverteria a
// precedência, a auto-referência passaria a valer, e TODA cor do produto
// viraria inválida — tela em branco e preto, com build verde.
// O que se procura é o bloco que DECLARA token. `@layer base` legitimamente
// tem um `[data-theme="dark"] { color-scheme: dark }` — regra de tema, não
// definição de token; ela pode morar em layer sem consequência nenhuma.
for (const l of CSS.matchAll(/^@layer\s+([a-z]+)\s*\{/gim)) {
const inicio = l.index ?? 0;
const fim = CSS.indexOf("\n}", inicio);
const corpo = CSS.slice(inicio, fim < 0 ? CSS.length : fim);
const regras = corpo.matchAll(/^\s*(:root|\[data-theme="(?:light|dark)"\])\s*\{/gm);
for (const r of regras) {
const de = r.index ?? 0;
const ate = corpo.indexOf("}", de);
const dentro = corpo.slice(de, ate < 0 ? corpo.length : ate);
expect(
/^\s*--[a-z0-9-]+\s*:/im.test(dentro),
`\`${r[1]}\` declara token dentro de @layer ${l[1]} — isso liga a auto-referência`,
).toBe(false);
}
}
});
it("todo token que o `@theme inline` consome existe no `:root`", () => {
// Um `var(--color-foo)` no @theme apontando para nada não gera erro: o
// utilitário nasce, aplica um valor vazio, e o elemento fica sem cor. Este
// teste é o que transforma o erro de digitação em falha de CI.
const raiz = new Set(propsDe(bloco(":root")));
const tema = bloco("@theme inline");
const consumidos = [...tema.matchAll(/var\((--[a-z0-9-]+)\)/gi)].flatMap((m) =>
m[1] ? [m[1]] : [],
);
expect(consumidos.length).toBeGreaterThan(50);
// As duas fontes são injetadas pelo `next/font` como custom property no
// `<html>` (app/layout.tsx), não pelo `:root` do CSS — por isso não caem na
// regra acima. A isenção não é um buraco: o teste confere logo abaixo que
// elas continuam sendo declaradas lá.
const DE_FORA_DO_CSS = ["--font-atkinson", "--font-mono"];
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
for (const v of DE_FORA_DO_CSS) {
expect(layout, `${v} deixou de ser declarada pelo next/font`).toContain(`"${v}"`);
}
const orfaos = [...new Set(consumidos)].filter(
(v) => !raiz.has(v) && !DE_FORA_DO_CSS.includes(v),
);
expect(orfaos, `tokens referenciados no @theme mas ausentes do :root`).toEqual([]);
});
it("o `@source` cobre toda pasta que realmente escreve className", () => {
// `source(none)` desliga a descoberta automática. O preço é este: pasta de
// UI nova fora da lista perde TODAS as classes, sem erro de build — a tela
// simplesmente renderiza sem estilo.
const declarados = [...CSS.matchAll(/^@source\s+"\.\.\/([a-z-]+)"/gim)].flatMap((m) =>
m[1] ? [m[1]] : [],
);
expect(declarados.length).toBeGreaterThan(0);
const raizesComClasse = fs
.readdirSync(RAIZ, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
.map((d) => d.name)
.filter((nome) => {
// Só interessa pasta que entrega UI ao browser. `tests/` e `docs/`
// escrevem className em fixture e em exemplo, e ficam de fora de
// propósito — varrê-las publicaria CSS que nenhuma tela usa.
if (["tests", "docs", "scripts", "supabase", "public", "tasks", "loop"].includes(nome)) {
return false;
}
return temClassName(path.join(RAIZ, nome));
});
const faltando = raizesComClasse.filter((n) => !declarados.includes(n));
expect(faltando, "pasta com className fora do @source de app/globals.css").toEqual([]);
});
});
describe("Tailwind 4 — utilitários que mudaram de significado", () => {
const ARQUIVOS = listarFontes(["app", "components", "lib", "hooks"]);
it("não usa `rounded` puro — no v4 ele é 0.25rem, não o `--radius-md` do produto", () => {
// No v3 este projeto redefinia o DEFAULT de borderRadius para
// `var(--radius-md)` (8px). O v4 não tem esse DEFAULT sobrescrevível — nem
// por `@utility rounded`, que o embutido vence. Deixar `rounded` puro
// encolhe o raio de 8px para 4px em silêncio.
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`])rounded(?=[\s"'`])/g);
expect(culpados, "use `rounded-md` (8px) ou o grau explícito").toEqual([]);
});
it("não usa `outline-none` — no v4 esse nome virou `outline-hidden`", () => {
// O `outline-none` do v4 é outra coisa (`outline-style: none`) e não
// preserva o contorno transparente que o modo de alto contraste do sistema
// precisa. Trocar por engano degrada acessibilidade sem quebrar nada.
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`:])outline-none(?=[\s"'`])/g);
expect(culpados, "use `outline-hidden`").toEqual([]);
});
it("não usa `flex-shrink-*` / `flex-grow-*` — renomeados para `shrink-*` / `grow-*`", () => {
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`:])flex-(shrink|grow)(-\d+)?(?=[\s"'`])/g);
expect(culpados, "use `shrink-*` / `grow-*`").toEqual([]);
});
});
// ── auxiliares ────────────────────────────────────────────────────────────
function listarFontes(raizes: string[]): string[] {
const saida: string[] = [];
const anda = (dir: string) => {
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, d.name);
if (d.isDirectory()) {
if (d.name === "node_modules" || d.name.startsWith(".")) continue;
anda(p);
} else if (/\.tsx?$/.test(d.name) && !/\.(test|spec)\.tsx?$/.test(d.name)) {
saida.push(p);
}
}
};
for (const r of raizes) {
const p = path.join(RAIZ, r);
if (fs.existsSync(p)) anda(p);
}
return saida;
}
function ocorrencias(arquivos: string[], rx: RegExp): string[] {
const achados: string[] = [];
for (const f of arquivos) {
const linhas = fs.readFileSync(f, "utf8").split("\n");
linhas.forEach((linha, i) => {
// Prosa não é classe. `lib/ai/cost.ts` documenta "rounded up" e viraria
// culpado; a heurística de linha de comentário é grosseira mas suficiente,
// porque nenhuma classe do produto mora em linha iniciada por `*` ou `//`.
const cru = linha.trimStart();
if (cru.startsWith("*") || cru.startsWith("//") || cru.startsWith("/*")) return;
if (new RegExp(rx.source, rx.flags.replace("g", "")).test(linha)) {
achados.push(`${path.relative(RAIZ, f)}:${i + 1}`);
}
});
}
return achados;
}
function temClassName(dir: string): boolean {
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
if (d.name === "node_modules" || d.name.startsWith(".")) continue;
const p = path.join(dir, d.name);
if (d.isDirectory()) {
if (temClassName(p)) return true;
} else if (/\.tsx$/.test(d.name) && fs.readFileSync(p, "utf8").includes("className=")) {
return true;
}
}
return false;
}