dismiss(toast.id)}
- className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-muted hover:bg-sunken hover:text-ink"
+ className="grid h-6 w-6 shrink-0 place-items-center rounded-lg text-muted hover:bg-sunken hover:text-ink"
>
diff --git a/app/src/ui/tokens.test.ts b/app/src/ui/tokens.test.ts
new file mode 100644
index 00000000..4883f4af
--- /dev/null
+++ b/app/src/ui/tokens.test.ts
@@ -0,0 +1,55 @@
+// The color tokens (spec 15.3): every color words are written in reads at
+// 4.5:1 or more (WCAG AA) on every background, in both themes.
+
+import { describe, expect, test } from "vitest";
+import css from "../index.css?raw";
+
+/** The `--name: #hex` tokens of the first block that opens with `selector {`. */
+function tokens(selector: string): Record {
+ const start = css.indexOf(`${selector} {`);
+ const block = css.slice(start, css.indexOf("}", start));
+ return Object.fromEntries(
+ [...block.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6})\b/gi)].map((m) => [m[1], m[2]]),
+ );
+}
+
+function luminance(hex: string): number {
+ const [r, g, b] = [1, 3, 5].map((at) => {
+ const c = Number.parseInt(hex.slice(at, at + 2), 16) / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ }) as [number, number, number];
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number];
+ return (x + 0.05) / (y + 0.05);
+}
+
+const BACKGROUNDS = ["canvas", "panel", "sunken"];
+/** Colors text is written in. `accent` is a fill; words use `accent-text`. */
+const TEXT = [
+ "ink",
+ "ink-soft",
+ "muted",
+ "accent-text",
+ "ok",
+ "work",
+ "warn",
+ "danger",
+ "idle-state",
+];
+
+describe.each([
+ ["light", ":root"],
+ ["dark", '[data-theme="dark"]'],
+])("the %s theme", (_name, selector) => {
+ const theme = tokens(selector);
+
+ test.each(TEXT)("%s reads on every background", (text) => {
+ for (const background of BACKGROUNDS) {
+ const ratio = contrast(theme[text] as string, theme[background] as string);
+ expect(ratio, `${text} on ${background}`).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+});
diff --git a/docs/spec.md b/docs/spec.md
index 3b0bc802..2a8021c0 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -863,6 +863,8 @@ Em dev, `pnpm tauri dev` usa o `target\debug\botloftd.exe` como sidecar e instal
Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Cantos arredondados, como nos apps de chat: botões, campos e linhas de menu com 8 px; cartões, avisos e menus com 12 px; compositor, diálogos e cartões do chat com 16 px. Menus e diálogos flutuam com uma sombra leve (`shadow-lift`, mais forte no tema escuro). Na barra lateral, a conversa aberta é um bloco arredondado recuado das bordas. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows.
+**Cores (tokens).** Toda cor vem de um token em `index.css` (`:root` para o tema claro, `[data-theme="dark"]` para o escuro), exposto ao Tailwind como `--color-*`: fundos `canvas`, `panel` e `sunken`; bordas `line` e `line-strong`; texto `ink`, `ink-soft` e `muted`; estados `ok`, `work`, `warn`, `danger` e `quiet`; `accent`, o laranja do Botloft, como preenchimento (botões, selos, bordas, ícones) e `accent-text` para palavras nessa cor; `read` (os tiques de lida), `scrim` (o fundo atrás de um diálogo) e `close` (o vermelho do botão fechar do Windows). Toda cor de texto passa de 4,5:1 (WCAG AA) sobre os três fundos nos dois temas, e um teste (`ui/tokens.test.ts`) confere isso a cada mudança; no tema claro o laranja de preenchimento não passa como texto, por isso o `accent-text` existe. Ícones podem usar `accent` (pedem 3:1). Ficam fora dos tokens só desenhos: o mascote, o cursor do bot, as cores do seletor de cor e o fundo branco da tela de uma página (22). Os cantos usam só a escala acima (8, 12 e 16 px, `rounded-lg`, `rounded-xl` e `rounded-2xl`) e o círculo (`rounded-full`).
+
Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), então texto, espaçamento, ícones e avatares crescem juntos. Níveis 100%, 110%, 125% e 150%; o padrão é **125%**, porque o desenho a 100% ficava miúdo num monitor sem ampliação do Windows. Quem já usa a ampliação alta volta para 100% em Configurações (área da conta) ou com Ctrl+-; Ctrl+= aumenta e Ctrl+0 volta ao padrão. A escolha fica no `localStorage` (`botloft.zoom`) e é aplicada antes da primeira pintura, para a janela não piscar pequena. Acima de 150% a janela mínima (900 px) não cabe o layout.
Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois).