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

104 KiB
Raw Blame History

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//); 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

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
"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
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)
"jsx": "react-jsx"
  • Step 5: Criar src/test/setup.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
<script type="module" src="/src/main.tsx"></script>

(título, descrição, favicon e <main id="app"> permanecem)

  • Step 7: Criar src/main.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)
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)
@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.
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)
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)
{
  "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.
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
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

# 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
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:

# 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
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.

/* ===== 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)
@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
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):
// 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:

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:

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
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
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
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)
// 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>
	);
}
// 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>
	);
}
// 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>
	);
}
// 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):

// 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");
	});
});
// 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
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:
// 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:

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:

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:

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:

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
// 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>
	);
}
// 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}
		/>
	);
}
// 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>
	);
}
// 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}
		/>
	);
}
// 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>
	);
}
// 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>
	);
}
// 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
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:
// 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)
// 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");
	});
});
// 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");
	});
});
// 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
// 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>
	);
}
// 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>;
}
// 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

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):
// 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):

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
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):
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

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:
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):

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
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

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:
// 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:

// 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):

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):

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):

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:

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

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

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):
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

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):
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

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:
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

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
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

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:

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
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

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.