Files
transcript/docs/superpowers/plans/2026-08-08-frontend-redesign.md
evandrodevbr 92c214254e
Desktop Build / build (linux-bundles, ubuntu-22.04) (push) Canceled after 0s
Desktop Build / build (windows-bundles, windows-latest) (push) Canceled after 0s
CI / quality (push) Canceled after 0s
docs: plano de implementação do redesign do front-end
2026-08-08 21:59:06 -03:00

2837 lines
104 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Substituir todo o front-end do Transcript (Tauri 2) por uma interface React 19 + Tailwind v4 com design system próprio (tokens, primitivos, Storybook), design book em markdown, e as 4 superfícies reformuladas (janela principal com abas, configurações, overlay de ditado, histórico) — sem tocar na marca nem no backend.
**Architecture:** Reescrita completa da camada de UI. Primeiro uma rodada de concorrência: 3 agentes designers produzem direções visuais em mockups HTML estáticos (design/proposals/<vencedor>/); o usuário escolhe; depois a implementação segue a direção vencedora — tokens → primitivos (Storybook) → estado (hooks que portam a máquina de ditado atual) → telas (Editor, Settings, History) → cutover (apaga app.ts antigo) → design book → auditoria final. O backend Rust só muda 2 constantes de tamanho de janela (ver Task 1).
**Tech Stack:** React 19, Vite 7, TypeScript 5.9, Tailwind CSS v4 (tokens em `@theme`), Storybook 8, Vitest + Testing Library, lucide-react, Tauri 2 (comandos e eventos inalterados).
## Global Constraints
- Textos da UI em pt-BR, portando as mensagens atuais (ex.: "Ouvindo…", "Pronto para gravar.", "Texto copiado.").
- Todos os comandos Tauri usam os nomes snake_case exatos (`get_app_snapshot`, `stop_recording`, `process_transcript_text`… ver Task 8).
- Todos os eventos Tauri usam os nomes exatos (`transcript://snapshot`, `transcript://dictation-started`, `transcript://dictation-finished`, `transcript://dictation-failed`, `transcript://toggle-recording`).
- Backend Rust intocado exceto: `APP_WINDOW_WIDTH` e `APP_WINDOW_HEIGHT` em `src-tauri/src/ui_commands.rs:28-29` (Task 1).
- Instalação de dependências com `npm` (o repo usa package-lock.json; pnpm-workspace.yaml é de outro workspace e não deve ser tocado).
- Dark-first: tema escuro ativo; tokens do tema claro prontos mas sem toggle na UI.
- Marca (logo, nome "Transcript") intocada.
- A janela é redimensionada pelo Rust durante o ditado (barra 560×124, revisão 720×~300) — o overlay precisa renderizar bem nesses tamanhos.
- Sem novas dependências além das listadas na Task 1.
- Commits em pt-BR, frequentes, um por task.
---
## Phase A — Fundação
### Task 1: Stack React + Tailwind + configuração
**Files:**
- Modify: `package.json`
- Modify: `vite.config.ts`
- Modify: `tsconfig.json`
- Modify: `index.html`
- Modify: `src-tauri/tauri.conf.json`
- Modify: `src-tauri/src/ui_commands.rs:28-29`
- Create: `src/test/setup.ts`
- Create: `src/main.tsx`
- Create: `src/App.tsx`
- Create: `src/App.test.tsx`
- Delete: dependências `@hypabolic/pi-hypa`, `lucide-static`
**Interfaces:**
- Produces: `src/App.tsx` exporta `export function App({ client = createNativeClient() }: { client?: NativeClient })` (esqueleto mínimo: topbar com marca + versão + botão de config; ainda sem abas). `src/main.tsx` monta `<App />` em `#app`. `src/test/setup.ts` registra cleanup do Testing Library.
- [ ] **Step 1: Instalar dependências**
```bash
npm i react react-dom lucide-react
npm i -D @vitejs/plugin-react tailwindcss @tailwindcss/vite \
@types/react @types/react-dom @testing-library/react \
@testing-library/jest-dom @testing-library/user-event \
storybook @storybook/react-vite @storybook/react
npm rm @hypabolic/pi-hypa lucide-static
```
- [ ] **Step 2: Adicionar scripts no `package.json`**
```json
"scripts": {
"dev": "vite --host 127.0.0.1 --port 1420",
"build": "tsc --noEmit && vite build",
"test": "vitest",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
}
```
- [ ] **Step 3: Reescrever `vite.config.ts`**
```ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
host: "127.0.0.1",
port: 1420,
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});
```
- [ ] **Step 4: Adicionar `jsx` no `tsconfig.json`** (dentro de `compilerOptions`)
```json
"jsx": "react-jsx"
```
- [ ] **Step 5: Criar `src/test/setup.ts`**
```ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
```
- [ ] **Step 6: Apontar `index.html` para o entry React**
```html
<script type="module" src="/src/main.tsx"></script>
```
(título, descrição, favicon e `<main id="app">` permanecem)
- [ ] **Step 7: Criar `src/main.tsx`**
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("app");
if (!root) throw new Error("elemento #app não encontrado");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
```
- [ ] **Step 8: Criar `src/App.tsx` mínimo (topbar com marca — será expandido na Task 10)**
```tsx
import { createNativeClient, type NativeClient } from "./lib/tauri";
import { Settings, Minus, X } from "lucide-react";
export function App({
client = createNativeClient(),
}: {
client?: NativeClient;
}) {
return (
<div className="flex h-screen flex-col bg-bg-base text-fg">
<header className="flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<img src="/favicon.ico" alt="" className="size-6" />
<h1 className="text-base font-semibold">Transcript</h1>
</div>
<div className="flex items-center gap-1">
<button type="button" aria-label="Configurações" className="rounded p-1.5 hover:bg-bg-elevated">
<Settings size={16} />
</button>
<button type="button" aria-label="Minimizar" className="rounded p-1.5 hover:bg-bg-elevated">
<Minus size={16} />
</button>
<button type="button" aria-label="Fechar" className="rounded p-1.5 hover:bg-bg-elevated">
<X size={16} />
</button>
</div>
</header>
<main className="flex-1" />
</div>
);
}
```
- [ ] **Step 9: Criar `src/styles.css` base** (a Task 4 define os tokens; aqui só o mínimo para o shell funcionar)
```css
@import "tailwindcss";
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-bg-base font-sans text-base text-fg antialiased;
}
}
```
- [ ] **Step 10: Criar `src/lib/tauri.ts` com o mínimo para o App compilar** — apenas `NativeClient` e `createNativeClient` por ora; a Task 8 expande com `commands` e `EVENTS`.
```ts
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
export interface NativeClient {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
}
export function createNativeClient(): NativeClient {
return {
invoke: (command, args) => tauriInvoke(command, args),
};
}
```
- [ ] **Step 11: Criar `src/App.test.tsx` (teste do shell)**
```tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./App";
import type { NativeClient } from "./lib/tauri";
const client: NativeClient = {
invoke: () => Promise.reject(new Error("não usado neste teste")),
};
describe("App", () => {
it("renderiza a marca e os controles de janela", () => {
render(<App client={client} />);
expect(screen.getByRole("heading", { name: "Transcript" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Configurações" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Minimizar" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Fechar" })).toBeInTheDocument();
});
});
```
- [ ] **Step 12: Ajustar a janela no `src-tauri/tauri.conf.json`** (janela vira redimensionável e maior; mantém frameless, transparent, alwaysOnTop)
```json
{
"label": "main",
"width": 640,
"height": 520,
"minWidth": 420,
"minHeight": 360,
"maxWidth": 1024,
"maxHeight": 768,
"resizable": true,
"maximizable": false,
"minimizable": true,
"closable": true,
"alwaysOnTop": true,
"visible": true,
"decorations": false,
"transparent": true,
"backgroundColor": "#00000000",
"shadow": false,
"dragDropEnabled": false,
"center": true
}
```
- [ ] **Step 13: Atualizar as constantes de restauração no Rust** (`src-tauri/src/ui_commands.rs:28-29`). Sem isso, `restore_main_window` (tray) encolheria a janela para 380×460 a cada restauração.
```rust
const APP_WINDOW_WIDTH: u32 = 640;
const APP_WINDOW_HEIGHT: u32 = 520;
```
- [ ] **Step 14: Rodar o teste e verificar falha do shell** (o App antigo ainda é o entry de produção; o teste novo valida o novo shell)
Run: `npm test -- --run src/App.test.tsx`
Expected: PASS
- [ ] **Step 15: Verificação completa**
Run: `npm run typecheck && npm run build && npm test -- --run`
Expected: tudo PASS (o app antigo continua passando; `build` gera o bundle novo).
- [ ] **Step 16: Commit**
```bash
git add -A
git commit -m "feat: fundação React 19 + Tailwind v4 + janela redimensionável"
```
---
### Task 2: Brief da fase de concorrência
**Files:**
- Create: `design/proposals/BRIEF.md`
**Interfaces:**
- Produces: `design/proposals/BRIEF.md` — documento que os 3 agentes designers seguem na Task 3.
- [ ] **Step 1: Criar o BRIEF com este conteúdo exato**
```markdown
# Brief — Direções visuais para o Transcript (front-end)
Contexto: Transcript é um utilitário desktop (Tauri 2, Windows/Linux) de ditado por voz:
hotkey global → fala → transcrição → texto inserido no app em foco. A UI atual é dark
glass/acrylic, janela compacta, tudo numa tela. O front será reescrito em React 19 +
Tailwind v4. Nesta fase você propõe UMA direção visual completa, em mockups HTML estáticos.
## Superfícies a cobrir (4 mockups mínimos, 7 arquivos)
1. Janela principal — estado vazio (design/proposals/<nome>/main-empty.html)
2. Janela principal — com texto transcrito + painel "Refinar com IA" aberto (main-text.html)
3. Overlay de ditado — gravando (waveform) (dictation-recording.html)
4. Overlay de ditado — revisão pós-transcrição (dictation-review.html)
5. Configurações — com abas internas (settings.html)
6. Histórico — lista de áudios recentes (history.html)
7. tokens.css — direção estética em tokens CSS (tokens.css)
Restrições de produto: janela pode ser maior que hoje, mas continua compacta (não é um
IDE); o overlay de ditado é a superfície mais usada e aparece redimensionando a janela
para ~560×124 (gravação) e ~720×~300 (revisão) — deve caber nesses tamanhos; fluxo
primário: hotkey → fala → insere (velocidade acima de tudo).
## Conteúdo de cada mockup
- HTML autossuficiente (CSS inline no <style>), dark-first, sem JS.
- Fiel ao conteúdo real: botões Gravar/Copiar/Inserir/Salvar/Limpar, Continuar/Regravar,
Refinar com IA (Normalizar/Criar prompt), status pill (Pronto/Gravando/Transcrevendo),
mensagem de status, atalho global exibido (Ctrl+Shift+Space), configurações com grupos
Gravação/Servidor local/Ditado/Áudios/Assistente de texto.
## tokens.css
Bloco `:root` com: paleta (bg base/elevada/forte, fg, accent, success/warning/danger,
borda), tipografia (escala + famílias), raio, sombra, motion. Valores em oklch ou hex.
## Mapa funcionalidade → superfície (mapa.md)
Onde cada funcionalidade atual fica na nova interface e POR QUÊ (1-2 linhas por item):
hotkey/ditado, editor+inserir, Refinar com IA, histórico de áudios, diagnóstico,
configurações (5 grupos), botões Continuar/Regravar/Expandir, download de áudio.
## Critérios de avaliação
Coerência com produto utilitário rápido; legibilidade; acessibilidade (contraste, foco,
teclado); viabilidade técnica do mapa de funcionalidades.
## Formato de entrega
Diretório design/proposals/<nome-da-direcao>/ com os 7 arquivos + resumo em resumo.md
(conceito em 1 parágrafo + 3 prós + 3 contras).
```
- [ ] **Step 2: Commit**
```bash
git add design/proposals/BRIEF.md
git commit -m "docs: brief da rodada de concorrência de direções visuais"
```
---
### Task 3: Rodada de concorrência — 3 agentes designers
**Files:**
- Create: `design/proposals/<nome-da-direcao>/{main-empty,main-text,dictation-recording,dictation-review,settings,history}.html`, `tokens.css`, `mapa.md`, `resumo.md` (3 diretórios, gerados pelos agentes)
- Create: `design/proposals/README.md` (registro do vencedor)
**Interfaces:**
- Consumes: `design/proposals/BRIEF.md` (Task 2).
- Produces: o diretório do vencedor. A partir da Task 4, **"proposta vencedora"** = o diretório registrado em `design/proposals/README.md` (campo `Vencedora:`). Todas as tasks seguintes usam `design/proposals/<vencedor>/` como fonte da direção visual.
- [ ] **Step 1: Disparar 3 agentes `designer` em paralelo**
Um task por agente, todos com o mesmo prompt base (via `local://brief.md` apontando para `design/proposals/BRIEF.md`):
> Leia `design/proposals/BRIEF.md` (ou `local://brief.md`). Produza a direção visual
> completa conforme o brief, no diretório `design/proposals/<seu-nome-de-direcao>/` com os
> 7 arquivos exigidos. Não toque em nenhum outro arquivo do repo. Não rode build/testes.
> O nome da direção: um adjetivo curto (ex.: "sereno", "vibrante", "austero").
- [ ] **Step 2: Consolidar e apresentar ao usuário**
Para cada direção, montar um resumo de 5-8 linhas: conceito, paleta (cores-chave), tipo/layout, decisões do mapa funcionalidade→superfície, prós/contras. Apresentar as 3 opções ao usuário com um parecer (qual atende melhor os critérios do BRIEF). **Gate: aguardar a escolha do usuário.**
- [ ] **Step 3: Registrar o vencedor**
Criar `design/proposals/README.md`:
```markdown
# Propostas de direção visual
Data: 2026-08-08
Vencedora: <nome-do-diretorio>
Escolhida por: usuário (data: <hoje>)
## Direções
- <dir-a>: <1 linha>
- <dir-b>: <1 linha>
- <dir-c>: <1 linha>
```
- [ ] **Step 4: Commit**
```bash
git add design/proposals
git commit -m "docs: direção visual escolhida: <nome>"
```
---
## Phase B — Design system
### Task 4: Tokens do design system (dark-first + camada light)
**Files:**
- Create: `src/ui/tokens.css`
- Modify: `src/styles.css`
**Interfaces:**
- Produces: tokens Tailwind v4 usados por todas as tasks seguintes. Nomes exatos:
`bg-bg-base`, `bg-bg-elevated`, `bg-bg-elevated-strong`, `bg-bg-inset`, `text-fg`, `text-fg-muted`, `text-fg-subtle`, `bg-accent`, `bg-accent-strong`, `text-accent-contrast`, `text-success`, `text-warning`, `text-danger`, `border-border`, `border-border-strong`, `rounded-sm/md/lg`, `shadow-pop`, `shadow-overlay`, `duration-fast/base/slow`, `ease-standard`, `font-sans`, `font-mono`, `text-xs…text-2xl`.
- [ ] **Step 1: Criar `src/ui/tokens.css`**
> Regra: os valores de cor ESCURO vêm da paleta da proposta vencedora
> (`design/proposals/<vencedor>/tokens.css`). O template abaixo já traz os valores atuais do
> app como fallback funcional — substitua cada cor onde o vencedor definir. Mantenha os
> nomes dos tokens exatamente como estão.
```css
/* ===== Tokens do design system Transcript =====
Valores dark: paleta da proposta vencedora (design/proposals/<vencedor>/tokens.css).
Nomes dos tokens são contrato — não renomear. */
/* Tema claro via classe .light no <html> (futuro toggle) */
@custom-variant light (&:where(.light, .light *));
@theme {
/* Tipografia */
--font-sans:
"Aptos Display", "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif;
--font-mono: "Cascadia Code", "Consolas", "SF Mono", monospace;
/* Escala tipográfica */
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
/* Cores — dark */
--color-bg-base: oklch(11% 0.012 270);
--color-bg-elevated: oklch(15% 0.012 270);
--color-bg-elevated-strong: oklch(18% 0.012 270);
--color-bg-inset: oklch(9% 0.01 270);
--color-fg: oklch(93% 0.008 270);
--color-fg-muted: oklch(70% 0.01 270);
--color-fg-subtle: oklch(55% 0.012 270);
--color-accent: oklch(72% 0.14 250);
--color-accent-strong: oklch(62% 0.17 250);
--color-accent-contrast: oklch(98% 0.005 250);
--color-success: oklch(75% 0.15 155);
--color-warning: oklch(80% 0.13 80);
--color-danger: oklch(68% 0.18 25);
--color-border: oklch(26% 0.012 270);
--color-border-strong: oklch(36% 0.014 270);
/* Raio */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
/* Sombra */
--shadow-pop: 0 8px 24px oklch(0% 0 0 / 45%);
--shadow-overlay: 0 16px 48px oklch(0% 0 0 / 55%);
/* Motion */
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 300ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
}
/* Camada light (pronta, inativa) */
@variant light {
@theme {
--color-bg-base: oklch(97% 0.004 270);
--color-bg-elevated: oklch(94% 0.006 270);
--color-bg-elevated-strong: oklch(91% 0.008 270);
--color-bg-inset: oklch(99% 0.002 270);
--color-fg: oklch(22% 0.015 270);
--color-fg-muted: oklch(40% 0.015 270);
--color-fg-subtle: oklch(52% 0.014 270);
--color-accent: oklch(55% 0.16 250);
--color-accent-strong: oklch(48% 0.17 250);
--color-accent-contrast: oklch(99% 0.002 250);
--color-success: oklch(48% 0.14 155);
--color-warning: oklch(55% 0.13 80);
--color-danger: oklch(52% 0.18 25);
--color-border: oklch(85% 0.01 270);
--color-border-strong: oklch(75% 0.012 270);
--shadow-pop: 0 8px 24px oklch(0% 0 0 / 12%);
--shadow-overlay: 0 16px 48px oklch(0% 0 0 / 18%);
}
}
```
- [ ] **Step 2: Atualizar `src/styles.css`** (importar tokens + base completa)
```css
@import "tailwindcss";
@import "./ui/tokens.css";
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-bg-base font-sans text-base text-fg antialiased;
}
::selection {
@apply bg-accent/30;
}
:focus-visible {
@apply outline-2 outline-offset-2 outline-accent;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
@apply rounded-full border-2 border-solid border-transparent bg-clip-padding bg-border;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-border-strong;
}
}
```
- [ ] **Step 3: Verificação**
Run: `npm run typecheck && npm run build && npm test -- --run`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ui/tokens.css src/styles.css
git commit -m "feat: tokens do design system (dark-first + camada light)"
```
---
### Task 5: Storybook + primitivos I (Button, IconButton, Kbd, Pill, Card)
**Files:**
- Create: `.storybook/main.ts`, `.storybook/preview.ts`
- Create: `src/ui/Button.tsx`, `src/ui/Button.test.tsx`, `src/ui/Button.stories.tsx`
- Create: `src/ui/IconButton.tsx`, `src/ui/IconButton.test.tsx`, `src/ui/IconButton.stories.tsx`
- Create: `src/ui/Kbd.tsx`, `src/ui/Kbd.stories.tsx`
- Create: `src/ui/Pill.tsx`, `src/ui/Pill.test.tsx`, `src/ui/Pill.stories.tsx`
- Create: `src/ui/Card.tsx`, `src/ui/Card.stories.tsx`
**Interfaces:**
- Produces (usados nas Tasks 6-13):
```ts
// src/ui/Button.tsx
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
}
export function Button(props: ButtonProps): JSX.Element;
// default type="button"; classes: rounded-md + font-medium + variante/tamanho
// src/ui/IconButton.tsx
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string; // vira aria-label e title — obrigatório
size?: "sm" | "md";
}
export function IconButton(props: IconButtonProps): JSX.Element;
// src/ui/Kbd.tsx
export function Kbd({ children }: { children: ReactNode }): JSX.Element;
// src/ui/Pill.tsx
export type PillTone = "neutral" | "success" | "warning" | "danger" | "accent";
export function Pill({ tone = "neutral", children }: { tone?: PillTone; children: ReactNode }): JSX.Element;
// src/ui/Card.tsx
export function Card({ className = "", children }: { className?: string; children: ReactNode }): JSX.Element;
```
- [ ] **Step 1: Configurar Storybook**
`.storybook/main.ts`:
```ts
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
```
`.storybook/preview.ts`:
```ts
import type { Preview } from "@storybook/react-vite";
import "../src/styles.css";
const preview: Preview = {
parameters: {
backgrounds: { default: "dark", values: [{ name: "dark", value: "#101014" }] },
},
};
export default preview;
```
- [ ] **Step 2: Escrever o teste que falha** — `src/ui/Button.test.tsx`
```tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
describe("Button", () => {
it("renderiza label e dispara onClick", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Gravar</Button>);
await userEvent.click(screen.getByRole("button", { name: "Gravar" }));
expect(onClick).toHaveBeenCalledOnce();
});
it("aplica a variante danger", () => {
render(<Button variant="danger">Remover</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-danger/15");
});
it("quando desabilitado não dispara onClick", async () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Salvar
</Button>,
);
await userEvent.click(screen.getByRole("button", { name: "Salvar" }));
expect(onClick).not.toHaveBeenCalled();
});
it("é type=button por padrão", () => {
render(<Button>OK</Button>);
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
});
});
```
- [ ] **Step 3: Rodar e confirmar falha**
Run: `npm test -- --run src/ui/Button.test.tsx`
Expected: FAIL ("Cannot find module './Button'")
- [ ] **Step 4: Implementar `src/ui/Button.tsx`**
```tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
"bg-accent text-accent-contrast hover:bg-accent-strong disabled:opacity-50",
secondary:
"border border-border bg-bg-elevated-strong text-fg hover:border-border-strong hover:bg-bg-elevated",
ghost: "text-fg-muted hover:bg-bg-elevated hover:text-fg",
danger: "border border-danger/30 bg-danger/15 text-danger hover:bg-danger/25",
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: "h-7 gap-1.5 px-2.5 text-sm",
md: "h-9 gap-2 px-3.5 text-sm",
lg: "h-11 gap-2 px-5 text-base",
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
}
export function Button({
variant = "primary",
size = "md",
icon,
className = "",
children,
type = "button",
...rest
}: ButtonProps) {
return (
<button
type={type}
className={`inline-flex cursor-pointer items-center justify-center rounded-md font-medium transition-colors duration-fast ease-standard disabled:cursor-not-allowed disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
{...rest}
>
{icon}
{children}
</button>
);
}
```
- [ ] **Step 5: Rodar o teste — PASS**
Run: `npm test -- --run src/ui/Button.test.tsx`
Expected: PASS
- [ ] **Step 6: Criar a story** — `src/ui/Button.stories.tsx`
```tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "./Button";
const meta = {
title: "Primitivos/Button",
component: Button,
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "ghost", "danger"],
},
size: { control: "select", options: ["sm", "md", "lg"] },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = { args: { children: "Gravar" } };
export const Secondary: Story = { args: { variant: "secondary", children: "Copiar" } };
export const Ghost: Story = { args: { variant: "ghost", children: "Limpar" } };
export const Danger: Story = { args: { variant: "danger", children: "Remover" } };
export const Disabled: Story = { args: { children: "Salvar", disabled: true } };
```
- [ ] **Step 7: Implementar `IconButton`, `Kbd`, `Pill`, `Card`** (mesmo ciclo TDD dos Steps 2-5; testes abaixo)
```tsx
// src/ui/IconButton.tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
size?: "sm" | "md";
}
export function IconButton({
label,
size = "md",
className = "",
children,
type = "button",
...rest
}: IconButtonProps) {
return (
<button
type={type}
aria-label={label}
title={label}
className={`inline-flex cursor-pointer items-center justify-center rounded-md text-fg-muted transition-colors duration-fast ease-standard hover:bg-bg-elevated hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 ${size === "sm" ? "size-7" : "size-9"} ${className}`}
{...rest}
>
{children}
</button>
);
}
```
```tsx
// src/ui/Kbd.tsx
import type { ReactNode } from "react";
export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="ml-2 rounded border border-border bg-bg-elevated-strong px-1.5 py-0.5 font-mono text-[10px] leading-none text-fg-subtle">
{children}
</kbd>
);
}
```
```tsx
// src/ui/Pill.tsx
import type { ReactNode } from "react";
export type PillTone = "neutral" | "success" | "warning" | "danger" | "accent";
const TONE_CLASSES: Record<PillTone, string> = {
neutral: "bg-bg-elevated text-fg-muted",
success: "bg-success/15 text-success",
warning: "bg-warning/15 text-warning",
danger: "bg-danger/15 text-danger",
accent: "bg-accent/15 text-accent",
};
export function Pill({ tone = "neutral", children }: { tone?: PillTone; children: ReactNode }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${TONE_CLASSES[tone]}`}
>
{children}
</span>
);
}
```
```tsx
// src/ui/Card.tsx
import type { ReactNode } from "react";
export function Card({ className = "", children }: { className?: string; children: ReactNode }) {
return (
<section className={`rounded-lg border border-border bg-bg-elevated ${className}`}>
{children}
</section>
);
}
```
Testes mínimos (arquivos `IconButton.test.tsx`, `Pill.test.tsx`):
```tsx
// IconButton.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { IconButton } from "./IconButton";
describe("IconButton", () => {
it("expõe label como aria-label e title", () => {
render(
<IconButton label="Fechar">
<span>×</span>
</IconButton>,
);
const button = screen.getByRole("button", { name: "Fechar" });
expect(button).toHaveAttribute("title", "Fechar");
});
});
```
```tsx
// Pill.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Pill } from "./Pill";
describe("Pill", () => {
it("aplica tom danger", () => {
render(<Pill tone="danger">Falha</Pill>);
expect(screen.getByText("Falha")).toHaveClass("bg-danger/15");
});
});
```
Stories: `IconButton.stories.tsx`, `Kbd.stories.tsx`, `Pill.stories.tsx`, `Card.stories.tsx` — uma story por variante/tamanho relevante, seguindo o padrão do Step 6.
- [ ] **Step 8: Verificação completa**
Run: `npm run typecheck && npm test -- --run && npm run build-storybook`
Expected: PASS (build-storybook gera `storybook-static/`; adicionar `storybook-static` ao `.gitignore` se ainda não estiver).
- [ ] **Step 9: Commit**
```bash
git add .storybook src/ui
git commit -m "feat: primitivos Button, IconButton, Kbd, Pill, Card + Storybook"
```
---
### Task 6: Primitivos II (Field, TextInput, Select, TextArea, Tabs, Modal, Disclosure)
**Files:**
- Create: `src/ui/Field.tsx` (+test+story)
- Create: `src/ui/TextInput.tsx` (+test+story)
- Create: `src/ui/Select.tsx` (+test+story)
- Create: `src/ui/TextArea.tsx` (+test+story)
- Create: `src/ui/Tabs.tsx` (+test+story)
- Create: `src/ui/Modal.tsx` (+test+story)
- Create: `src/ui/Disclosure.tsx` (+test+story)
**Interfaces:**
- Produces:
```ts
// Field: label + htmlFor pelo `name`; hint opcional; erro com role="alert"
export interface FieldProps {
label: string;
name: string;
hint?: string;
error?: string | null;
optional?: boolean;
children: ReactNode;
}
export function Field(props: FieldProps): JSX.Element;
// TextInput / Select / TextArea: prop `invalid?: boolean`; forwardRef não necessário;
// id e name passados via rest props (o label do Field aponta para `name`).
export function TextInput(props: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }): JSX.Element;
export function Select(props: SelectHTMLAttributes<HTMLSelectElement> & { options: Array<{ value: string; label: string }> }): JSX.Element;
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }): JSX.Element;
// Tabs: role=tablist/tab/tabpanel, aria-selected, ids tab-<id>/panel-<id>
export interface TabItem { id: string; label: string; }
export interface TabsProps { tabs: TabItem[]; activeId: string; onChange: (id: string) => void; }
export function Tabs(props: TabsProps): JSX.Element;
export function TabPanel({ id, active, children }: { id: string; active: boolean; children: ReactNode }): JSX.Element | null;
// Modal: open/onClose/title; Escape fecha; clique no backdrop fecha; foco no painel ao abrir;
// role=dialog aria-modal; footer opcional.
export interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode; }
export function Modal(props: ModalProps): JSX.Element | null;
// Disclosure: details/summary controlado
export function Disclosure({ summary, children, defaultOpen = false }: { summary: ReactNode; children: ReactNode; defaultOpen?: boolean }): JSX.Element;
```
- [ ] **Step 1: TDD — escrever os testes (todos juntos), rodar e ver falhar**
`src/ui/Field.test.tsx`:
```tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Field } from "./Field";
import { TextInput } from "./TextInput";
describe("Field", () => {
it("associa label ao input pelo name", () => {
render(
<Field label="Modelo" name="model">
<TextInput id="model" />
</Field>,
);
expect(screen.getByLabelText("Modelo")).toBeInTheDocument();
});
it("exibe erro com role alert", () => {
render(
<Field label="Endpoint" name="endpoint" error="obrigatório">
<TextInput id="endpoint" />
</Field>,
);
expect(screen.getByRole("alert")).toHaveTextContent("obrigatório");
});
it("marca campo opcional", () => {
render(
<Field label="Chave" name="key" optional>
<TextInput id="key" />
</Field>,
);
expect(screen.getByText("(opcional)")).toBeInTheDocument();
});
});
```
`src/ui/Tabs.test.tsx`:
```tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Tabs, TabPanel } from "./Tabs";
const tabs = [
{ id: "text", label: "Texto" },
{ id: "history", label: "Histórico" },
];
describe("Tabs", () => {
it("alterna aba ativa e chama onChange", async () => {
const onChange = vi.fn();
render(<Tabs tabs={tabs} activeId="text" onChange={onChange} />);
const historyTab = screen.getByRole("tab", { name: "Histórico" });
await userEvent.click(historyTab);
expect(onChange).toHaveBeenCalledWith("history");
});
it("marca aria-selected na aba ativa", () => {
render(<Tabs tabs={tabs} activeId="text" onChange={() => undefined} />);
expect(screen.getByRole("tab", { name: "Texto" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Histórico" })).toHaveAttribute("aria-selected", "false");
});
it("TabPanel só renderiza conteúdo quando ativo", () => {
render(
<>
<TabPanel id="text" active>
<p>Editor</p>
</TabPanel>
<TabPanel id="history" active={false}>
<p>Histórico</p>
</TabPanel>
</>,
);
expect(screen.getByText("Editor")).toBeInTheDocument();
expect(screen.queryByText("Histórico")).not.toBeInTheDocument();
});
});
```
`src/ui/Modal.test.tsx`:
```tsx
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Modal } from "./Modal";
describe("Modal", () => {
it("não renderiza quando fechado", () => {
render(<Modal open={false} onClose={() => undefined} title="Configurações">x</Modal>);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("fecha com Escape", async () => {
const onClose = vi.fn();
render(<Modal open onClose={onClose} title="Configurações">x</Modal>);
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledOnce();
});
it("fecha ao clicar no backdrop", () => {
const onClose = vi.fn();
const { container } = render(<Modal open onClose={onClose} title="Configurações">x</Modal>);
const backdrop = container.querySelector("[data-backdrop]");
expect(backdrop).not.toBeNull();
// fireEvent direto no backdrop: userEvent.click atingiria o centro
// (onde o painel está) e não o backdrop.
fireEvent.mouseDown(backdrop as HTMLElement);
expect(onClose).toHaveBeenCalledOnce();
});
});
```
`src/ui/Disclosure.test.tsx`:
```tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Disclosure } from "./Disclosure";
describe("Disclosure", () => {
it("abre e fecha o conteúdo", async () => {
render(
<Disclosure summary="Áudios recentes">
<p>lista</p>
</Disclosure>,
);
expect(screen.queryByText("lista")).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Áudios recentes"));
expect(screen.getByText("lista")).toBeInTheDocument();
});
});
```
Run: `npm test -- --run src/ui`
Expected: FAIL (módulos não existem).
- [ ] **Step 2: Implementar os componentes**
```tsx
// src/ui/Field.tsx
import type { ReactNode } from "react";
export interface FieldProps {
label: string;
name: string;
hint?: string;
error?: string | null;
optional?: boolean;
children: ReactNode;
}
export function Field({ label, name, hint, error, optional, children }: FieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={name} className="text-sm font-medium text-fg">
{label}
{optional && <span className="font-normal text-fg-subtle"> (opcional)</span>}
</label>
{children}
{hint && !error && <p className="text-xs text-fg-subtle">{hint}</p>}
{error && (
<p className="text-xs text-danger" role="alert">
{error}
</p>
)}
</div>
);
}
```
```tsx
// src/ui/TextInput.tsx
import type { InputHTMLAttributes } from "react";
export function TextInput({
invalid,
className = "",
...rest
}: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }) {
return (
<input
className={`h-9 rounded-md border bg-bg-inset px-3 text-sm text-fg transition-colors duration-fast placeholder:text-fg-subtle focus:border-accent focus:outline-none ${invalid ? "border-danger" : "border-border hover:border-border-strong"} ${className}`}
{...rest}
/>
);
}
```
```tsx
// src/ui/Select.tsx
import type { SelectHTMLAttributes } from "react";
export interface SelectOption {
value: string;
label: string;
}
export function Select({
options,
className = "",
...rest
}: SelectHTMLAttributes<HTMLSelectElement> & { options: SelectOption[] }) {
return (
<select
className={`h-9 rounded-md border border-border bg-bg-inset px-3 text-sm text-fg transition-colors duration-fast hover:border-border-strong focus:border-accent focus:outline-none ${className}`}
{...rest}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
```
```tsx
// src/ui/TextArea.tsx
import type { TextareaHTMLAttributes } from "react";
export function TextArea({
invalid,
className = "",
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }) {
return (
<textarea
className={`rounded-md border bg-bg-inset px-3 py-2 text-sm text-fg transition-colors duration-fast placeholder:text-fg-subtle focus:border-accent focus:outline-none ${invalid ? "border-danger" : "border-border hover:border-border-strong"} ${className}`}
{...rest}
/>
);
}
```
```tsx
// src/ui/Tabs.tsx
import type { ReactNode } from "react";
export interface TabItem {
id: string;
label: string;
}
export interface TabsProps {
tabs: TabItem[];
activeId: string;
onChange: (id: string) => void;
}
export function Tabs({ tabs, activeId, onChange }: TabsProps) {
return (
<div role="tablist" className="flex gap-1 border-b border-border px-3">
{tabs.map((tab) => {
const active = tab.id === activeId;
return (
<button
key={tab.id}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={active}
aria-controls={`panel-${tab.id}`}
onClick={() => onChange(tab.id)}
className={`-mb-px cursor-pointer border-b-2 px-3 py-2 text-sm transition-colors duration-fast ${active ? "border-accent text-fg" : "border-transparent text-fg-muted hover:border-border-strong hover:text-fg"}`}
>
{tab.label}
</button>
);
})}
</div>
);
}
export function TabPanel({ id, active, children }: { id: string; active: boolean; children: ReactNode }) {
if (!active) return null;
return (
<div role="tabpanel" id={`panel-${id}`} aria-labelledby={`tab-${id}`} className="pt-4">
{children}
</div>
);
}
```
```tsx
// src/ui/Modal.tsx
import { useEffect, useRef, type ReactNode } from "react";
import { IconButton } from "./IconButton";
import { X } from "lucide-react";
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
panelRef.current?.focus();
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
data-backdrop
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="flex max-h-[85vh] w-[560px] max-w-[90vw] flex-col overflow-hidden rounded-lg border border-border-strong bg-bg-elevated shadow-overlay outline-none"
>
<header className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 id="modal-title" className="text-lg font-semibold">
{title}
</h2>
<IconButton label="Fechar" onClick={onClose}>
<X size={18} />
</IconButton>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-border px-4 py-3">
{footer}
</footer>
)}
</aside>
</div>
);
}
```
```tsx
// src/ui/Disclosure.tsx
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
export interface DisclosureProps {
summary: ReactNode;
children: ReactNode;
defaultOpen?: boolean;
}
export function Disclosure({ summary, children, defaultOpen = false }: DisclosureProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<details
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="rounded-lg border border-border bg-bg-elevated"
>
<summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium text-fg-muted transition-colors duration-fast hover:text-fg [&::-webkit-details-marker]:hidden">
{summary}
<ChevronDown
size={16}
className={`ml-auto transition-transform duration-base ${open ? "rotate-180" : ""}`}
/>
</summary>
<div className="border-t border-border px-4 py-3">{children}</div>
</details>
);
}
```
- [ ] **Step 3: Rodar testes — PASS**
Run: `npm test -- --run src/ui`
Expected: PASS
- [ ] **Step 4: Stories** — `Field.stories.tsx`, `TextInput.stories.tsx`, `Select.stories.tsx`, `TextArea.stories.tsx`, `Tabs.stories.tsx`, `Modal.stories.tsx`, `Disclosure.stories.tsx` (padrão da Task 5; Modal com `open` true; Tabs com estado interno via `useState`).
- [ ] **Step 5: Verificação completa**
Run: `npm run typecheck && npm test -- --run && npm run build-storybook`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/ui
git commit -m "feat: primitivos Field, TextInput, Select, TextArea, Tabs, Modal, Disclosure"
```
---
### Task 7: Primitivos III (Waveform, Message, EmptyState)
**Files:**
- Create: `src/ui/Waveform.tsx` (+test+story)
- Create: `src/ui/Message.tsx` (+test+story)
- Create: `src/ui/EmptyState.tsx` (+test+story)
**Interfaces:**
- Produces:
```ts
// Waveform: 12 barras; altura = f(level, índice) quando active; barra mínima quando inativo
export function Waveform({ level, active }: { level: number; active: boolean }): JSX.Element;
// Message: role=status; tons neutral/danger/success; null quando sem children
export function Message({ children, tone = "neutral" }: { children?: ReactNode; tone?: "neutral" | "danger" | "success" }): JSX.Element | null;
// EmptyState: ícone + título + descrição opcional
export function EmptyState({ icon, title, description }: { icon?: ReactNode; title: string; description?: string }): JSX.Element;
```
- [ ] **Step 1: Testes (TDD)**
```tsx
// src/ui/Waveform.test.tsx
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { Waveform } from "./Waveform";
describe("Waveform", () => {
it("renderiza 12 barras", () => {
const { container } = render(<Waveform level={0.5} active />);
expect(container.querySelectorAll("[data-bar]")).toHaveLength(12);
});
it("barras ficam maiores com level alto", () => {
const low = render(<Waveform level={0.1} active />);
const high = render(<Waveform level={0.9} active />);
const lowHeight = low.container.querySelector("[data-bar]")?.getAttribute("style") ?? "";
const highHeight = high.container.querySelector("[data-bar]")?.getAttribute("style") ?? "";
expect(highHeight).not.toBe(lowHeight);
});
it("inativo usa altura mínima", () => {
const { container } = render(<Waveform level={0.9} active={false} />);
const style = container.querySelector("[data-bar]")?.getAttribute("style") ?? "";
expect(style).not.toContain("0.9");
});
});
```
```tsx
// src/ui/Message.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Message } from "./Message";
describe("Message", () => {
it("renderiza nada sem conteúdo", () => {
const { container } = render(<Message />);
expect(container).toBeEmptyDOMElement();
});
it("tem role status", () => {
render(<Message>Texto copiado.</Message>);
expect(screen.getByRole("status")).toHaveTextContent("Texto copiado.");
});
it("aplica tom danger", () => {
render(<Message tone="danger">Falhou</Message>);
expect(screen.getByRole("status")).toHaveClass("text-danger");
});
});
```
```tsx
// src/ui/EmptyState.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { EmptyState } from "./EmptyState";
describe("EmptyState", () => {
it("exibe título e descrição", () => {
render(<EmptyState title="Nenhum áudio" description="Grave para começar." />);
expect(screen.getByText("Nenhum áudio")).toBeInTheDocument();
expect(screen.getByText("Grave para começar.")).toBeInTheDocument();
});
});
```
Run: `npm test -- --run src/ui/Waveform.test.tsx src/ui/Message.test.tsx src/ui/EmptyState.test.tsx`
Expected: FAIL
- [ ] **Step 2: Implementar**
```tsx
// src/ui/Waveform.tsx
const BAR_COUNT = 12;
function barHeight(level: number, index: number): number {
const phase = 0.6 + Math.abs(Math.sin(index * 1.7)) * 0.4;
return Math.max(0.15, Math.min(1, level * phase));
}
export function Waveform({ level, active }: { level: number; active: boolean }) {
return (
<div className="flex h-8 items-center gap-1" aria-hidden="true">
{Array.from({ length: BAR_COUNT }, (_, index) => (
<span
key={index}
data-bar
className="w-1 rounded-full bg-accent/70 transition-[height] duration-fast ease-standard"
style={{ height: active ? `${barHeight(level, index)}em` : "0.25em" }}
/>
))}
</div>
);
}
```
```tsx
// src/ui/Message.tsx
import type { ReactNode } from "react";
export interface MessageProps {
children?: ReactNode;
tone?: "neutral" | "danger" | "success";
}
const TONE_CLASSES = {
neutral: "text-fg-muted",
danger: "text-danger",
success: "text-success",
} as const;
export function Message({ children, tone = "neutral" }: MessageProps) {
if (!children) return null;
return <p className={`text-sm ${TONE_CLASSES[tone]}`}>{children}</p>;
}
```
```tsx
// src/ui/EmptyState.tsx
import type { ReactNode } from "react";
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
}
export function EmptyState({ icon, title, description }: EmptyStateProps) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center">
{icon && <div className="text-fg-subtle">{icon}</div>}
<p className="text-sm font-medium text-fg-muted">{title}</p>
{description && <p className="text-xs text-fg-subtle">{description}</p>}
</div>
);
}
```
- [ ] **Step 3: Rodar testes — PASS**
Run: `npm test -- --run src/ui/Waveform.test.tsx src/ui/Message.test.tsx src/ui/EmptyState.test.tsx`
Expected: PASS
- [ ] **Step 4: Stories** (`Waveform.stories.tsx` com níveis 0.2/0.5/0.9; `Message.stories.tsx` com os 3 tons; `EmptyState.stories.tsx`).
- [ ] **Step 5: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run && npm run build-storybook`
Expected: PASS
```bash
git add src/ui
git commit -m "feat: primitivos Waveform, Message, EmptyState"
```
---
## Phase C — Estado e shell
### Task 8: Tipos compartilhados + cliente Tauri tipado + useAppSnapshot
**Files:**
- Create: `src/lib/types.ts`
- Modify: `src/lib/tauri.ts` (expandir com `commands`, `EVENTS`, `listen`)
- Create: `src/app/state/useAppSnapshot.ts` (+test)
- Create: `src/lib/tauri.test.ts`
**Interfaces:**
- Consumes: `src/app.ts:20-127` (tipos existentes — portar verbatim).
- Produces (contrato usado por todas as tasks seguintes):
```ts
// src/lib/types.ts — exporta (portados de src/app.ts:20-127):
export type RecordingStatus = "idle" | "recording" | "finalizing" | "transcribing";
export type TranscriptionMode = "localFirst" | "apiOnly";
export type TextProcessingProvider = "openai" | "anthropic" | "custom";
export type TextProcessingApiStyle = "openai" | "anthropic";
export type TextProcessingMode = "normalize" | "promptEngineering";
export interface TextProcessingSettings { provider; apiStyle; endpoint; model; apiKeyConfigured }
export interface OpenAiCompatibleSettings { endpoint; apiKeyConfigured; model; language: string | null }
export interface AppSettings { transcription: { mode; modelId; language: string | null; openaiCompatible }; insertion: { method: "clipboardPaste" | "keyTyping" | "manualCopy"; restoreClipboard: boolean; waylandManualFallback: boolean }; storage: { keepAudio: boolean; audioFormat: "wav" | "ogg" | "mp3" }; textProcessing: TextProcessingSettings; hotkey?: string | null }
export interface ModelWithStatus { model: {...}; status: "notInstalled" | "ready" | { error: string }; localPath?: string | null }
export interface TranscriptionAttempt { stage; audioPath; audioSizeBytes; sidecar; model; exitCode: number | null; stdoutTail; stderrTail; error: string | null }
export interface TranscriptDraft { text: string; lastMessage: string | null; lastAttempt?: TranscriptionAttempt }
export interface AudioHistoryItem { id: string; sizeBytes: number; modifiedAtMs: number }
export interface AppSnapshot { settings: AppSettings; recordingStatus: RecordingStatus; transcript: TranscriptDraft; models: ModelWithStatus[]; appVersion: string; audioHistory: AudioHistoryItem[] }
export interface DictationEvent { message: string; inserted: boolean; text?: string }
export interface InsertTextResponse { method: string; inserted: boolean; needs_review: boolean; message: string }
export interface NativeClient { invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> }
// src/lib/tauri.ts — exporta:
export function createNativeClient(): NativeClient;
export function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void>;
export const EVENTS: { toggleRecording; snapshot; dictationStarted; dictationFinished; dictationFailed } // valores "transcript://..." exatos
export const commands: { getAppSnapshot; startRecording; getAudioLevel; stopRecording; transcribePending; stopRecordingAndTranscribe; copyReadyText; requestInsertText; downloadAudio; getSettings; saveSettings; saveOpenaiApiKey; clearOpenaiApiKey; saveTextProcessingApiKey; clearTextProcessingApiKey; hasTextProcessingApiKey; getTextProcessingApiKey; processTranscriptText; setHotkey; clearHotkey; checkApiHealth; cancelPendingTranscription; minimizeWindow; closeWindow; dismissDictationOverlay }
// todas assinam (client: NativeClient, ...args) e chamam client.invoke com o nome snake_case exato
// src/app/state/useAppSnapshot.ts — exporta:
export const FALLBACK_SNAPSHOT: AppSnapshot;
export function useAppSnapshot(client: NativeClient): { snapshot: AppSnapshot; refresh: () => Promise<void> };
```
- [ ] **Step 1: Criar `src/lib/types.ts`** portando os tipos de `src/app.ts:20-127` (mesmos nomes e campos, incluindo `ModelWithStatus.model` completo e `fallbackSnapshot` → `FALLBACK_SNAPSHOT` em `useAppSnapshot.ts`). O `fallbackSnapshot` atual está em `src/app.ts:149-188`.
- [ ] **Step 2: Expandir `src/lib/tauri.ts`** — `listen` re-exportando `@tauri-apps/api/event`, `EVENTS` e o objeto `commands` com TODOS os comandos abaixo (nomes exatos, confirmados contra `src-tauri/src/ui_commands.rs`):
```
get_app_snapshot() -> AppSnapshot start_recording() -> AppSnapshot
get_audio_level() -> number stop_recording() -> AppSnapshot
transcribe_pending() -> AppSnapshot stop_recording_and_transcribe() -> AppSnapshot
copy_ready_text() -> string request_insert_text({ text }) -> InsertTextResponse
download_audio({ id }) -> boolean get_settings() -> AppSettings
save_settings({ settings }) -> AppSettings save_openai_api_key({ apiKey }) -> boolean
clear_openai_api_key() -> boolean save_text_processing_api_key({ provider, apiKey }) -> boolean
clear_text_processing_api_key({ provider }) -> boolean
has_text_processing_api_key({ provider }) -> boolean
get_text_processing_api_key({ provider }) -> string | null
process_transcript_text({ text, mode }) -> string
set_hotkey({ combination }) -> boolean clear_hotkey() -> boolean
check_api_health() -> boolean cancel_pending_transcription({ reason }) -> AppSnapshot
minimize_window() -> void close_window() -> void
dismiss_dictation_overlay() -> void
```
`src/lib/tauri.test.ts` (mock do cliente verificando nome/args):
```ts
import { describe, expect, it, vi } from "vitest";
import { commands, type NativeClient } from "./tauri";
function mockClient(): NativeClient & { calls: Array<[string, Record<string, unknown> | undefined]> } {
const calls: Array<[string, Record<string, unknown> | undefined>] = [];
const client = {
calls,
invoke: vi.fn((command: string, args?: Record<string, unknown>) => {
calls.push([command, args]);
return Promise.resolve(undefined);
}),
} as unknown as NativeClient & { calls: Array<[string, Record<string, unknown> | undefined>] };
return client;
}
describe("commands", () => {
it("usa o nome snake_case exato com os args certos", async () => {
const client = mockClient();
await commands.processTranscriptText(client, "texto", "normalize");
expect(client.calls).toEqual([
["process_transcript_text", { text: "texto", mode: "normalize" }],
]);
});
it("set_hotkey envia combination", async () => {
const client = mockClient();
await commands.setHotkey(client, "Ctrl+Shift+Space");
expect(client.calls).toEqual([["set_hotkey", { combination: "Ctrl+Shift+Space" }]]);
});
it("get_app_snapshot não envia args", async () => {
const client = mockClient();
await commands.getAppSnapshot(client);
expect(client.calls).toEqual([["get_app_snapshot", undefined]]);
});
});
```
- [ ] **Step 3: Criar `src/app/state/useAppSnapshot.ts`**
```ts
import { useCallback, useEffect, useState } from "react";
import { commands, listen, EVENTS } from "../../lib/tauri";
import { FALLBACK_SNAPSHOT, type AppSnapshot, type NativeClient } from "../../lib/types";
export function useAppSnapshot(client: NativeClient) {
const [snapshot, setSnapshot] = useState<AppSnapshot>(FALLBACK_SNAPSHOT);
const refresh = useCallback(async () => {
setSnapshot(await commands.getAppSnapshot(client));
}, [client]);
useEffect(() => {
void refresh();
let unlisten: (() => void) | undefined;
void listen<AppSnapshot>(EVENTS.snapshot, (payload) => {
setSnapshot(payload);
}).then((fn) => {
unlisten = fn;
});
return () => {
unlisten?.();
};
}, [client, refresh]);
return { snapshot, refresh };
}
```
- [ ] **Step 4: Teste do hook** — `src/app/state/useAppSnapshot.test.ts` (mock de `../../lib/tauri` com `vi.mock`, fake client, `renderHook`):
```ts
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act } from "react";
const snapshotPayload = {
settings: { transcription: { mode: "localFirst", modelId: "", language: null, openaiCompatible: { endpoint: "", apiKeyConfigured: false, model: "", language: null } }, insertion: { method: "clipboardPaste", restoreClipboard: false, waylandManualFallback: false }, storage: { keepAudio: true, audioFormat: "wav" }, textProcessing: { provider: "openai", apiStyle: "openai", endpoint: "", model: "", apiKeyConfigured: false }, hotkey: null },
recordingStatus: "idle",
transcript: { text: "", lastMessage: null },
models: [],
appVersion: "0.3.1",
audioHistory: [],
};
vi.mock("../../lib/tauri", () => ({
commands: {
getAppSnapshot: vi.fn(),
},
EVENTS: { snapshot: "transcript://snapshot" },
listen: vi.fn(() => Promise.resolve(() => undefined)),
}));
import { commands, listen } from "../../lib/tauri";
import { useAppSnapshot } from "./useAppSnapshot";
describe("useAppSnapshot", () => {
const client = { invoke: vi.fn() } as never;
beforeEach(() => {
vi.mocked(commands.getAppSnapshot).mockResolvedValue(snapshotPayload as never);
});
it("carrega o snapshot inicial", async () => {
const { result } = renderHook(() => useAppSnapshot(client));
await waitFor(() => {
expect(result.current.snapshot.appVersion).toBe("0.3.1");
});
});
it("refresh busca de novo", async () => {
const { result } = renderHook(() => useAppSnapshot(client));
await waitFor(() => expect(vi.mocked(commands.getAppSnapshot)).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.refresh();
});
expect(vi.mocked(commands.getAppSnapshot)).toHaveBeenCalledTimes(2);
});
it("escuta o evento de snapshot", async () => {
renderHook(() => useAppSnapshot(client));
await waitFor(() => expect(listen).toHaveBeenCalledWith("transcript://snapshot", expect.any(Function)));
});
});
```
- [ ] **Step 5: Rodar testes — PASS**
Run: `npm test -- --run src/lib src/app/state`
Expected: PASS
- [ ] **Step 6: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run`
Expected: PASS
```bash
git add src/lib src/app/state
git commit -m "feat: tipos compartilhados, cliente Tauri tipado e useAppSnapshot"
```
---
### Task 9: Máquina de ditado (useDictationMachine)
**Files:**
- Create: `src/app/state/useDictationMachine.ts` (+test)
**Interfaces:**
- Consumes: `commands`, `listen`, `EVENTS` (Task 8); `RecordingStatus`, `DictationEvent`, `NativeClient` (Task 8).
- Produces:
```ts
export type DictationMode = "hidden" | "recording" | "finalizing" | "transcribing" | "review";
export interface DictationMachine {
mode: DictationMode;
message: string;
reviewText: string;
setReviewText: (text: string) => void;
audioLevel: number;
dismiss: () => void;
copyReview: () => Promise<void>;
}
export function useDictationMachine(client: NativeClient, recordingStatus: RecordingStatus): DictationMachine;
```
Comportamento (portado de `src/app.ts` — eventos em 1883-1925, overlay em 590-663, polling de nível em ~1316):
- `dictation-started` → mode "recording", message = payload.message, cancela auto-hide.
- `dictation-finished` → se `inserted`: mode "hidden"; senão mode "review", message = payload.message, reviewText = payload.text ?? mantém.
- `dictation-failed` → mode "recording", message = `Falha: ${payload.message}`, auto-hide em 1600 ms.
- `recordingStatus === "finalizing"` → mode "finalizing", message "Finalizando áudio…".
- `recordingStatus === "transcribing"` → mode "transcribing", message "Transcrevendo…".
- `recordingStatus === "idle"` e mode era "finalizing"/"transcribing" → mode "hidden" (a Task 10 decide se o review abre; no fluxo do app, review só abre por evento dictation-finished).
- Enquanto mode === "recording": polling de `get_audio_level` a cada 100 ms → `audioLevel` (0..1); limpa ao sair.
- `dismiss()` → `commands.dismissDictationOverlay(client)` (erros ignorados) + mode "hidden".
- `copyReview()` → `commands.copyReadyText(client)`.
- [ ] **Step 1: Testes (TDD)** — `src/app/state/useDictationMachine.test.ts` (mock de `../../lib/tauri`; `vi.useFakeTimers()` para polling/auto-hide; capturar os handlers registrados via `listen`):
```ts
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
type Listener = (payload: unknown) => void;
const listeners = new Map<string, Listener>();
vi.mock("../../lib/tauri", () => ({
commands: {
getAudioLevel: vi.fn(),
dismissDictationOverlay: vi.fn(),
copyReadyText: vi.fn(),
},
EVENTS: {
dictationStarted: "transcript://dictation-started",
dictationFinished: "transcript://dictation-finished",
dictationFailed: "transcript://dictation-failed",
},
listen: vi.fn((event: string, handler: Listener) => {
listeners.set(event, handler);
return Promise.resolve(() => listeners.delete(event));
}),
}));
import { commands } from "../../lib/tauri";
import { useDictationMachine } from "./useDictationMachine";
const client = { invoke: vi.fn() } as never;
function emit(event: string, payload: unknown) {
act(() => {
listeners.get(event)?.(payload);
});
}
describe("useDictationMachine", () => {
beforeEach(() => {
listeners.clear();
vi.useFakeTimers();
vi.mocked(commands.getAudioLevel).mockResolvedValue(0.42 as never);
vi.mocked(commands.dismissDictationOverlay).mockResolvedValue(undefined as never);
vi.mocked(commands.copyReadyText).mockResolvedValue("texto" as never);
});
afterEach(() => {
vi.useRealTimers();
});
it("inicia oculto", () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
expect(result.current.mode).toBe("hidden");
});
it("dictation-started abre em recording com a mensagem", () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-started", { message: "Ouvindo…", inserted: false });
expect(result.current.mode).toBe("recording");
expect(result.current.message).toBe("Ouvindo…");
});
it("dictation-finished com inserted abre review com o texto", () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-finished", { message: "Reveja", inserted: false, text: "olá" });
expect(result.current.mode).toBe("review");
expect(result.current.reviewText).toBe("olá");
});
it("dictation-finished com inserted=true esconde", () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-finished", { message: "ok", inserted: true });
expect(result.current.mode).toBe("hidden");
});
it("recordingStatus transcribing mostra transcrevendo", () => {
const { result } = renderHook(() => useDictationMachine(client, "transcribing"));
expect(result.current.mode).toBe("transcribing");
expect(result.current.message).toBe("Transcrevendo…");
});
it("polling de nível roda enquanto grava e para depois", async () => {
const { result, rerender } = renderHook(
({ status }) => useDictationMachine(client, status),
{ initialProps: { status: "idle" as const } },
);
emit("transcript://dictation-started", { message: "Ouvindo…", inserted: false });
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(vi.mocked(commands.getAudioLevel)).toHaveBeenCalled();
expect(result.current.audioLevel).toBe(0.42);
rerender({ status: "idle" });
emit("transcript://dictation-finished", { message: "ok", inserted: true });
const calls = vi.mocked(commands.getAudioLevel).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(vi.mocked(commands.getAudioLevel).mock.calls.length).toBe(calls);
});
it("dictation-failed mostra erro e esconde após 1600ms", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-failed", { message: "boom", inserted: false });
expect(result.current.mode).toBe("recording");
expect(result.current.message).toBe("Falha: boom");
await act(async () => {
vi.advanceTimersByTime(1700);
});
expect(result.current.mode).toBe("hidden");
});
it("dismiss chama o comando e esconde", async () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-started", { message: "Ouvindo…", inserted: false });
await act(async () => {
result.current.dismiss();
});
expect(vi.mocked(commands.dismissDictationOverlay)).toHaveBeenCalledWith(client);
expect(result.current.mode).toBe("hidden");
});
it("setReviewText permite editar a revisão", () => {
const { result } = renderHook(() => useDictationMachine(client, "idle"));
emit("transcript://dictation-finished", { message: "Reveja", inserted: false, text: "olá" });
act(() => result.current.setReviewText("olá mundo"));
expect(result.current.reviewText).toBe("olá mundo");
});
});
```
- [ ] **Step 2: Rodar e confirmar falha**
Run: `npm test -- --run src/app/state/useDictationMachine.test.ts`
Expected: FAIL
- [ ] **Step 3: Implementar `src/app/state/useDictationMachine.ts`**
```ts
import { useCallback, useEffect, useRef, useState } from "react";
import { commands, listen, EVENTS } from "../../lib/tauri";
import type { DictationEvent, NativeClient, RecordingStatus } from "../../lib/types";
export type DictationMode = "hidden" | "recording" | "finalizing" | "transcribing" | "review";
export interface DictationMachine {
mode: DictationMode;
message: string;
reviewText: string;
setReviewText: (text: string) => void;
audioLevel: number;
dismiss: () => void;
copyReview: () => Promise<void>;
}
const AUTO_HIDE_MS = 1600;
const AUDIO_LEVEL_INTERVAL_MS = 100;
export function useDictationMachine(
client: NativeClient,
recordingStatus: RecordingStatus,
): DictationMachine {
const [mode, setMode] = useState<DictationMode>("hidden");
const [message, setMessage] = useState("Ouvindo…");
const [reviewText, setReviewText] = useState("");
const [audioLevel, setAudioLevel] = useState(0);
const hideTimerRef = useRef<number | undefined>(undefined);
const show = useCallback((nextMode: DictationMode, nextMessage: string) => {
if (hideTimerRef.current !== undefined) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = undefined;
}
setMode(nextMode);
setMessage(nextMessage);
}, []);
const hide = useCallback(() => {
setMode("hidden");
}, []);
const dismiss = useCallback(() => {
void commands.dismissDictationOverlay(client).catch(() => undefined);
hide();
}, [client, hide]);
const copyReview = useCallback(async () => {
await commands.copyReadyText(client);
}, [client]);
// Eventos do ditado global (hotkey)
useEffect(() => {
const unlisteners: Array<() => void> = [];
void listen<DictationEvent>(EVENTS.dictationStarted, (event) => {
show("recording", event.message);
}).then((fn) => unlisteners.push(fn));
void listen<DictationEvent>(EVENTS.dictationFinished, (event) => {
if (event.inserted) {
hide();
return;
}
show("review", event.message);
if (event.text !== undefined) setReviewText(event.text);
}).then((fn) => unlisteners.push(fn));
void listen<DictationEvent>(EVENTS.dictationFailed, (event) => {
show("recording", `Falha: ${event.message}`);
hideTimerRef.current = window.setTimeout(hide, AUTO_HIDE_MS);
}).then((fn) => unlisteners.push(fn));
return () => {
unlisteners.forEach((fn) => fn());
if (hideTimerRef.current !== undefined) {
window.clearTimeout(hideTimerRef.current);
}
};
}, [client, show, hide]);
// Snapshot dirige finalizando/transcrevendo (fluxo dentro do app)
useEffect(() => {
if (recordingStatus === "finalizing") {
show("finalizing", "Finalizando áudio…");
} else if (recordingStatus === "transcribing") {
show("transcribing", "Transcrevendo…");
}
}, [recordingStatus, show]);
// Polling de nível de áudio durante a gravação
useEffect(() => {
if (mode !== "recording") return;
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
if (cancelled) return;
try {
const level = await commands.getAudioLevel(client);
if (!cancelled) setAudioLevel(level);
} catch {
// nível indisponível — mantém o último valor
}
if (!cancelled) timer = window.setTimeout(tick, AUDIO_LEVEL_INTERVAL_MS);
};
timer = window.setTimeout(tick, AUDIO_LEVEL_INTERVAL_MS);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
setAudioLevel(0);
};
}, [client, mode]);
return { mode, message, reviewText, setReviewText, audioLevel, dismiss, copyReview };
}
```
- [ ] **Step 4: Rodar testes — PASS**
Run: `npm test -- --run src/app/state/useDictationMachine.test.ts`
Expected: PASS
- [ ] **Step 5: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run`
Expected: PASS
```bash
git add src/app/state/useDictationMachine.ts src/app/state/useDictationMachine.test.ts
git commit -m "feat: máquina de ditado (useDictationMachine)"
```
---
### Task 10: Shell do app + topbar + overlay de ditado
**Files:**
- Modify: `src/App.tsx` (substituir o esqueleto da Task 1)
- Create: `src/app/components/Topbar.tsx`
- Create: `src/app/components/DictationOverlay.tsx` (+test)
- Create: `src/app/App.test.tsx` (substituir `src/App.test.tsx` da Task 1)
- Delete: `src/App.test.tsx` (movido para `src/app/App.test.tsx`)
**Interfaces:**
- Consumes: `useAppSnapshot` (Task 8), `useDictationMachine` (Task 9), primitivos (Tasks 5-7), `commands` (Task 8).
- Produces:
```ts
// src/app/components/Topbar.tsx
export interface TopbarProps {
appVersion: string;
onOpenSettings: () => void;
onMinimize: () => void;
onClose: () => void;
}
export function Topbar(props: TopbarProps): JSX.Element;
// marca: logo (assets/brand/mark.png via import) + "Transcript" + versão; drag region data-tauri-drag-region;
// botão Configurações (IconButton) + minimizar/fechar (IconButton, comandos minimize_window/close_window)
// src/app/components/DictationOverlay.tsx
export interface DictationOverlayProps {
machine: DictationMachine;
onStop: () => void;
}
export function DictationOverlay(props: DictationOverlayProps): JSX.Element | null;
// null quando machine.mode === "hidden"; ver especificação visual abaixo
// src/App.tsx
export function App({ client = createNativeClient() }: { client?: NativeClient }): JSX.Element;
```
- [ ] **Step 1: Especificação visual do shell (fonte: proposta vencedora)**
A composição visual (disposição, espaçamentos, hierarquia) deve seguir `design/proposals/<vencedor>/main-empty.html` e `main-text.html` (janela principal) e `dictation-recording.html` / `dictation-review.html` (overlay). Estrutura obrigatória:
```tsx
// src/App.tsx
<div className="flex h-screen flex-col overflow-hidden">
<Topbar appVersion={snapshot.appVersion} onOpenSettings={() => setSettingsOpen(true)} onMinimize={...} onClose={...} />
<Tabs tabs={[{ id: "text", label: "Texto" }, { id: "history", label: "Histórico" }]} activeId={tab} onChange={setTab} />
<main className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<TabPanel id="text" active={tab === "text"}>
<EditorView client={client} snapshot={snapshot} refresh={refresh} onToggleRecording={toggleRecording} />
</TabPanel>
<TabPanel id="history" active={tab === "history"}>
<HistoryView client={client} snapshot={snapshot} />
</TabPanel>
</main>
<DictationOverlay machine={machine} onStop={toggleRecording} />
<Modal open={settingsOpen} onClose={closeSettings} title="Configurações" footer={...}>
<SettingsView client={client} snapshot={snapshot} refresh={refresh} onClose={closeSettings} onMessage={...} />
</Modal>
</div>
```
Onde, no App:
- `const { snapshot, refresh } = useAppSnapshot(client);`
- `const machine = useDictationMachine(client, snapshot.recordingStatus);`
- `toggleRecording`: portado de `src/app.ts:1378-1405` — se `recordingStatus === "recording"`: `stop_recording` → (se modo `apiOnly`: `check_api_health`, em erro `cancel_pending_transcription({ reason: "API indisponível: ..." })` e retorna) → `transcribe_pending`; se `"idle"`: `start_recording`. Sempre com `refresh()` após cada comando e `catch` → mensagem de erro.
- `closeSettings`: fecha o modal (foco volta ao botão Configurações — ver Task 12 para o fluxo completo de save).
- Listener legado: `listen(EVENTS.toggleRecording, () => void toggleRecording())` — hoje nenhum emissor no Rust (comentário: mantido por compatibilidade).
- Botões minimizar/fechar chamam `commands.minimizeWindow(client)` / `commands.closeWindow(client)` com catch silencioso (falha no navegador).
- [ ] **Step 2: Testes do shell** — `src/app/App.test.tsx` (mock de `../../lib/tauri` e telas ainda inexistentes? **Não**: as telas serão implementadas nas Tasks 11-13; para esta task, `EditorView`/`HistoryView`/`SettingsView` ainda não existem. Estratégia: criar stubs de import? Não — em vez disso, esta task implementa o shell com **placeholders reais**: `src/app/screens/EditorView.tsx`, `HistoryView.tsx`, `SettingsView.tsx` recebem implementação mínima real (renderizam as props que consomem, ex.: título + mensagem) e as Tasks 11-13 as completam. Assim o shell compila e testa de verdade.
`src/app/screens/EditorView.tsx` (mínimo real, expandido na Task 11):
```tsx
import type { NativeClient, AppSnapshot } from "../../lib/types";
export interface EditorViewProps {
client: NativeClient;
snapshot: AppSnapshot;
refresh: () => Promise<void>;
onToggleRecording: () => void;
}
export function EditorView({ snapshot }: EditorViewProps) {
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-fg-muted">{snapshot.transcript.lastMessage ?? "Pronto para gravar."}</p>
<textarea
className="min-h-40 w-full rounded-md border border-border bg-bg-inset p-3 text-sm text-fg"
value={snapshot.transcript.text}
readOnly
/>
</div>
);
}
```
`src/app/screens/HistoryView.tsx` (mínimo real, expandido na Task 13):
```tsx
import type { AppSnapshot, NativeClient } from "../../lib/types";
export interface HistoryViewProps {
client: NativeClient;
snapshot: AppSnapshot;
}
export function HistoryView({ snapshot }: HistoryViewProps) {
return (
<p className="text-sm text-fg-muted">
{snapshot.audioHistory.length} áudio(s) recente(s)
</p>
);
}
```
`src/app/screens/SettingsView.tsx` (mínimo real, expandido na Task 12):
```tsx
import type { AppSnapshot, NativeClient } from "../../lib/types";
export interface SettingsViewProps {
client: NativeClient;
snapshot: AppSnapshot;
refresh: () => Promise<void>;
onClose: () => void;
onMessage: (message: string) => void;
}
export function SettingsView(_props: SettingsViewProps) {
return <p className="text-sm text-fg-muted">Configurações (Task 12)</p>;
}
```
Testes do shell (`src/app/App.test.tsx`) — mock de `./screens/*` (vi.mock) + mock de `../../lib/tauri`:
```tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./App";
vi.mock("../../lib/tauri", () => ({
commands: { getAppSnapshot: vi.fn(), startRecording: vi.fn(), stopRecording: vi.fn(), transcribePending: vi.fn(), checkApiHealth: vi.fn(), cancelPendingTranscription: vi.fn(), minimizeWindow: vi.fn(), closeWindow: vi.fn(), getAudioLevel: vi.fn(), dismissDictationOverlay: vi.fn() },
EVENTS: { toggleRecording: "transcript://toggle-recording", snapshot: "transcript://snapshot", dictationStarted: "transcript://dictation-started", dictationFinished: "transcript://dictation-finished", dictationFailed: "transcript://dictation-failed" },
listen: vi.fn(() => Promise.resolve(() => undefined)),
createNativeClient: vi.fn(),
}));
import { commands } from "../../lib/tauri";
const baseSnapshot = {
settings: { transcription: { mode: "localFirst", modelId: "", language: null, openaiCompatible: { endpoint: "", apiKeyConfigured: false, model: "", language: null } }, insertion: { method: "clipboardPaste", restoreClipboard: false, waylandManualFallback: false }, storage: { keepAudio: true, audioFormat: "wav" }, textProcessing: { provider: "openai", apiStyle: "openai", endpoint: "", model: "", apiKeyConfigured: false }, hotkey: null },
recordingStatus: "idle",
transcript: { text: "", lastMessage: null },
models: [],
appVersion: "0.3.1",
audioHistory: [],
};
describe("App", () => {
beforeEach(() => {
vi.mocked(commands.getAppSnapshot).mockResolvedValue(baseSnapshot as never);
vi.mocked(commands.minimizeWindow).mockResolvedValue(undefined as never);
vi.mocked(commands.closeWindow).mockResolvedValue(undefined as never);
});
it("mostra as abas Texto e Histórico", async () => {
render(<App client={{ invoke: vi.fn() } as never} />);
expect(await screen.findByRole("tab", { name: "Texto" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Histórico" })).toBeInTheDocument();
});
it("minimizar e fechar chamam os comandos", async () => {
render(<App client={{ invoke: vi.fn() } as never} />);
await userEvent.click(screen.getByRole("button", { name: "Minimizar" }));
expect(commands.minimizeWindow).toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Fechar" }));
expect(commands.closeWindow).toHaveBeenCalled();
});
it("abre configurações pelo botão da topbar", async () => {
render(<App client={{ invoke: vi.fn() } as never} />);
await userEvent.click(await screen.findByRole("button", { name: "Configurações" }));
expect(await screen.findByRole("dialog", { name: "Configurações" })).toBeInTheDocument();
});
});
```
- [ ] **Step 3: TDD — rodar testes do shell e ver falhar** (App.tsx novo ainda não existe com as abas)
Run: `npm test -- --run src/app/App.test.tsx`
Expected: FAIL
- [ ] **Step 4: Implementar `Topbar.tsx`** (marca via `import markUrl from "../../assets/brand/mark.png"` — o arquivo existe; `data-tauri-drag-region` no header; versão em `<span>` discreto; IconButtons Minimizar/Fechar/Configurações com ícones lucide `Minus`/`X`/`Settings`).
- [ ] **Step 5: Implementar `DictationOverlay.tsx`**
```tsx
import { Copy, X } from "lucide-react";
import { Button, Pill, Waveform } from "../../ui";
import type { DictationMachine } from "../state/useDictationMachine";
export interface DictationOverlayProps {
machine: DictationMachine;
onStop: () => void;
}
export function DictationOverlay({ machine, onStop }: DictationOverlayProps) {
if (machine.mode === "hidden") return null;
const isReview = machine.mode === "review";
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center p-3">
<div className="pointer-events-auto flex w-full max-w-md flex-col gap-3 rounded-lg border border-border-strong bg-bg-elevated/95 px-4 py-3 shadow-pop backdrop-blur">
{isReview ? (
<>
<div className="flex items-center justify-between">
<strong className="text-sm">Revise antes de colar</strong>
<Pill tone="warning">Campo editável não confirmado</Pill>
</div>
<textarea
className="min-h-24 w-full rounded-md border border-border bg-bg-inset p-2 text-sm text-fg"
value={machine.reviewText}
onChange={(event) => machine.setReviewText(event.target.value)}
spellCheck
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" icon={<X size={16} />} onClick={machine.dismiss}>
Fechar
</Button>
<Button
variant="secondary"
icon={<Copy size={16} />}
onClick={() => void machine.copyReview()}
>
Copiar
</Button>
</div>
</>
) : (
<div className="flex items-center gap-3">
<Waveform level={machine.audioLevel} active={machine.mode === "recording"} />
<div className="min-w-0 flex-1">
<strong className="block text-sm">
{machine.mode === "recording"
? "Ouvindo"
: machine.mode === "finalizing"
? "Finalizando"
: "Transcrevendo"}
</strong>
<span className="block truncate text-xs text-fg-muted">{machine.message}</span>
</div>
{machine.mode === "recording" && (
<Button variant="ghost" icon={<X size={16} />} onClick={onStop}>
Parar
</Button>
)}
</div>
)}
</div>
</div>
);
}
```
Criar `src/ui/index.ts` re-exportando todos os primitivos (Button, IconButton, Kbd, Pill, Card, Field, TextInput, Select, TextArea, Tabs, TabPanel, Modal, Disclosure, Waveform, Message, EmptyState) para imports limpos (`../../ui`).
- [ ] **Step 6: Implementar `App.tsx` final do shell** (estrutura do Step 1; `toggleRecording` portado; listener legado; botões da topbar conectados).
- [ ] **Step 7: Rodar testes — PASS**
Run: `npm test -- --run src/app`
Expected: PASS
- [ ] **Step 8: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run && npm run build`
Expected: PASS
```bash
git add src/App.tsx src/app src/ui/index.ts
git commit -m "feat: shell do app com abas, topbar e overlay de ditado"
```
---
## Phase D — Telas
### Task 11: EditorView (editor + ações + Refinar com IA)
**Files:**
- Modify: `src/app/screens/EditorView.tsx` (substituir o mínimo da Task 10)
- Create: `src/app/screens/EditorView.test.tsx`
**Interfaces:**
- Consumes: `EditorViewProps` (Task 10): `{ client, snapshot, refresh, onToggleRecording }`.
- Produces: `EditorView` completo, consumido por `App` (Task 10).
**Comportamento (portado de `src/app.ts` — render 1238-1367, handlers 1407-1549):**
- **Status pill**: `snapshot.recordingStatus` → "Pronto" | "Gravando" | "Finalizando" | "Transcrevendo" (Pill com tom neutral; "Transcrevendo"/"Finalizando" desabilitam Gravar).
- **Mensagem**: `transient ?? snapshot.transcript.lastMessage ?? "Pronto para gravar."` — `transient` é estado local (setado por ações locais: "Texto copiado.", "Texto limpo.", "Campo limpo. Pronto para regravar.", "Arquivo gerado para download.", "Enviando texto para a IA…", "Texto normalizado pela IA.", "Prompt criado pela IA.", erros); `useEffect` limpa `transient` quando `snapshot.transcript.lastMessage` muda.
- **Botão Gravar** (primary, ícone Mic): label "Gravar" com `<Kbd>{hotkey}</Kbd>` quando `settings.hotkey`; "Parar" quando gravando; disabled + "Finalizando…"/"Transcrevendo…" nos estados correspondentes; `onClick` → `onToggleRecording`.
- **Ações**: Copiar (`navigator.clipboard.writeText(editorText)` → transient "Texto copiado."), Inserir (`commands.requestInsertText(client, editorText)` → transient = result.message), Salvar (Blob text/plain → download `transcricao.txt` → transient "Arquivo gerado para download."), Limpar (editor vazio → transient "Texto limpo."). Ações só habilitadas com texto não vazio.
- **Ações secundárias** (visíveis com texto): Continuar (`commands.startRecording` + refresh), Regravar (editor vazio + `commands.getAppSnapshot` + refresh → transient "Campo limpo. Pronto para regravar."), Expandir/Recolher (alterna editor expandido — classe no wrapper).
- **Painel "Refinar com IA"** (Disclosure/expansível, seguindo a proposta vencedora): modo Normalizar/Criar prompt (radio, fieldset com legend sr-only); botão primário com Sparkles → `commands.processTranscriptText(client, editorText, mode)`; durante o processamento: botão com label "Normalizando…"/"Criando prompt…", desabilitado; resultado substitui o texto do editor; hint: "Corrige a transcrição sem mudar o significado."; provider exibido (OpenAI/Anthropic/Custom — label portado de `textProviderLabel` em app.ts).
- **Editor**: TextArea controlado; texto vem de `snapshot.transcript.text`; edição local (o texto digitado NÃO vai para o backend até o próximo snapshot — comportamento atual: `editor.value` é o estado local após render; portar como `useState` inicializado do snapshot, atualizado quando snapshot muda e o valor não foi editado desde a última sincronização).
**Visual**: `design/proposals/<vencedor>/main-empty.html` e `main-text.html`.
- [ ] **Step 1: Testes (TDD)** — `src/app/screens/EditorView.test.tsx` (mock de `../../lib/tauri`; snapshot base igual ao da Task 10; `navigator.clipboard` mockado):
```tsx
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorView } from "./EditorView";
import { commands } from "../../lib/tauri";
vi.mock("../../lib/tauri", () => ({
commands: {
requestInsertText: vi.fn(),
processTranscriptText: vi.fn(),
startRecording: vi.fn(),
getAppSnapshot: vi.fn(),
},
EVENTS: {},
listen: vi.fn(() => Promise.resolve(() => undefined)),
}));
const snapshot = (overrides: Record<string, unknown> = {}) => ({
settings: { transcription: { mode: "localFirst", modelId: "", language: null, openaiCompatible: { endpoint: "", apiKeyConfigured: false, model: "", language: null } }, insertion: { method: "clipboardPaste", restoreClipboard: false, waylandManualFallback: false }, storage: { keepAudio: true, audioFormat: "wav" }, textProcessing: { provider: "openai", apiStyle: "openai", endpoint: "", model: "", apiKeyConfigured: false }, hotkey: "Ctrl+Shift+Space" },
recordingStatus: "idle",
transcript: { text: "olá mundo", lastMessage: null },
models: [],
appVersion: "0.3.1",
audioHistory: [],
...overrides,
});
const props = (overrides: Record<string, unknown> = {}) => ({
client: { invoke: vi.fn() } as never,
snapshot: snapshot(),
refresh: vi.fn(),
onToggleRecording: vi.fn(),
...overrides,
});
describe("EditorView", () => {
beforeEach(() => {
vi.mocked(commands.requestInsertText).mockResolvedValue({ method: "clipboardPaste", inserted: true, needs_review: false, message: "Texto inserido." } as never);
vi.mocked(commands.processTranscriptText).mockResolvedValue("texto normalizado" as never);
vi.mocked(commands.startRecording).mockResolvedValue(snapshot() as never);
vi.mocked(commands.getAppSnapshot).mockResolvedValue(snapshot() as never);
});
it("mostra o texto transcrito e o hotkey no botão", () => {
render(<EditorView {...props()} />);
expect(screen.getByDisplayValue("olá mundo")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Gravar/ })).toHaveTextContent("Ctrl+Shift+Space");
});
it("botão Gravar chama onToggleRecording", async () => {
const p = props();
render(<EditorView {...p} />);
await userEvent.click(screen.getByRole("button", { name: /Gravar/ }));
expect(p.onToggleRecording).toHaveBeenCalledOnce();
});
it("Inserir chama request_insert_text e mostra a mensagem", async () => {
render(<EditorView {...props()} />);
await userEvent.click(screen.getByRole("button", { name: "Inserir" }));
expect(commands.requestInsertText).toHaveBeenCalledWith(expect.anything(), "olá mundo");
expect(await screen.findByText("Texto inserido.")).toBeInTheDocument();
});
it("Refinar com IA substitui o texto", async () => {
render(<EditorView {...props()} />);
await userEvent.click(screen.getByRole("button", { name: "Normalizar texto" }));
expect(commands.processTranscriptText).toHaveBeenCalledWith(expect.anything(), "olá mundo", "normalize");
expect(await screen.findByDisplayValue("texto normalizado")).toBeInTheDocument();
});
it("status pill mostra Transcrevendo", () => {
render(<EditorView {...props({ snapshot: snapshot({ recordingStatus: "transcribing" }) })} />);
expect(screen.getByText("Transcrevendo")).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Rodar e confirmar falha**
Run: `npm test -- --run src/app/screens/EditorView.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implementar `EditorView.tsx`** conforme o comportamento especificado (composição visual da proposta vencedora; `useState` para texto local sincronizado com snapshot; helper `setTransient`; lógica de download via Blob; `textProviderLabel` portada de `src/app.ts`).
- [ ] **Step 4: Rodar testes — PASS**
Run: `npm test -- --run src/app/screens/EditorView.test.tsx`
Expected: PASS
- [ ] **Step 5: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run && npm run build`
Expected: PASS
```bash
git add src/app/screens/EditorView.tsx src/app/screens/EditorView.test.tsx
git commit -m "feat: EditorView com ações, status e painel Refinar com IA"
```
---
### Task 12: SettingsView (configurações em abas)
**Files:**
- Modify: `src/app/screens/SettingsView.tsx` (substituir o mínimo da Task 10)
- Create: `src/app/screens/SettingsView.test.tsx`
**Interfaces:**
- Consumes: `SettingsViewProps` (Task 10): `{ client, snapshot, refresh, onClose, onMessage }`.
- Produces: `SettingsView` completo (conteúdo + footer com Cancelar/Salvar), renderizado dentro do `Modal` do App (Task 10).
**Comportamento (portado de `src/app.ts` 1330-1367 e 1563-1745):**
- **Abas internas** (Tabs): `dictation` (Ditado), `transcription` (Transcrição), `assistant` (Assistente de texto), `audio` (Áudios).
- **Ditado**: hotkey (TextInput, placeholder "Ctrl+Shift+Space"), método de inserção (Select: "clipboardPaste" → "Clipboard + Ctrl+V automático", "manualCopy" → "Copiar e revisar manualmente" — note: o backend também aceita "keyTyping"; mantenha a opção atual de app.ts que só oferece 2).
- **Transcrição**: modo (2 radio-cards portados de `buildModeOption`: "localFirst" → "Transcrição local" / "Funciona offline, direto no computador."; "apiOnly" → "Servidor local" / "Usa o serviço de transcrição em execução na máquina."), idioma (TextInput, placeholder "pt"), endpoint/modelo (TextInput), chave (TextInput type=password + botões Salvar chave / Remover + status "api_key salva no credential manager." | "Nenhuma api_key salva."), nota do modo local ("Whisper base multilingual, sem api_key.").
- **Assistente de texto**: provedor (Select: openai/anthropic/custom), formato da API (Select: openai/anthropic; visível quando custom — comportamento atual: `syncTextProviderFields`), endpoint, modelo, chave (password + Mostrar/Remover + status), nota de segurança ("A chave fica criptografada no gerenciador de credenciais do sistema; só é exibida quando você pedir.").
- **Áudios**: nota "Os 10 últimos WAVs ficam guardados no computador para baixar quando precisar."
- **Validação no Salvar** (portada de app.ts 1653-1680): modo apiOnly exige endpoint+modelo; assistente exige endpoint+modelo; erros exibidos com role=alert + aria-invalid nos campos.
- **Salvar** (app.ts 1682-1744): salva chaves se preenchidas (`save_openai_api_key`, `save_text_processing_api_key`), hotkey (`set_hotkey`/`clear_hotkey`), monta `newSettings: AppSettings` (mesma estrutura exata de app.ts 1699-1731, incluindo `apiKeyConfigured` e `textProcessingKeyConfigured`), `save_settings(newSettings)`, `refresh()`, `onClose()`, mensagem "Configurações salvas. Próximo ditado usará Docker/API." ou "...Whisper integrado." conforme modo.
- **Cancelar/Fechar**: `onClose` (o App restaura o foco no botão Configurações — ver Task 10 `closeSettings`).
**Visual**: `design/proposals/<vencedor>/settings.html`.
- [ ] **Step 1: Testes (TDD)** — `src/app/screens/SettingsView.test.tsx` (mock de `../../lib/tauri`; snapshot com settings preenchidos):
```tsx
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsView } from "./SettingsView";
import { commands } from "../../lib/tauri";
vi.mock("../../lib/tauri", () => ({
commands: {
saveSettings: vi.fn(),
setHotkey: vi.fn(),
clearHotkey: vi.fn(),
saveOpenaiApiKey: vi.fn(),
saveTextProcessingApiKey: vi.fn(),
},
EVENTS: {},
listen: vi.fn(() => Promise.resolve(() => undefined)),
}));
const snapshot = () => ({
settings: {
transcription: { mode: "localFirst", modelId: "", language: "pt", openaiCompatible: { endpoint: "http://127.0.0.1:8000/v1/stt/transcribe", apiKeyConfigured: false, model: "nvidia/parakeet-tdt-0.6b-v3", language: "pt" } },
insertion: { method: "clipboardPaste", restoreClipboard: false, waylandManualFallback: false },
storage: { keepAudio: true, audioFormat: "wav" },
textProcessing: { provider: "openai", apiStyle: "openai", endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt-4o-mini", apiKeyConfigured: false },
hotkey: "Ctrl+Shift+Space",
},
recordingStatus: "idle",
transcript: { text: "", lastMessage: null },
models: [],
appVersion: "0.3.1",
audioHistory: [],
});
const props = () => ({
client: { invoke: vi.fn() } as never,
snapshot: snapshot(),
refresh: vi.fn(),
onClose: vi.fn(),
onMessage: vi.fn(),
});
describe("SettingsView", () => {
beforeEach(() => {
vi.mocked(commands.saveSettings).mockResolvedValue(snapshot().settings as never);
vi.mocked(commands.setHotkey).mockResolvedValue(true as never);
vi.mocked(commands.clearHotkey).mockResolvedValue(true as never);
vi.mocked(commands.saveOpenaiApiKey).mockResolvedValue(true as never);
vi.mocked(commands.saveTextProcessingApiKey).mockResolvedValue(true as never);
});
it("mostra as 4 abas", () => {
render(<SettingsView {...props()} />);
for (const label of ["Ditado", "Transcrição", "Assistente de texto", "Áudios"]) {
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
}
});
it("salva settings e fecha", async () => {
const p = props();
render(<SettingsView {...p} />);
await userEvent.click(screen.getByRole("button", { name: "Salvar" }));
expect(commands.saveSettings).toHaveBeenCalledTimes(1);
expect(p.onClose).toHaveBeenCalledOnce();
expect(p.refresh).toHaveBeenCalledOnce();
});
it("valida endpoint/modelo no modo API", async () => {
const apiSnapshot = snapshot();
apiSnapshot.settings.transcription.mode = "apiOnly";
apiSnapshot.settings.transcription.openaiCompatible.endpoint = "";
render(<SettingsView {...props({ snapshot: apiSnapshot })} />);
await userEvent.click(screen.getByRole("button", { name: "Salvar" }));
expect(screen.getByRole("alert")).toHaveTextContent(/Endpoint e modelo são obrigatórios/);
expect(commands.saveSettings).not.toHaveBeenCalled();
});
it("hotkey vazio chama clear_hotkey", async () => {
render(<SettingsView {...props()} />);
const hotkey = screen.getByLabelText("Atalho global");
await userEvent.clear(hotkey);
await userEvent.click(screen.getByRole("button", { name: "Salvar" }));
expect(commands.clearHotkey).toHaveBeenCalledOnce();
expect(commands.setHotkey).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Rodar e confirmar falha**
Run: `npm test -- --run src/app/screens/SettingsView.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implementar `SettingsView.tsx`** (abas internas com estado local; campos controlados inicializados do snapshot; validação e fluxo de save portados; `Mostrar` alterna type password/text).
- [ ] **Step 4: Rodar testes — PASS**
Run: `npm test -- --run src/app/screens/SettingsView.test.tsx`
Expected: PASS
- [ ] **Step 5: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run && npm run build`
Expected: PASS
```bash
git add src/app/screens/SettingsView.tsx src/app/screens/SettingsView.test.tsx
git commit -m "feat: SettingsView com abas e fluxo de save portado"
```
---
### Task 13: HistoryView (histórico de áudios + diagnóstico)
**Files:**
- Modify: `src/app/screens/HistoryView.tsx` (substituir o mínimo da Task 10)
- Create: `src/app/screens/HistoryView.test.tsx`
**Interfaces:**
- Consumes: `HistoryViewProps` (Task 10): `{ client, snapshot }`.
- Produces: `HistoryView` completo.
**Comportamento (portado de `src/app.ts` — renderAudioHistory 1182-1235, handlers 1498-1501):**
- **Lista de áudios**: `snapshot.audioHistory` (ordenada como vem, mais recente primeiro); cada item mostra nome (id) + tamanho formatado (`formatBytes` portado: B/KB/MB) + botão de download (IconButton, `commands.downloadAudio(client, id)`).
- **Botão "Baixar último áudio"** (secondary, visível quando há itens): `commands.downloadAudio(client, snapshot.audioHistory[0].id)`.
- **Empty state**: EmptyState "Nenhum áudio gravado ainda." com ícone Mic.
- **Diagnóstico**: Disclosure "Detalhes" com `<pre>` monoespaçado contendo `formatAttempt(snapshot.transcript.lastAttempt)` (função portada de app.ts 295-305) ou "—" quando ausente.
**Visual**: `design/proposals/<vencedor>/history.html`.
- [ ] **Step 1: Testes (TDD)** — `src/app/screens/HistoryView.test.tsx`:
```tsx
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HistoryView } from "./HistoryView";
import { commands } from "../../lib/tauri";
vi.mock("../../lib/tauri", () => ({
commands: { downloadAudio: vi.fn() },
EVENTS: {},
listen: vi.fn(() => Promise.resolve(() => undefined)),
}));
const snapshot = (audioHistory: unknown[] = []) => ({
settings: { transcription: { mode: "localFirst", modelId: "", language: null, openaiCompatible: { endpoint: "", apiKeyConfigured: false, model: "", language: null } }, insertion: { method: "clipboardPaste", restoreClipboard: false, waylandManualFallback: false }, storage: { keepAudio: true, audioFormat: "wav" }, textProcessing: { provider: "openai", apiStyle: "openai", endpoint: "", model: "", apiKeyConfigured: false }, hotkey: null },
recordingStatus: "idle",
transcript: { text: "", lastMessage: null },
models: [],
appVersion: "0.3.1",
audioHistory,
});
describe("HistoryView", () => {
beforeEach(() => {
vi.mocked(commands.downloadAudio).mockResolvedValue(true as never);
});
it("mostra empty state sem áudios", () => {
render(<HistoryView client={{ invoke: vi.fn() } as never} snapshot={snapshot()} />);
expect(screen.getByText("Nenhum áudio gravado ainda.")).toBeInTheDocument();
});
it("lista áudios com tamanho formatado", () => {
render(
<HistoryView
client={{ invoke: vi.fn() } as never}
snapshot={snapshot([{ id: "gravacao-1.wav", sizeBytes: 1536, modifiedAtMs: 0 }])}
/>,
);
expect(screen.getByText("gravacao-1.wav")).toBeInTheDocument();
expect(screen.getByText(/1[,.]5 KB/)).toBeInTheDocument();
});
it("Baixar último áudio chama download_audio com o id mais recente", async () => {
render(
<HistoryView
client={{ invoke: vi.fn() } as never}
snapshot={snapshot([
{ id: "b.wav", sizeBytes: 1, modifiedAtMs: 2 },
{ id: "a.wav", sizeBytes: 1, modifiedAtMs: 1 },
])}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Baixar último áudio" }));
expect(commands.downloadAudio).toHaveBeenCalledWith(expect.anything(), "b.wav");
});
it("mostra diagnóstico vazio como —", () => {
render(<HistoryView client={{ invoke: vi.fn() } as never} snapshot={snapshot()} />);
expect(screen.getByText("—")).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Rodar e confirmar falha**
Run: `npm test -- --run src/app/screens/HistoryView.test.tsx`
Expected: FAIL
- [ ] **Step 3: Implementar `HistoryView.tsx`** (comportamento acima; `formatBytes` portado de app.ts:289-293: `1 KB = 1024`, `n < 1024 → "N B"`, `n < 1024² → "X KB"` com 1 casa, senão MB).
- [ ] **Step 4: Rodar testes — PASS**
Run: `npm test -- --run src/app/screens/HistoryView.test.tsx`
Expected: PASS
- [ ] **Step 5: Verificação + Commit**
Run: `npm run typecheck && npm test -- --run && npm run build`
Expected: PASS
```bash
git add src/app/screens/HistoryView.tsx src/app/screens/HistoryView.test.tsx
git commit -m "feat: HistoryView com lista de áudios e diagnóstico"
```
---
## Phase E — Cutover e fechamento
### Task 14: Cutover — remover o front antigo e verificar no Tauri
**Files:**
- Delete: `src/app.ts`, `src/main.ts`, `src/styles.css` (substituído por `src/styles.css` novo — o arquivo já foi sobrescrito na Task 4; confirmar que não há mais referências a classes antigas), `src/app.test.ts`
- Modify: `src/main.tsx` (se precisar)
- Verify: `src-tauri/src/lib.rs` (nenhuma mudança esperada — eventos/comandos já registrados)
**Interfaces:**
- Consumes: tudo das Tasks 1-13.
- Produces: repo sem o front antigo; `npm run build` gera só o bundle React.
- [ ] **Step 1: Verificar que nada importa os arquivos antigos**
Run: `npm run typecheck`
Expected: PASS (se houver erro de import, ajustar o importador — só o `main.ts` e `app.test.ts` usam `app.ts`; ambos serão deletados)
- [ ] **Step 2: Deletar os arquivos antigos**
```bash
git rm src/app.ts src/main.ts src/app.test.ts
```
(mover as explorações de design antigas para arquivo: `mkdir -p design/archive && git mv design/transcript-design-exploration.html design/archive/ && git mv design/transcript-design-exploration-v2.html design/archive/`)
- [ ] **Step 3: Verificação estática completa**
Run: `npm run typecheck && npm test -- --run && npm run build && npm run build-storybook`
Expected: todos PASS
- [ ] **Step 4: Verificação manual no Tauri (obrigatória)**
Run: `npm run tauri:dev`
Checklist:
- [ ] Janela abre 640×520, redimensionável, topbar frameless com marca + versão.
- [ ] Abas Texto/Histórico alternam.
- [ ] Hotkey global `Ctrl+Shift+Space`: 1º toque abre o overlay gravando (waveform anima, "Ouvindo…"); 2º toque transcreve; com campo ativo o texto é inserido; sem campo ativo, abre a revisão ("Revise antes de colar") com o texto.
- [ ] Editor: Gravar/Parar, Copiar, Inserir, Salvar (baixa transcricao.txt), Limpar, Continuar, Regravar, Expandir.
- [ ] Refinar com IA: Normalizar e Criar prompt funcionam com provedor configurado.
- [ ] Configurações: abas, salvar persiste (fechar e reabrir), validação de API-only.
- [ ] Histórico: áudios listados, download abre o diálogo nativo.
- [ ] Diagnóstico mostra o último attempt.
- [ ] Tray: Mostrar app restaura a janela no tamanho 640×520.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor: remove front antigo (vanilla) após cutover para React"
```
---
### Task 15: Design book (markdown) + README
**Files:**
- Create: `docs/design-book/README.md`
- Create: `docs/design-book/tokens.md`
- Create: `docs/design-book/components.md`
- Create: `docs/design-book/contributing.md`
- Modify: `README.md` (seção Arquitetura: `src/app.ts`/`src/styles.css` → estrutura nova)
**Interfaces:**
- Consumes: tokens (Task 4), primitivos (Tasks 5-7), direção vencedora (Task 3).
- Produces: design book navegável a partir de `docs/design-book/README.md`.
- [ ] **Step 1: `docs/design-book/README.md`** — visão geral: propósito, como navegar (tokens → componentes → contribuição), link do Storybook (`npm run storybook`), princípios do design (5-8 princípios derivados da proposta vencedora: velocidade de uso, clareza, dark-first, acessibilidade, consistência via tokens).
- [ ] **Step 2: `docs/design-book/tokens.md`** — tabela com TODOS os tokens de `src/ui/tokens.css` (nome, valor dark, valor light, uso): cores, tipografia, raio, sombra, motion. Gerar manualmente a partir do arquivo (fonte única: o código; o markdown é a documentação).
- [ ] **Step 3: `docs/design-book/components.md`** — um bloco por primitivo (Button, IconButton, Kbd, Pill, Card, Field, TextInput, Select, TextArea, Tabs/TabPanel, Modal, Disclosure, Waveform, Message, EmptyState): propósito (1-2 linhas), variantes/props principais, exemplo de uso (code block TSX), regras de acessibilidade.
- [ ] **Step 4: `docs/design-book/contributing.md`** — como adicionar token (editar `src/ui/tokens.css` + atualizar `tokens.md`) e como adicionar componente (criar em `src/ui/` + teste + story + entrada em `components.md`); regra: nenhum valor hardcoded fora dos tokens; commits em pt-BR.
- [ ] **Step 5: Atualizar `README.md`** — seção Arquitetura: substituir o bloco `src/` atual por:
```text
src/
main.tsx Entry React
app/ Shell, telas e estado (EditorView, SettingsView, HistoryView,
DictationOverlay, useDictationMachine, useAppSnapshot)
ui/ Design system: primitivos (Button, Field, Tabs, Modal...) + tokens.css
lib/ Cliente Tauri tipado (types.ts, tauri.ts)
```
- [ ] **Step 6: Verificação**
Run: `npm run typecheck && npm run build-storybook`
Expected: PASS. Conferir que todos os links internos do design book apontam para arquivos existentes.
- [ ] **Step 7: Commit**
```bash
git add docs/design-book README.md
git commit -m "docs: design book (princípios, tokens, componentes, contribuição)"
```
---
### Task 16: Auditoria de consistência e fechamento
**Files:**
- Modify: o que a auditoria apontar (esperado: ajustes pontuais de CSS/classes).
**Interfaces:**
- Consumes: design book (Task 15), telas (Tasks 10-13), tokens (Task 4).
- Produces: veredito de conformidade registrado em `docs/design-book/audit-2026-08-08.md`.
- [ ] **Step 1: Disparar 1 agente `designer` (auditoria)**
> Audite a implementação do front-end (src/ui, src/app, src/styles.css) contra
> `docs/design-book/` (princípios, tokens, componentes) e a proposta vencedora
> `design/proposals/<vencedor>/`. Verifique: uso consistente de tokens (sem cores/heights
> hardcoded fora de tokens.css), primitivos usados em vez de divs soltas, acessibilidade
> básica (focus-visible, aria nas abas/modal), paridade com os mockups vencedores.
> Produza `docs/design-book/audit-2026-08-08.md` com: itens conformes, não-conformidades
> (arquivo + linha + correção exata), e nada de opiniões fora do escopo. Não rode build/testes.
- [ ] **Step 2: Aplicar as correções listadas** (uma a uma, re-verificando `npm run typecheck` após cada lote).
- [ ] **Step 3: Verificação final completa**
Run: `npm run typecheck && npm test -- --run && npm run build && npm run build-storybook`
Expected: todos PASS
- [ ] **Step 4: Re-rodar o checklist manual da Task 14** (itens afetados pela auditoria).
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "chore: auditoria de consistência do design system"
```
---
## Self-Review (executado pelo autor do plano)
- **Cobertura do spec:** stack React/Tailwind (T1) · concorrência 3 agentes (T2/T3) · tokens dark+light (T4) · primitivos+Storybook (T5-7) · estado/overlay (T8-10) · telas Editor/Settings/History (T11-13) · cutover e janela redimensionável (T1/T14) · design book (T15) · auditoria final (T16) · marca intocada (nenhuma task mexe em assets/brand ou nome) · backend intocado (T1 só muda 2 constantes de janela, documentado).
- **Sem placeholders:** todos os steps têm código ou fonte exata (`src/app.ts:<linhas>`, `design/proposals/<vencedor>/` registrado em README na T3).
- **Consistência de tipos:** `NativeClient`/`commands`/`EVENTS` definidos na T8 e usados por T9-13; `DictationMachine` definido na T9, consumido na T10; props das telas definidas na T10 (stubs reais) e completadas na T11-13; tokens da T4 usados por todas; `FALLBACK_SNAPSHOT` e tipos em `lib/types.ts` (T8). Nomes de comandos conferidos contra `src-tauri/src/ui_commands.rs`.