Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions docs/qa/echo-1.0/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/qa/echo-1.0/home-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/echo-1.0/home-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/echo-1.0/settings-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/echo-1.0/settings-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 172 additions & 0 deletions frontend/e2e/design.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>('.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)
})
40 changes: 40 additions & 0 deletions frontend/scripts/capture-design.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
19 changes: 19 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />)
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(<App />)
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',
Expand Down
Loading