diff --git a/README.md b/README.md
index 5b4303e..88b8cd0 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,16 @@ it for you. Any text not translated yet shows in English.
To change the English wording or add a language, see
[docs/translation.md](docs/translation.md).
+## Audio
+
+The exercise clips and their sentences come from
+[Mozilla Common Voice](https://commonvoice.mozilla.org/)'s Thai dataset:
+sentences read aloud by volunteers and released into the public domain under
+CC0. The clips aren't stored in this repository; they are loaded from a local
+copy of the dataset (see [Running it locally](#running-it-locally)). You can
+record or check Thai sentences for the dataset at
+[commonvoice.mozilla.org/th](https://commonvoice.mozilla.org/th).
+
## License
AGPL-3.0-or-later — see [LICENSE](LICENSE). Running a modified copy of this
diff --git a/src/frontend/e2e/footer.spec.js b/src/frontend/e2e/footer.spec.js
new file mode 100644
index 0000000..ec6dbbb
--- /dev/null
+++ b/src/frontend/e2e/footer.spec.js
@@ -0,0 +1,70 @@
+import { expect, test } from '@playwright/test'
+
+import { LONGEST, mockBackend, sentenceOnScreen } from './fixtures'
+
+/** The lowest point any part of the exercise reaches, and the footer's box. */
+async function layout(page) {
+ return page.evaluate(() => ({
+ contentBottom: Math.max(
+ ...[...document.querySelector('main').children].map((el) => el.getBoundingClientRect().bottom),
+ ),
+ footer: document.querySelector('footer').getBoundingClientRect().toJSON(),
+ scrolls: document.documentElement.scrollHeight > window.innerHeight,
+ }))
+}
+
+test.describe('the footer', () => {
+ test('links to the source code', async ({ page }) => {
+ await mockBackend(page)
+ await page.goto('/')
+ await sentenceOnScreen(page)
+
+ const footer = page.getByRole('contentinfo')
+ await expect(footer.getByRole('link', { name: 'Source code on GitHub' })).toHaveAttribute(
+ 'href',
+ 'https://github.com/Akay7/TypeLearn',
+ )
+ })
+
+ // The laptop screen the "fits on one screen" requirement names: the footer
+ // must not be what pushes the keyboard below the fold.
+ test('fits under the longest exercise without scrolling the page', async ({ page }) => {
+ await mockBackend(page, [LONGEST])
+ await page.goto('/')
+ await sentenceOnScreen(page)
+ await page.waitForFunction(() => document.fonts.status === 'loaded')
+
+ const { contentBottom, footer, scrolls } = await layout(page)
+
+ expect(scrolls).toBe(false)
+ expect(footer.top).toBeGreaterThanOrEqual(contentBottom)
+ })
+
+ test.describe('on a screen shorter than the exercise', () => {
+ test.use({ viewport: { width: 1280, height: 560 } })
+
+ test('sits below the keyboard legend instead of over it', async ({ page }) => {
+ await mockBackend(page, [LONGEST])
+ await page.goto('/')
+ await sentenceOnScreen(page)
+ await page.waitForFunction(() => document.fonts.status === 'loaded')
+
+ const { contentBottom, footer, scrolls } = await layout(page)
+
+ expect(scrolls).toBe(true)
+ expect(footer.top).toBeGreaterThanOrEqual(contentBottom)
+ })
+ })
+
+ test('follows the interface language', async ({ page }) => {
+ await mockBackend(page)
+ await page.goto('/')
+ await sentenceOnScreen(page)
+
+ await page.locator('button[aria-haspopup="true"]').click()
+ await page.locator('#interface-language').selectOption('ru')
+
+ const footer = page.getByRole('contentinfo')
+ await expect(footer.getByRole('link', { name: 'Исходный код на GitHub' })).toBeVisible()
+ })
+})
diff --git a/src/frontend/src/App.vue b/src/frontend/src/App.vue
index f0632f0..3dd8537 100644
--- a/src/frontend/src/App.vue
+++ b/src/frontend/src/App.vue
@@ -1,23 +1,42 @@
-
-
-
- TypeLearn
-
-
+
+
+
+
+ TypeLearn
+
+
+
+
+
+
diff --git a/src/frontend/src/locales/de.json b/src/frontend/src/locales/de.json
index df350bc..2b0efd4 100644
--- a/src/frontend/src/locales/de.json
+++ b/src/frontend/src/locales/de.json
@@ -76,5 +76,8 @@
"today": "Heute",
"last7Days": "Letzte 7 Tage",
"next": "Nächste Übung →"
+ },
+ "footer": {
+ "source": "Quellcode auf GitHub"
}
}
diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json
index c467767..2094480 100644
--- a/src/frontend/src/locales/en.json
+++ b/src/frontend/src/locales/en.json
@@ -76,5 +76,8 @@
"today": "Today",
"last7Days": "Last 7 days",
"next": "Next exercise →"
+ },
+ "footer": {
+ "source": "Source code on GitHub"
}
}
diff --git a/src/frontend/src/locales/fr.json b/src/frontend/src/locales/fr.json
index 5a64798..3fc8f92 100644
--- a/src/frontend/src/locales/fr.json
+++ b/src/frontend/src/locales/fr.json
@@ -77,5 +77,8 @@
"today": "Aujourd'hui",
"last7Days": "7 derniers jours",
"next": "Exercice suivant →"
+ },
+ "footer": {
+ "source": "Code source sur GitHub"
}
}
diff --git a/src/frontend/src/locales/hu.json b/src/frontend/src/locales/hu.json
index 49745df..57349c0 100644
--- a/src/frontend/src/locales/hu.json
+++ b/src/frontend/src/locales/hu.json
@@ -76,5 +76,8 @@
"today": "Ma",
"last7Days": "Elmúlt 7 nap",
"next": "Következő gyakorlat →"
+ },
+ "footer": {
+ "source": "Forráskód a GitHubon"
}
}
diff --git a/src/frontend/src/locales/ru.json b/src/frontend/src/locales/ru.json
index e8df924..88e463d 100644
--- a/src/frontend/src/locales/ru.json
+++ b/src/frontend/src/locales/ru.json
@@ -78,5 +78,8 @@
"today": "Сегодня",
"last7Days": "Последние 7 дней",
"next": "Следующее упражнение →"
+ },
+ "footer": {
+ "source": "Исходный код на GitHub"
}
}
diff --git a/src/frontend/src/locales/th.json b/src/frontend/src/locales/th.json
index 6d34070..5f94af9 100644
--- a/src/frontend/src/locales/th.json
+++ b/src/frontend/src/locales/th.json
@@ -75,5 +75,8 @@
"today": "วันนี้",
"last7Days": "7 วันที่ผ่านมา",
"next": "แบบฝึกหัดถัดไป →"
+ },
+ "footer": {
+ "source": "ซอร์สโค้ดบน GitHub"
}
}