104 KiB
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_WIDTHeAPP_WINDOW_HEIGHTemsrc-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.tsxexportaexport function App({ client = createNativeClient() }: { client?: NativeClient })(esqueleto mínimo: topbar com marca + versão + botão de config; ainda sem abas).src/main.tsxmonta<App />em#app.src/test/setup.tsregistra 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
jsxnotsconfig.json(dentro decompilerOptions)
"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.htmlpara 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.tsxmí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.cssbase (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.tscom o mínimo para o App compilar — apenasNativeClientecreateNativeClientpor ora; a Task 8 expande comcommandseEVENTS.
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(campoVencedora:). Todas as tasks seguintes usamdesign/proposals/<vencedor>/como fonte da direção visual. -
Step 1: Disparar 3 agentes
designerem 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(oulocal://brief.md). Produza a direção visual completa conforme o brief, no diretóriodesign/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 comopentrue; Tabs com estado interno viauseState). -
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.tsxcom níveis 0.2/0.5/0.9;Message.stories.tsxcom 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 comcommands,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.tsportando os tipos desrc/app.ts:20-127(mesmos nomes e campos, incluindoModelWithStatus.modelcompleto efallbackSnapshot→FALLBACK_SNAPSHOTemuseAppSnapshot.ts). OfallbackSnapshotatual está emsrc/app.ts:149-188. -
Step 2: Expandir
src/lib/tauri.ts—listenre-exportando@tauri-apps/api/event,EVENTSe o objetocommandscom TODOS os comandos abaixo (nomes exatos, confirmados contrasrc-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/tauricomvi.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→ seinserted: 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_levela 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 vialisten):
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(substituirsrc/App.test.tsxda Task 1) - Delete:
src/App.test.tsx(movido parasrc/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 desrc/app.ts:1378-1405— serecordingStatus === "recording":stop_recording→ (se modoapiOnly:check_api_health, em errocancel_pending_transcription({ reason: "API indisponível: ..." })e retorna) →transcribe_pending; se"idle":start_recording. Sempre comrefresh()após cada comando ecatch→ 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/taurie telas ainda inexistentes? Não: as telas serão implementadas nas Tasks 11-13; para esta task,EditorView/HistoryView/SettingsViewainda 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.tsxrecebem 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 viaimport markUrl from "../../assets/brand/mark.png"— o arquivo existe;data-tauri-drag-regionno header; versão em<span>discreto; IconButtons Minimizar/Fechar/Configurações com ícones lucideMinus/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.tsxfinal do shell (estrutura do Step 1;toggleRecordingportado; 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:
EditorViewcompleto, consumido porApp(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);useEffectlimpatransientquandosnapshot.transcript.lastMessagemuda. - Botão Gravar (primary, ícone Mic): label "Gravar" com
<Kbd>{hotkey}</Kbd>quandosettings.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 → downloadtranscricao.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 detextProviderLabelem 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 comouseStateinicializado 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.clipboardmockado):
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.tsxconforme o comportamento especificado (composição visual da proposta vencedora;useStatepara texto local sincronizado com snapshot; helpersetTransient; lógica de download via Blob;textProviderLabelportada desrc/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:
SettingsViewcompleto (conteúdo + footer com Cancelar/Salvar), renderizado dentro doModaldo 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), montanewSettings: AppSettings(mesma estrutura exata de app.ts 1699-1731, incluindoapiKeyConfiguredetextProcessingKeyConfigured),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 10closeSettings).
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;Mostraralterna 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:
HistoryViewcompleto.
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 (formatBytesportado: 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 contendoformatAttempt(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;formatBytesportado 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 porsrc/styles.cssnovo — 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 buildgera 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 desrc/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 (editarsrc/ui/tokens.css+ atualizartokens.md) e como adicionar componente (criar emsrc/ui/+ teste + story + entrada emcomponents.md); regra: nenhum valor hardcoded fora dos tokens; commits em pt-BR. -
Step 5: Atualizar
README.md— seção Arquitetura: substituir o blocosrc/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 vencedoradesign/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. Produzadocs/design-book/audit-2026-08-08.mdcom: 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 typecheckapó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/EVENTSdefinidos na T8 e usados por T9-13;DictationMachinedefinido 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_SNAPSHOTe tipos emlib/types.ts(T8). Nomes de comandos conferidos contrasrc-tauri/src/ui_commands.rs.