diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac5bcb..f653a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.0.0 + +Echo 1.0 introduces a redesigned desktop workspace for private, local dictation. + +- Home, History, Dictionary, and Settings share neutral light and dark themes, a consistent type scale, aligned controls, and quieter borders. +- Labeled navigation stays visible at the default and minimum desktop window sizes. Compact windows keep all four section names available. +- Home puts a labeled recording action above a full-width transcript, with recent history and compact usage counts below. A new View history action opens saved transcripts directly. +- Recording, transcription, text insertion, and failure have distinct messages. Recording controls wait while Echo processes or inserts text, and recording retains its live level meter and elapsed-time limit. +- Setup progress takes less space while keeping microphone, speech, and shortcut repair actions visible. Keyboard focus, reduced motion, and text contrast have browser regression coverage. + +Existing settings, models, history, dictionary entries, and the dictation shortcut remain compatible. No data migration is required. Linux Debian, RPM, AppImage, and standalone binary downloads remain available. + ## v0.14.15 - Dictionary replacements now preserve shorter valid matches when a longer overlapping phrase takes priority. Adding unrelated dictionary entries no longer changes which replacements apply. diff --git a/Cargo.lock b/Cargo.lock index db000f3..ea39e43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,7 +1166,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "echo" -version = "0.14.15" +version = "1.0.0" dependencies = [ "bzip2", "cpal", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "echo-core" -version = "0.14.15" +version = "1.0.0" dependencies = [ "caseless", "fs2", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "echo-desktop" -version = "0.14.15" +version = "1.0.0" dependencies = [ "ashpd", "clap", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "echo-ipc" -version = "0.14.15" +version = "1.0.0" dependencies = [ "echo", "echo-core", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "echo-ipc-gen" -version = "0.14.15" +version = "1.0.0" dependencies = [ "echo-ipc", ] @@ -6087,7 +6087,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.14.15" +version = "1.0.0" dependencies = [ "png 0.18.1", "resvg", diff --git a/Cargo.toml b/Cargo.toml index d456ffc..135d200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" license = "MIT" repository = "https://github.com/ddv1982/echo" rust-version = "1.89" -version = "0.14.15" +version = "1.0.0" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/docs/qa/echo-1.0/README.md b/docs/qa/echo-1.0/README.md new file mode 100644 index 0000000..0088a0c --- /dev/null +++ b/docs/qa/echo-1.0/README.md @@ -0,0 +1,46 @@ +# Echo 1.0 frontend + +Echo uses a full-width workspace with labeled navigation and a text recording button. The latest transcript sits above recent history and compact usage counts. Neutral light and dark themes share the same controls and spacing. + +Page headings use 28px Inter, section headings use 14px, controls use 13px, and secondary text uses 12px. The transcript uses 16px for reading. Spoken dictionary phrases use the same proportional font as the rest of the interface. Icon buttons center their glyphs without inherited text-button padding. + +Horizontal navigation preserves content width at the 920 × 680 default window and 760 × 560 minimum. Home places recording beside its heading on desktop and below it on narrow screens. Responsive styles load after component styles so narrow layouts take precedence. Existing controllers and generated IPC types still own application state. + +![Home in light mode](home-light.png) + +![Home in dark mode](home-dark.png) + +![Settings in light mode](settings-light.png) + +![Settings in dark mode](settings-dark.png) + +## Reproduce the screenshots + +The browser preview uses disposable fixtures, not real audio or local transcripts. These Home screenshots represent completed shortcut verification. The capture command also saves setup, recording, History, Dictionary, and Settings at four widths in both themes. + +Start the preview in one terminal: + +```sh +npm run dev --prefix frontend -- --host 127.0.0.1 --port 4178 +``` + +Run the capture in another terminal from the repository root: + +```sh +node frontend/scripts/capture-design.mjs +``` + +Images are written to `frontend/test-results/design`. Pass a destination as the first argument to choose another directory. + +## Verify interactions + +```sh +./scripts/verify-settings-ux.sh +npm run build --prefix frontend +npm run lint --prefix frontend +npm run test --prefix frontend +``` + +The browser suite checks navigation at 390, 680, 681, 760, 920, and 1280 pixels, keyboard recording, processing controls, search, dictionary edits and long phrases, dialog focus, reduced motion, secondary-text contrast, icon alignment, consistent typography, and narrow Home layout. The existing Settings suite also checks breakpoint edges, microphone controls, theme selection, and long diagnostics. + +Browser verification covers the React interface with the preview API. Native microphone capture, global shortcuts, text insertion, and Linux packaging remain covered by the repository's native tests and release workflows. diff --git a/docs/qa/echo-1.0/home-dark.png b/docs/qa/echo-1.0/home-dark.png new file mode 100644 index 0000000..1587c25 Binary files /dev/null and b/docs/qa/echo-1.0/home-dark.png differ diff --git a/docs/qa/echo-1.0/home-light.png b/docs/qa/echo-1.0/home-light.png new file mode 100644 index 0000000..7fda84a Binary files /dev/null and b/docs/qa/echo-1.0/home-light.png differ diff --git a/docs/qa/echo-1.0/settings-dark.png b/docs/qa/echo-1.0/settings-dark.png new file mode 100644 index 0000000..1d19795 Binary files /dev/null and b/docs/qa/echo-1.0/settings-dark.png differ diff --git a/docs/qa/echo-1.0/settings-light.png b/docs/qa/echo-1.0/settings-light.png new file mode 100644 index 0000000..c7c667d Binary files /dev/null and b/docs/qa/echo-1.0/settings-light.png differ diff --git a/frontend/e2e/design.spec.ts b/frontend/e2e/design.spec.ts new file mode 100644 index 0000000..8ff4338 --- /dev/null +++ b/frontend/e2e/design.spec.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test' + +for (const theme of ['light', 'dark']) { + test(`navigation stays labeled and reachable in ${theme} mode`, async ({ page }) => { + await page.addInitScript((mode) => localStorage.setItem('echo-theme', mode), theme) + for (const width of [390, 680, 681, 760, 920, 1280]) { + await page.setViewportSize({ width, height: 680 }) + await page.goto('/') + const nav = page.getByRole('navigation', { name: 'Echo sections' }) + for (const name of ['Home', 'History', 'Dictionary', 'Settings']) { + const button = nav.getByRole('button', { name, exact: true }) + await expect(button.locator('span')).toBeVisible() + await button.click() + await expect(button).toHaveAttribute('aria-current', 'page') + if (name !== 'Home') await expect(page.getByRole('heading', { name, exact: true })).toBeVisible() + } + const fits = await page.evaluate(() => { + const buttons = [...document.querySelectorAll('.topbar button')] + const bounds = buttons.map((button) => button.getBoundingClientRect()) + return bounds.every((a, index) => a.left >= 0 && a.right <= innerWidth && a.height >= 32 && + bounds.every((b, other) => index === other || a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)) + }) + expect(fits, `header controls at ${width}px`).toBe(true) + } + }) + + test(`secondary text stays readable on every ${theme} surface`, async ({ page }) => { + await page.addInitScript((mode) => localStorage.setItem('echo-theme', mode), theme) + await page.goto('/') + await expect(page.locator('html')).toHaveAttribute('data-theme', theme) + const ratios = await page.evaluate(() => { + const sample = document.createElement('span') + document.body.append(sample) + const luminance = (token: string) => { + sample.style.color = `hsl(var(${token}))` + const [red, green, blue] = getComputedStyle(sample).color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [] + if (red === undefined || green === undefined || blue === undefined) throw new Error(`Cannot resolve ${token}`) + const linear = (channel: number) => { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + } + return linear(red) * 0.2126 + linear(green) * 0.7152 + linear(blue) * 0.0722 + } + const text = luminance('--text-tertiary') + const result = ['--surface-app', '--surface-card', '--surface-raised'].map((surface) => { + const background = luminance(surface) + return { surface, ratio: (Math.max(text, background) + 0.05) / (Math.min(text, background) + 0.05) } + }) + sample.remove() + return result + }) + for (const { surface, ratio } of ratios) expect(ratio, `${theme} ${surface}`).toBeGreaterThanOrEqual(4.5) + }) +} + +test('recording, processing and history work with the keyboard', async ({ page }) => { + await page.goto('/') + const start = page.getByRole('button', { name: 'Start recording', exact: true }) + await start.focus() + await expect(start).toBeFocused() + await page.keyboard.press('Enter') + const stop = page.getByRole('button', { name: 'Stop and transcribe', exact: true }) + await expect(stop).toBeFocused() + await expect(page.getByRole('heading', { name: 'Listening…', exact: true })).toBeVisible() + await expect(page.locator('.readout-timer')).toContainText('/ 10:00') + await page.keyboard.press('Enter') + await expect(page.getByRole('button', { name: 'Transcribing', exact: true })).toBeDisabled() + await expect(start).toBeEnabled() + await page.getByRole('button', { name: 'View history', exact: true }).click() + await expect(page.getByRole('heading', { name: 'History', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: 'Search history' }).fill('no transcript matches this phrase') + await expect(page.getByText('No matching transcripts', { exact: true })).toBeVisible() +}) + +test('dictionary edits and voice dialog preserve focus through the redesigned shell', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Dictionary', exact: true }).click() + await page.getByRole('textbox', { name: 'What Echo hears', exact: true }).fill('design studio') + await page.getByRole('textbox', { name: 'What Echo should write', exact: true }).fill('Design Studio') + await page.getByRole('button', { name: 'Add', exact: true }).click() + const remove = page.getByRole('button', { name: 'Remove Design Studio', exact: true }) + await expect(remove).toBeVisible() + await page.getByRole('button', { name: 'Home', exact: true }).click() + await page.getByRole('button', { name: 'Dictionary', exact: true }).click() + await expect(remove).toBeVisible() + const trigger = page.getByRole('button', { name: 'Teach by voice', exact: true }) + await trigger.click() + await expect(page.getByRole('dialog', { name: 'Teach Echo by voice' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(trigger).toBeFocused() + await remove.click() + await expect(remove).toHaveCount(0) +}) + +test('reduced motion stops the processing animation', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/') + await page.getByRole('button', { name: 'Start recording', exact: true }).click() + await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).click() + const spinner = page.locator('.processing-icon') + await expect(spinner).toBeVisible() + const duration = await spinner.evaluate((node) => getComputedStyle(node).animationDuration) + expect(parseFloat(duration)).toBeLessThanOrEqual(0.00001) +}) + +test('long dictionary phrases wrap inside a narrow window', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/') + await page.getByRole('button', { name: 'Dictionary', exact: true }).click() + await page.getByRole('textbox', { name: 'What Echo hears', exact: true }).fill('s'.repeat(200)) + await page.getByRole('textbox', { name: 'What Echo should write', exact: true }).fill('W'.repeat(200)) + await page.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByRole('button', { name: `Remove ${'W'.repeat(200)}`, exact: true })).toBeVisible() + const fits = await page.locator('.dictionary-row').last().evaluate((node) => + node.scrollWidth <= node.clientWidth && document.documentElement.scrollWidth <= innerWidth) + expect(fits).toBe(true) +}) + +test('every icon button centers its icon', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'History', exact: true }).click() + const offsets = await page.locator('.icon-button').evaluateAll((buttons) => buttons.map((button) => { + const icon = button.querySelector('svg') + if (!icon) throw new Error('Icon button has no icon') + const box = button.getBoundingClientRect() + const glyph = icon.getBoundingClientRect() + return { + label: button.getAttribute('aria-label'), + x: Math.abs(glyph.x + glyph.width / 2 - box.x - box.width / 2), + y: Math.abs(glyph.y + glyph.height / 2 - box.y - box.height / 2), + } + })) + expect(offsets.length).toBeGreaterThan(1) + for (const { label, x, y } of offsets) { + expect(x, `${label} horizontal center`).toBeLessThanOrEqual(0.5) + expect(y, `${label} vertical center`).toBeLessThanOrEqual(0.5) + } +}) + +test('page headings and controls keep a consistent type scale without all-caps labels', async ({ page }) => { + await page.setViewportSize({ width: 920, height: 680 }) + await page.goto('/') + const headingStyles = [] + for (const name of ['Home', 'History', 'Dictionary', 'Settings']) { + await page.getByRole('button', { name, exact: true }).click() + headingStyles.push(await page.locator('main h2').first().evaluate((node) => { + const style = getComputedStyle(node) + return { size: style.fontSize, weight: style.fontWeight, height: style.lineHeight, font: style.fontFamily } + })) + const uppercase = await page.locator('main').evaluate((main) => [...main.querySelectorAll('*')] + .some((node) => getComputedStyle(node).textTransform === 'uppercase')) + expect(uppercase, `${name} has an uppercase text transformation`).toBe(false) + } + expect(new Set(headingStyles.map((style) => JSON.stringify(style))).size).toBe(1) + await page.getByRole('button', { name: 'Test selected', exact: true }).waitFor() + const sizes = await page.locator('.setting-row select, .segmented-control button, .microphone-actions button').evaluateAll((nodes) => + [...new Set(nodes.map((node) => getComputedStyle(node).fontSize))]) + expect(sizes).toEqual(['13px']) +}) + +test('narrow Home puts the recording action below the heading', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/') + await page.getByRole('button', { name: 'Start recording', exact: true }).waitFor() + const stacked = await page.locator('.record-hero').evaluate((hero) => { + const copy = hero.querySelector('.hero-copy')?.getBoundingClientRect() + const action = hero.querySelector('.record-button')?.getBoundingClientRect() + if (!copy || !action) throw new Error('Recording content is missing') + return copy.width >= hero.clientWidth - 1 && action.top >= copy.bottom + }) + expect(stacked).toBe(true) +}) diff --git a/frontend/scripts/capture-design.mjs b/frontend/scripts/capture-design.mjs new file mode 100644 index 0000000..946914f --- /dev/null +++ b/frontend/scripts/capture-design.mjs @@ -0,0 +1,40 @@ +import { mkdir } from 'node:fs/promises' +import { resolve } from 'node:path' +import { chromium } from '@playwright/test' + +const destination = resolve(process.argv[2] ?? resolve(import.meta.dirname, '../test-results/design')) +const baseURL = process.env.ECHO_PREVIEW_URL ?? 'http://127.0.0.1:4178' +await mkdir(destination, { recursive: true }) +const browser = await chromium.launch() +try { + for (const theme of ['light', 'dark']) { + for (const [width, height] of [[920, 680], [760, 560], [390, 844], [1440, 1000]]) { + const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' }) + await page.addInitScript((mode) => localStorage.setItem('echo-theme', mode), theme) + await page.goto(baseURL) + await page.getByRole('button', { name: 'Start recording', exact: true }).waitFor() + await page.getByRole('region', { name: 'Finish setup' }).waitFor() + await page.evaluate(() => document.fonts.ready) + for (const view of ['Home', 'History', 'Dictionary', 'Settings']) { + await page.getByRole('button', { name: view, exact: true }).click() + if (view === 'Settings') await page.getByRole('button', { name: 'Test selected', exact: true }).waitFor() + await page.screenshot({ path: resolve(destination, `${theme}-${width}-${view.toLowerCase()}.png`), fullPage: true }) + } + await page.evaluate(() => { + localStorage.setItem('echo-shortcut-verified-at', String(Math.floor(Date.now() / 1000))) + localStorage.setItem('echo-shortcut-verified-identity', 'portal:Super+Alt+Space') + }) + await page.reload() + await page.getByRole('button', { name: 'Start recording', exact: true }).waitFor() + await page.getByRole('region', { name: 'Finish setup' }).waitFor({ state: 'hidden' }) + await page.screenshot({ path: resolve(destination, `${theme}-${width}-home-ready.png`), fullPage: true }) + await page.getByRole('button', { name: 'Start recording', exact: true }).click() + await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).waitFor() + await page.screenshot({ path: resolve(destination, `${theme}-${width}-recording.png`), fullPage: true }) + await page.close() + } + } +} finally { + await browser.close() +} +console.log(`Design screenshots saved to ${destination}`) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1a1f1e5..0176c2b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -318,6 +318,25 @@ describe('Echo desktop shell', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled()) }) + it.each([ + ['Transcribing', 'Transcribing locally…', 'Transcribing'], + ['Injecting', 'Inserting your text…', 'Inserting text'], + ] satisfies Array<[AppStatus['phase'], string, string]>)('keeps %s visible and prevents a second recording', async (phase, title, action) => { + seedPreviewStatus({ phase }) + render() + expect(await screen.findByRole('heading', { name: title })).toBeInTheDocument() + expect(screen.getByRole('button', { name: action })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Start recording' })).not.toBeInTheDocument() + }) + + it('offers a fresh recording after a failed transcription without claiming readiness', async () => { + seedPreviewStatus({ phase: 'Failed', lastError: 'Speech engine stopped' }) + render() + expect(await screen.findByRole('heading', { name: 'Let’s try that again' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Start recording' })).toBeEnabled() + expect(screen.queryByRole('heading', { name: 'Ready when you are' })).not.toBeInTheDocument() + }) + it('uses the snapped recording limit on Home', async () => { seedPreviewStatus({ phase: 'Recording', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfb6661..8e4d769 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,6 @@ import { DictionaryView } from './dictionary/DictionaryView' import { HistoryView } from './history/HistoryView' import { HomeView } from './home/HomeView' import { SettingsView } from './settings/SettingsView' -import { presentShortcut } from './shortcut' import type { View } from './types' const navigation: Array<{ id: View; label: string; icon: typeof Home }> = [ @@ -43,7 +42,6 @@ function App() { addDictionaryEntriesBatch, removeDictionaryEntry, } = useAppController() - const shortcut = presentShortcut(status.shortcut) return (
@@ -52,22 +50,7 @@ function App() {

Echo

-
- - -
- - -
-