diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..26a7ea48 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,38 @@ +# Project-local Claude instructions + +This repo's renderer (`src/renderer/`) uses: + +- **Panda CSS** for styling. See `.claude/rules/panda-css.md`. +- **Radix UI** primitives wrapped under `src/renderer/src/components/ui/`. + See `.claude/rules/radix-ui.md`. +- **TanStack Router** for file-based routing in `src/renderer/src/routes/`. +- **TanStack React Query** for backend data; query/mutation factories live in + `src/renderer/src/services/aymurai/queries.ts`. +- **i18next** for Spanish UI strings under + `src/renderer/src/constants/i18n/locales/es/`. + +When adding code to the renderer, the rules above apply. When uncertain about a +library API, prefer `mcp__plugin_context7_context7__query-docs` over guessing +from training data. + +## Tooling + +- Package manager: **pnpm** (`pnpm-lock.yaml` is authoritative). `npm install` + is unsupported in this repo. +- `pnpm prepare` runs `panda codegen && lefthook install`. Run after a fresh + clone or after pulling changes that touch `panda.config.ts`. +- Pre-commit gate (lefthook): biome (autoformat + lint), panda codegen, + forbidden-pattern grep (`console.log`, `debugger`, leftover merge markers, + `.only(`, `@stitches/react`). +- Pre-push gate: `pnpm typecheck` and `pnpm knip`. + +## Migration in progress + +Stitches CSS-in-JS is being replaced by Panda CSS. Files under `components/ui/`, +`components/voice-to-text/`, `components/finish/`, `components/anonymizer/`, +`components/home/`, and `components/layout/` are already on Panda. Older +components (under `components/{checkbox,radio,file-*,validation-form,...}` and +`components/{label,title,text,subtitle,spinner,uncontrolled-input}`) still +import from `@/styles/stitches.config`. Migrate any of these you touch; the +biome rule banning `@stitches/react` is **not yet enabled** because of this +backlog. diff --git a/.claude/rules/panda-css.md b/.claude/rules/panda-css.md new file mode 100644 index 00000000..447e0ea8 --- /dev/null +++ b/.claude/rules/panda-css.md @@ -0,0 +1,95 @@ +# Panda CSS Rules (this repo) + +The renderer uses [Panda CSS](https://panda-css.com) for all styling. The legacy +Stitches setup (`@stitches/react`, `@/styles/stitches.config`) is being phased +out — do not introduce new stitches usage; migrate any file you touch. + +## Setup + +- Config: `panda.config.ts` at repo root. +- Generated output: `src/renderer/src/styled/` (gitignored). Regenerated by + `pnpm prepare` (which runs `panda codegen`) and on pre-commit by lefthook + whenever `panda.config.ts` or any source file changes. +- PostCSS plugin wires the runtime: `postcss.config.cjs`. +- `strictTokens: true` is enabled — arbitrary values must use the `[bracket]` + escape syntax (e.g. `width: "[36px]"`, `bg: "[#9F99A5]"`). + +## Authoring + +Pick the lightest tool that fits: + +1. **`css({ ... })`** — one-off styles attached via `className={...}`. +2. **`cva({ base, variants })`** — components with prop-driven variants + (button-like, with active/disabled/size states). +3. **`sva({ slots, base, variants })`** — slot recipes (e.g. dialog with + overlay/content/header slots). + +Prefer layout primitives from `@/styled/jsx` over raw divs: + +- `` (vertical), `` (horizontal), ``, ``, `` +- `` for typography that already has a + named text style — use them instead of repeating `fontSize`/`lineHeight`. + +## Tokens + +Defined in `panda.config.ts theme.semanticTokens`: + +- `colors.brand.{primary,secondary,tertiary}` +- `colors.text.{default,lighter,onbutton-default,onbutton-alternative,onbutton-disabled}` +- `colors.action.{default,disabled,alt-default,hover,pressed,focus}` +- `colors.bg.{primary,secondary,primary-alternative,primary-highlight,secondary-highlight}` +- `colors.system.{success,success-secondary,error,error-secondary,warning,warning-secondary,info,info-secondary}` +- `borders.{primary,secondary,primary-alt,error}` +- `gradients.primary` + +Text styles: `title.md.{strong,default}`, `subtitle.{md,sm}.{strong,default}`, +`paragraph.{md,sm,xsm}.{strong,default}`, `cta.{md,sm}.{strong,default}`, +`label.{md,sm}.{strong,default}`. + +Reference tokens by dotted path: `color: "text.default"`, never raw hex unless +bracketed (`color: "[#FFE066]"`). + +## Conditions + +Use Panda's `_hover` / `_focus` / `_disabled` style props in recipes, **or** the +nested `&:hover` / `&:focus-visible` / `&:disabled` selector form inside `css()` +— both are valid. Pick one style per component for consistency. + +```ts +const button = cva({ + base: { ... }, + variants: { variant: { primary: { bg: "action.default", _hover: { bg: "action.hover" } } } }, +}); +``` + +## Workflow + +- After changes to `panda.config.ts`, run `pnpm panda codegen` (lefthook does + this on commit, but the IDE needs the regenerated types to type-check). +- New tokens: add to `theme.semanticTokens` instead of inlining bracketed + values across many files. +- Animations: add `keyframes` + `tokens.animations` to `panda.config.ts` rather + than inline `` (only acceptable as a temporary + bridge — flag with TODO). + +## Don'ts + +- Don't import from `@stitches/react` in new code. +- Don't inline raw hex without `[bracket]` — strictTokens will reject it. +- Don't bypass `strictTokens: true` by editing the config; if a value is needed + often, add a token. +- Don't write Tailwind-style `className="hover:bg-blue"`. Panda does not parse + utility class strings. + +## Quick reference for migration from Stitches + +| Stitches | Panda | +|---|---| +| `styled("div", { color: "$primary" })` | inline `
` | +| `styled("button", { variants: { ... } })` | `const button = cva({ base, variants })` then ` + + + + + {t("howItWorks.modalTitle")} + + + + + + + + + + + + + + ); +} +``` + +Confirm `@/components/ui/dialog` exports `Dialog`, `DialogClose`, `DialogContent`, `DialogTrigger` (it does — see the existing `how-it-works-modal.tsx` imports). If `DialogClose` does not accept `asChild`, wrap the button without `asChild` and call close via `onClick` is unnecessary — the existing modal uses `` directly, so the `asChild` Button variant is the only new usage; if it errors, replace the bottom `DialogClose asChild` block with a plain `{t("howItWorks.gotIt")}` styled as a button. + +- [ ] **Step 2: Render the voice modal from the header** + +In `src/renderer/src/components/layout/header.tsx`, import the enum and the modal at the top: + +```ts +import { FeatureFlowEnum } from "@/types/features"; +import VoiceHowItWorksModal from "@/components/voice-to-text/how-it-works"; +``` + +Change the type import line `import type { FeatureFlowEnum } ...` to a value import (remove the `type` keyword) since we now compare against it. + +Replace the `rightSlot` definition (header.tsx:43-50) with: + +```tsx + const rightSlot = feature ? ( + + {feature === FeatureFlowEnum.VoiceToText ? ( + + ) : ( + tutorialSeen && + )} + + + ) : ( + right + ); +``` + +This makes the `?` always available on VTT screens (matching 001/002/003) while leaving document features unchanged. + +- [ ] **Step 3: Typecheck** + +Run: `pnpm typecheck` +Expected: no new errors from `how-it-works.tsx` or `header.tsx`. + +- [ ] **Step 4: Verify visually** + +Run app → Voz a Texto. Click the `?` icon in the nav. Confirm the modal matches `screenshots/003_proceso_de_archivos.png`: title "¿Cómo funciona de Voz a Texto?", four numbered illustrated cards, an "Entendido" button, and an X close. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/voice-to-text/how-it-works.tsx src/renderer/src/components/layout/header.tsx +git commit -m "feat(voice-to-text): shared how-it-works grid + always-on help modal" +``` + +--- + +### Task 7: Onboarding gating + file-selection drop area (spec points 1 & 3) + +First visit → show the how-it-works grid (001). After the user has loaded a file once (`tutorialSeen` is set), the onboarding screen shows the audio file-selection drop area (002) directly. + +**Files:** +- Create: `src/renderer/src/components/voice-to-text/file-drop.tsx` +- Modify: `src/renderer/src/components/voice-to-text/onboarding.tsx` + +- [ ] **Step 1: Create the audio drop area (matches 002)** + +```tsx +import { FileAudio } from "phosphor-react"; +import { type DragEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { AUDIO_EXTENSIONS } from "@/constants/config"; +import { css, cva } from "@/styled/css"; +import { Stack, styled } from "@/styled/jsx"; + +const zone = cva({ + base: { + display: "flex", + flexDir: "column", + alignItems: "center", + justifyContent: "center", + gap: "4", + width: "full", + minHeight: "[320px]", + rounded: "lg", + borderWidth: "[1px]", + borderStyle: "solid", + cursor: "pointer", + transition: "[background-color 150ms ease, border-color 150ms ease]", + }, + variants: { + dragging: { + true: { bg: "bg.primary-highlight", borderColor: "brand.primary" }, + false: { bg: "bg.primary-alternative", borderColor: "[#BCBAB8]" }, + }, + }, + defaultVariants: { dragging: false }, +}); + +const iconBox = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "16", + height: "16", + rounded: "md", + bg: "bg.secondary-highlight", + color: "brand.primary", +}); + +interface VoiceFileDropProps { + onDropFiles: (files: File[]) => void; +} + +function hasAudioExtension(file: File): boolean { + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + return AUDIO_EXTENSIONS.includes(ext); +} + +export default function VoiceFileDrop({ onDropFiles }: VoiceFileDropProps) { + const { t } = useTranslation("voice-to-text"); + const [dragging, setDragging] = useState(false); + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setDragging(false); + const files = Array.from(e.dataTransfer.files).filter(hasAudioExtension); + if (files.length) onDropFiles(files); + }; + + return ( + + ); +} +``` + +If `colors.bg.primary-alternative` / `bg.primary-highlight` / `bg.secondary-highlight` aren't a close enough match to the light-purple fill in 002, bracket the exact hex from Figma instead (e.g. `bg: "[#EEEDFB]"`) — but prefer the token first. + +- [ ] **Step 2: Rewrite `onboarding.tsx` to gate on `tutorialSeen`** + +Replace the body of `VoiceOnboarding` (`components/voice-to-text/onboarding.tsx`). Keep the existing imports for navigate/file dispatch/hidden input and add the new ones. The full new file: + +```tsx +import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { type ChangeEventHandler, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import HiddenInput from "@/components/hidden-input"; +import Footer from "@/components/layout/footer"; +import Header from "@/components/layout/header"; +import MainContent from "@/components/layout/main-content"; +import BackButton from "@/components/ui/back-button"; +import Button from "@/components/ui/button"; +import VoiceFileDrop from "@/components/voice-to-text/file-drop"; +import { + VoiceHowItWorksGrid, +} from "@/components/voice-to-text/how-it-works"; +import VoiceStepper from "@/components/voice-to-text/stepper"; +import { AUDIO_EXTENSIONS } from "@/constants/config"; +import { useFileDispatch } from "@/hooks"; +import { SectionTitle } from "@/layout/section-title"; +import { addFiles } from "@/reducers/file/actions"; +import { useSetTutorialSeen, useTutorialSeen } from "@/store/useLocal"; +import { HStack, Stack, styled } from "@/styled/jsx"; +import { FeatureFlowEnum } from "@/types/features"; + +export default function VoiceOnboarding() { + const { t } = useTranslation("voice-to-text"); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const inputRef = useRef(null); + const dispatch = useFileDispatch(); + const tutorialSeen = useTutorialSeen(FeatureFlowEnum.VoiceToText); + const toggleTutorialSeen = useSetTutorialSeen(); + + const handleAddFiles = async (files: File[]) => { + dispatch(addFiles(files)); + await navigate({ + to: "/app/$feature/preview", + params: { feature: FeatureFlowEnum.VoiceToText }, + }); + toggleTutorialSeen(FeatureFlowEnum.VoiceToText); + }; + + const handleInputChange: ChangeEventHandler = (e) => { + const rawFiles = e.target.files; + if (rawFiles) handleAddFiles(Array.from(rawFiles)); + }; + + const handleOpenInput = () => inputRef.current?.click(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only on mount + useEffect(() => { + queryClient.removeQueries({ queryKey: ["transcribe"] }); + }, []); + + return ( + <> +
: undefined} + /> + + {tutorialSeen ? ( + + + + {t("onboarding.sectionTitle")} + + + + ) : ( + + {t("howItWorks.pageTitle")} + + + )} + +
+ + + +
+ + + ); +} +``` + +Notes: +- First visit shows `¿Cómo funciona?` title + grid (matches 001); the `?` in the header (Task 6) opens the same content as a modal. +- After first load `tutorialSeen` is true → file-selection drop area (matches 002) with the stepper. +- The old text-only `StepCard`/`stepNumber` styles and `Grid`/`ReactNode` imports are gone (now provided by `VoiceHowItWorksGrid`). + +- [ ] **Step 3: Typecheck + lint** + +Run: `pnpm typecheck && pnpm lint` +Expected: no errors in `onboarding.tsx` / `file-drop.tsx`. + +- [ ] **Step 4: Verify visually (both states)** + +Run app. To see the first-visit state, clear the persisted flag: in DevTools console run `localStorage.removeItem("local-storage")` then reload, or open the Application tab and delete the `local-storage` key. Navigate to Voz a Texto: +- First visit → matches `screenshots/001_onboarding.png`. +- Load a file, go back to onboarding (via the stepper/home and re-enter) → matches `screenshots/002_seleccion_de_archivos.png`. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/voice-to-text/onboarding.tsx src/renderer/src/components/voice-to-text/file-drop.tsx +git commit -m "feat(voice-to-text): gate onboarding grid vs audio drop area" +``` + +--- + +### Task 8: Selected-file preview restyle + 10s play (spec point 4) + +Match `screenshots/002_A_seleccion_de_archivos.png`: header "{n} archivo seleccionado", each file in a bordered row with a purple play button (plays 10s then auto-pauses), filename, "{duration} - {size}" meta, and a trash icon. Footer button "Siguiente". + +**Files:** +- Modify: `src/renderer/src/components/voice-to-text/preview.tsx` + +- [ ] **Step 1: Add a per-row component with the snippet player** + +At the top of `preview.tsx`, replace the icon imports and add the hook + the existing `formatFileSize`. New imports: + +```tsx +import { Pause, Play, Trash } from "phosphor-react"; +import { useAudioSnippet, formatDuration } from "@/components/voice-to-text/use-audio-snippet"; +``` + +Add row styles (replace the existing `fileRow` / `removeButton` styles with these): + +```ts +const fileRow = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "4", + width: "full", + px: "4", + py: "3", + rounded: "md", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "[#BCBAB8]", + bg: "bg.secondary", +}); + +const playButton = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "10", + height: "10", + rounded: "md", + border: "[none]", + cursor: "pointer", + flexShrink: "0", + bg: "bg.secondary-highlight", + color: "brand.primary", + "&:hover": { bg: "action.hover", color: "text.onbutton-alternative" }, +}); + +const removeButton = css({ + background: "transparent", + border: "[none]", + cursor: "pointer", + color: "text.lighter", + p: "2", + rounded: "md", + flexShrink: "0", + "&:hover": { color: "system.error" }, +}); +``` + +Add a `FileRow` component above `VoicePreview`: + +```tsx +function FileRow({ + file, + onRemove, +}: { + file: File; + onRemove: () => void; +}) { + const { t } = useTranslation("voice-to-text"); + const { durationMs, isPlaying, toggle } = useAudioSnippet(file); + + return ( +
+ + + + + {file.name} + + + {t("preview.meta", { + duration: formatDuration(durationMs), + size: formatFileSize(file.size), + })} + + + + +
+ ); +} +``` + +`formatFileSize` already exists in the file (returns e.g. `21.5 MB`). To match Figma's lowercase "mb", change its return to lowercase: `return \`${(bytes / (1024 * 1024)).toFixed(1)} mb\`;` and `\`${Math.round(bytes / 1024)} kb\`;`. (Optional — confirm with design; default to keeping uppercase if unsure.) + +- [ ] **Step 2: Use `FileRow` and update the heading + section title** + +In `VoicePreview`'s JSX, replace the `` body's heading and file list with: + +```tsx + + + + {t("preview.selectedCount", { count: files.length })} + + + {files.map((file) => ( + + ))} + + + +``` + +Update the section title to `{t("preview.sectionTitle")}` (now "1. Selección de archivo"), keep the `BackButton` (to `/app/$feature/onboarding`), and add `center={}` to the `
` (from Task 5). Keep the footer "Cargar más audios" (secondary) + "Siguiente" buttons; "Siguiente" stays `disabled={files.length === 0}`. + +`handleRemoveFile` already returns a handler — note we now call it as `handleRemoveFile(file.data.name)` (invoking to get the handler) and pass that as `onRemove`. Keep its definition `const handleRemoveFile = (fileName: string) => () => {...}`. + +- [ ] **Step 3: Typecheck + lint** + +Run: `pnpm typecheck && pnpm lint` +Expected: no errors. + +- [ ] **Step 4: Verify visually + functionally** + +Run app, load an audio file. Confirm against `screenshots/002_A_seleccion_de_archivos.png`: bordered row, purple play button, filename + "{duration} - {size}", trash icon, "Siguiente". Click play → audio plays and **auto-pauses after ~10 seconds**; clicking again before 10s pauses immediately; the icon toggles play/pause. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/components/voice-to-text/preview.tsx +git commit -m "feat(voice-to-text): restyle file preview with 10s snippet play" +``` + +--- + +### Task 9: Transcription progress states (spec point 5) + +Match the four states in `screenshots/003_proceso_de_archivos_progress.png`, `transcript-progress-with-text.png`, `transcript-progress-100.png`, `transcript-progress-error.png`: + +- Section title "2. Transcripción de voz a texto"; card title "AymurAI está transcribiendo el archivo." + subtitle. +- Row: filename · right side shows `{percent}%` **and a "Detener" button** while processing; "✓ Carga finalizada 100%" when complete; red "Error en la transcripción del archivo. Volvelo a intentar" + a "↻ Reemplazar" button on error. +- Striped/hatched progress bar (brand stripes), error bar in error tint. +- Text viewport below: placeholder "Esperando las primeras palabras…" or the streamed text with a bottom fade. +- Info callout at the bottom (present except in the error state). + +**Files:** +- Modify: `src/renderer/src/components/voice-to-text/process.tsx` + +- [ ] **Step 1: Consume `abort` and add a replace input + retry key** + +Replace the hook call and add state. Current line 95-97 becomes: + +```tsx + const [retryKey, setRetryKey] = useState(0); + const replaceInputRef = useRef(null); + const dispatch = useFileDispatch(); + + const { progress, status, partialText, abort } = useTranscribe(audioFiles, { + dispatch: transcriptionDispatch, + }); +``` + +Add imports: `import { useFileDispatch } from "@/hooks";`, `import { removeAllFiles, addFiles } from "@/reducers/file/actions";`, `import HiddenInput from "@/components/hidden-input";`, `import { AUDIO_EXTENSIONS } from "@/constants/config";`, `import { CheckCircle, Info, ArrowsClockwise } from "phosphor-react";`, and `VoiceStepper`. Keep `useState`, add `useRef` if not already imported (it is). + +Add handlers: + +```tsx + const handleStop = () => abort(); + + const handleReplaceClick = () => replaceInputRef.current?.click(); + + const handleReplaceFiles: ChangeEventHandler = (e) => { + const raw = e.target.files; + if (!raw || raw.length === 0) return; + dispatch(removeAllFiles()); + dispatch(addFiles(Array.from(raw))); + setRetryKey((k) => k + 1); // force a fresh transcription pass + }; +``` + +The `retryKey` is appended to the viewport `key`/used to re-mount is not necessary because `useTranscribe` re-runs when `audioFiles` identity changes (new files dispatched). `retryKey` is only needed if the user replaces with the *same* file object; keep it as a `key` on the outer `` to force a clean remount: ``. Add `import type { ChangeEventHandler } from "react";`. + +- [ ] **Step 2: Add striped-bar + state styles** + +Replace the `bar`/`barContainer` styles and add new ones: + +```ts +const barContainer = css({ + width: "full", + height: "[10px]", + bg: "bg.secondary-highlight", + rounded: "full", + overflow: "hidden", +}); + +const barProcessing = css({ + height: "full", + rounded: "full", + transition: "[width 200ms ease]", + // diagonal brand stripes + backgroundImage: + "[repeating-linear-gradient(45deg, #3F479D, #3F479D 8px, #6B73C9 8px, #6B73C9 16px)]", +}); + +const barError = css({ + height: "full", + width: "full", + rounded: "full", + bg: "system.error-secondary", +}); + +const calloutBox = css({ + display: "flex", + alignItems: "center", + gap: "3", + px: "4", + py: "3", + rounded: "md", + bg: "bg.secondary-highlight", + color: "text.default", +}); + +const stopButton = css({ + display: "flex", + alignItems: "center", + gap: "2", + px: "4", + py: "2", + rounded: "md", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "brand.primary", + bg: "bg.secondary", + color: "brand.primary", + cursor: "pointer", + whiteSpace: "nowrap", + "&:hover": { bg: "bg.secondary-highlight" }, +}); +``` + +If `#3F479D`/`#6B73C9` don't match the brand stripes, read the actual brand primary from `panda.config.ts` `colors.brand.primary` and use that hex; keep the lighter stripe ~30% lighter. + +- [ ] **Step 3: Rewrite the status row + bar + callout JSX** + +Replace the progress `` block (process.tsx:173-224) with: + +```tsx + + + + {files[0]?.data.name} + + + + {isError ? ( + + {t("process.errorLabel")} + + ) : isCompleted ? ( + + + + {t("process.completedLabel")} + + + ) : ( + + {t("process.progressLabel", { percent: progressPercent })} + + )} + + {isProcessing && ( + + )} + {isError && ( + + )} + + + +
+ {isError ? ( +
+ ) : ( +
+ )} +
+ + {/* text viewport: show whenever not errored */} + {!isError && ( +
+ {displayedText ? ( +
+ {displayedText} +
+ ) : ( + + {t("process.waitingForWords")} + + )} +
+ )} + {isError && ( +
+ + {t("process.waitingForWords")} + +
+ )} + + {!isError && ( +
+ + + {t("process.callout")} + +
+ )} + +``` + +Note: the completed (100%) screenshot still shows the streamed text and the callout — the `!isError` branch covers both processing and completed; the viewport is no longer gated on `isProcessing`. Adjust the `useEffect` that clears `displayedText` when `!isProcessing` (process.tsx:115-120) so completed text persists: change the condition to `if (status === "idle" || status === "stopped" || status === "error")`. + +- [ ] **Step 4: Add the replace `HiddenInput` and stepper** + +Add `center={}` to `
`. Before the closing ``, add: + +```tsx + +``` + +Keep the existing footer (Volver/Siguiente) — it's how the user advances to validation when complete (the screenshots crop it out). Leave `Siguiente` `disabled={!isCompleted}`. + +- [ ] **Step 5: Typecheck + lint** + +Run: `pnpm typecheck && pnpm lint` +Expected: no errors. + +- [ ] **Step 6: Verify all four states** + +Run app with the mock STT so states are reachable: `VITE_USE_MOCK_STT=true VITE_STT_MOCK_DELAY_MS=8000 pnpm dev:web` (see `constants/config.ts`). Load a file, continue to the process screen: +- During processing → matches `003_proceso_de_archivos_progress.png` (percent + Detener, striped bar, "Esperando…", callout) and `transcript-progress-with-text.png` once text streams. +- At 100% → matches `transcript-progress-100.png` (✓ Carga finalizada 100%, full striped bar, text, callout). +- Click **Detener** mid-way → transcription aborts (status `stopped`). +- Error state → matches `transcript-progress-error.png` (red filename + message, Reemplazar button, error bar, no callout). To force an error, point at a non-mock backend that 500s, or temporarily throw in `transcribe.ts` to confirm styling, then revert. + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/components/voice-to-text/process.tsx +git commit -m "feat(voice-to-text): progress states with stop, replace, and callout" +``` + +--- + +### Task 10: Transcript results restyle (spec point 6) + +Match `screenshots/transcrip-results.png`: + +- Search bar on top (full width) with the "Modo Edición" toggle on the right of that row; the editable title **below** the search row. +- Title editable via a pencil icon → inline input → dispatch `renameTranscription`. +- Turn body text **left-aligned with the speaker name** (indented past the avatar). +- Bottom bar unifies the audio player and the "Finalizar" button into one bar (the separate footer is removed). + +**Files:** +- Modify: `src/renderer/src/components/voice-to-text/audio-player.tsx` +- Modify: `src/renderer/src/components/voice-to-text/transcription-editor/index.tsx` +- Modify: `src/renderer/src/components/voice-to-text/transcription-editor/turn-block.tsx` +- Modify: `src/renderer/src/components/voice-to-text/validation.tsx` + +- [ ] **Step 1: Give `AudioPlayer` an optional right slot** + +In `audio-player.tsx`, extend the props and render the slot at the end of the bar. + +Add to `AudioPlayerProps`: + +```ts + rightSlot?: React.ReactNode; +``` + +Add `import type { ReactNode } from "react";` (or use `React.ReactNode`). Destructure `rightSlot` in the component signature. After the `progressSection` div (audio-player.tsx:302), before closing `
` of `playerBar`, add: + +```tsx + {rightSlot} +``` + +- [ ] **Step 2: Forward footer actions through the editor + reorder header** + +In `transcription-editor/index.tsx`: + +Add `footerActions?: React.ReactNode` to `TranscriptionEditorProps` and destructure it. Pass to the player: ``. + +Reorder the header so the search/toolbar row is first and the title is below it. Replace the `
` block (index.tsx:236-290) so the order is: toolbar (search + edit switch) first, then the editable title. Use this structure: + +```tsx +
+
+
+ {/* unchanged search input + counter + prev/next buttons */} +
+ +
+ + + dispatch(renameTranscription(transcription.id, value)) + } + /> + + {isEditMode && ( +
+ + {t("editor.editModeBanner")} +
+ )} +
+``` + +Add `mt: "4"` spacing to the title and banner via the styles below. Add imports: `import { Info, PencilSimple } from "phosphor-react";`, `import { useTranscriptionDispatch } from "@/hooks/useTranscriptions";`, `import { renameTranscription } from "@/reducers/transcription/actions";`. Get the dispatch in the component: `const dispatch = useTranscriptionDispatch();`. + +Add styles: + +```ts +const titleRow = css({ + display: "flex", + alignItems: "center", + gap: "3", + mt: "4", +}); + +const titleInput = css({ + fontSize: "[32px]", + lineHeight: "[38px]", + fontWeight: "[600]", + color: "text.default", + border: "[none]", + borderBottomWidth: "[2px]", + borderBottomStyle: "solid", + borderBottomColor: "brand.primary", + outline: "none", + bg: "transparent", + m: "[0]", + p: "[0]", +}); + +const editIconButton = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + border: "[none]", + bg: "transparent", + cursor: "pointer", + color: "text.lighter", + p: "1", + rounded: "[4px]", + "&:hover": { color: "brand.primary" }, +}); + +const editBanner = css({ + display: "flex", + alignItems: "center", + gap: "3", + mt: "4", + px: "4", + py: "3", + rounded: "md", + bg: "bg.secondary-highlight", + color: "text.default", + fontSize: "[14px]", +}); +``` + +Add the `EditableTitle` component in the same file (above `TranscriptionEditor`): + +```tsx +function EditableTitle({ + title, + onRename, +}: { + title: string; + onRename: (value: string) => void; +}) { + const { t } = useTranslation("voice-to-text"); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(title); + const inputRef = useRef(null); + + const prevTitle = useRef(title); + if (prevTitle.current !== title) { + prevTitle.current = title; + setValue(title); + } + + const commit = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== title) onRename(trimmed); + else setValue(title); + setEditing(false); + }; + + if (editing) { + return ( +
+ {/* biome-ignore lint/a11y/noAutofocus: explicit user action to edit title */} + setValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setValue(title); + setEditing(false); + } + }} + /> +
+ ); + } + + return ( +
+

+ {title} +

+ +
+ ); +} +``` + +Note: this `autoFocus` is on a plain input inside the editor body (not a Radix dialog), so the radix-ui "no autoFocus in dialog inputs" rule does not apply. The biome-ignore is needed because biome's a11y rule flags autoFocus generally — keep the comment. Remove the now-unused standalone `const title = css({...})` style only if nothing else references it (the `EditableTitle` uses inline style for the `

` to avoid clashing with the imported `title` css name; rename the old `title` css const to `titleText` and apply it to the `

` instead of inline styles for cleanliness): + +Cleaner alternative (preferred): keep the existing `const title = css({...})` recipe, rename it to `titleText`, and in `EditableTitle`'s display branch render `

{title}

` — dropping the inline `style`. Update the reference accordingly. + +- [ ] **Step 3: Align turn text with the speaker name** + +In `turn-block.tsx`, restructure so the avatar sits in a left column and the label-row + text share a right column (so text lines up under the speaker name). Replace the render body (the ` +
+ + {isEditMode ? ( +