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>
@@ -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
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./app/globals.css"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 206 KiB |
@@ -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": []
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
})();
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||