2837 lines
104 KiB
Markdown
2837 lines
104 KiB
Markdown
# 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`.
|