Skip to content

Commit 4e95337

Browse files
TMHSDigitalclaude
andcommitted
Make the site installable and usable offline
sw.js saves the app's files on install and answers requests from the network first, falling back to the saved copy, so the page loads offline after one visit and still picks up new deploys while online. A web app manifest and 192/512 px icons make it installable. Playwright blocks service workers by default so tests do not share cached responses; the offline test enables them for its own context. Fixes #26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a06a206 commit 4e95337

11 files changed

Lines changed: 137 additions & 4 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ jobs:
5959
- name: Collect site files
6060
run: |
6161
mkdir _site
62-
cp index.html theme.js texts.js script.js style.css _site/
63-
cp -r fonts _site/
62+
cp index.html theme.js texts.js script.js style.css sw.js manifest.webmanifest _site/
63+
cp -r fonts icons _site/
6464
6565
- uses: actions/upload-pages-artifact@v5
6666
with:

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Open `index.html` in a browser, or serve the folder with any static file server.
3434
- **Challenge links**: after a run of a second or longer, Copy challenge link puts a URL on the clipboard. The URL's hash holds the run's settings, the seed of its passages, its WPM, and its keystroke timings, compressed. Opening it starts a run on the same passages with that run as the ghost, and the result dialog says who was faster. The receiver's saved settings are not changed; after the run, Play Again goes back to them. Links made before a change to the passages in `texts.js` may show different text.
3535
- **Stats**: a dialog, opened from the chart icon in the header, with your best WPM for every setting you have played, the average of your last ten runs, and a list of recorded runs. Export saves everything in `localStorage` to a JSON file, Import loads such a file and replaces the stored data, and Clear data (two clicks) removes history, bests, and key stats.
3636
- **Themes**: System (follows the OS light/dark and increased-contrast settings), Dark, Light, and High contrast, chosen in Settings. `theme.js`, loaded in `<head>`, applies the saved theme before the page paints.
37+
- **Offline and install**: `sw.js`, a service worker, saves the app's files on the first visit, so later visits load without a network connection. It tries the network first, so a new version is used as soon as you are online. `manifest.webmanifest` and the icons in `icons/` let browsers install the page as an app.
3738
- **Sound**: short tones made with the Web Audio API for keypress, mistake, and completion. Can be turned off.
3839
- **Keyboard**: Tab restarts while you are typing. Escape closes a dialog. Opening Settings during a run abandons the run.
3940
- **Focus**: if the typing input loses focus, a hint appears over the text. Pressing a letter key moves focus back to the input.
@@ -69,9 +70,9 @@ npm run format # Prettier, write mode
6970
npm run check # lint, format check, and tests
7071
```
7172

72-
`index.html`, `theme.js`, `texts.js`, `script.js`, and `style.css` are the whole app. The script runs at load and expects to be the last tag in `<body>`. Tests live in `tests/` and drive the real page with keystrokes in Chromium, Firefox, and WebKit. Each item under "What it does" has at least one test, as do recovery from corrupt `localStorage`, the contrast check, and the absence of requests to other sites.
73+
`index.html`, `theme.js`, `texts.js`, `script.js`, and `style.css` are the app; `sw.js`, `manifest.webmanifest`, and `icons/` add offline use and installing. The script runs at load and expects to be the last tag in `<body>`. Tests live in `tests/` and drive the real page with keystrokes in Chromium, Firefox, and WebKit. Each item under "What it does" has at least one test, as do recovery from corrupt `localStorage`, the contrast check, and the absence of requests to other sites.
7374

74-
Each push to `main` runs lint, the format check, and the tests in GitHub Actions. If they pass, the same workflow publishes `index.html`, `theme.js`, `texts.js`, `script.js`, `style.css`, and `fonts/` to GitHub Pages; if they fail, the live site is not changed.
75+
Each push to `main` runs lint, the format check, and the tests in GitHub Actions. If they pass, the same workflow publishes those files and `fonts/` to GitHub Pages; if they fail, the live site is not changed.
7576

7677
## License
7778

‎eslint.config.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ module.exports = [
3333
// The constants are used by script.js.
3434
rules: { 'no-unused-vars': 'off' },
3535
},
36+
{
37+
files: ['sw.js'],
38+
languageOptions: {
39+
ecmaVersion: 2022,
40+
sourceType: 'script',
41+
globals: { ...globals.serviceworker },
42+
},
43+
},
3644
{
3745
files: ['tests/**/*.js', 'playwright.config.js', 'eslint.config.js'],
3846
languageOptions: {

‎icons/icon-192.png‎

2.21 KB
Loading

‎icons/icon-512.png‎

7.39 KB
Loading

‎index.html‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
content="TypeSpeed: a browser typing trainer with timed and word-count modes, a code category, and ghost replay of your best run."
1313
/>
1414
<title>TypeSpeed</title>
15+
<link rel="manifest" href="manifest.webmanifest" />
16+
<meta name="theme-color" content="#0f172a" />
17+
<link rel="apple-touch-icon" href="icons/icon-192.png" />
1518
<link
1619
rel="icon"
1720
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230f172a'/%3E%3Ctext x='16' y='23' font-size='20' font-family='monospace' font-weight='700' text-anchor='middle' fill='%23818cf8'%3ET%3C/text%3E%3C/svg%3E"

‎manifest.webmanifest‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "TypeSpeed",
3+
"short_name": "TypeSpeed",
4+
"description": "Browser typing trainer with time and word modes, a code category, and ghost replay.",
5+
"start_url": "./",
6+
"scope": "./",
7+
"display": "standalone",
8+
"background_color": "#0f172a",
9+
"theme_color": "#0f172a",
10+
"icons": [
11+
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
12+
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
13+
]
14+
}

‎playwright.config.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ module.exports = defineConfig({
88
use: {
99
baseURL: 'http://127.0.0.1:8765',
1010
trace: 'retain-on-failure',
11+
// The service worker would cache responses across tests. The offline
12+
// test turns it back on for its own context.
13+
serviceWorkers: 'block',
1114
},
1215
webServer: {
1316
command: 'node scripts/serve.mjs',

‎script.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,17 @@ function init() {
16691669

16701670
resetGame();
16711671
loadChallengeFromHash();
1672+
registerServiceWorker();
1673+
}
1674+
1675+
// --- Offline ---
1676+
// sw.js saves the app's files so the page loads offline. Service workers
1677+
// only run over HTTPS or on localhost, so file:// and other origins skip it.
1678+
function registerServiceWorker() {
1679+
if (!('serviceWorker' in navigator) || !window.isSecureContext) return;
1680+
// Offline use is optional: if registration fails (blocked by the
1681+
// browser or its settings), the page works the same without it.
1682+
navigator.serviceWorker.register('sw.js').catch(() => {});
16721683
}
16731684

16741685
init();

‎sw.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* TypeSpeed service worker. Saves the app's files on install so the page
3+
* loads without a network connection. Requests try the network first and
4+
* fall back to the saved copy, so a new deploy is picked up on the next
5+
* load while online.
6+
*/
7+
const CACHE = 'typespeed';
8+
const FILES = [
9+
'./',
10+
'index.html',
11+
'style.css',
12+
'theme.js',
13+
'texts.js',
14+
'script.js',
15+
'manifest.webmanifest',
16+
'fonts/jetbrains-mono-latin.woff2',
17+
'fonts/space-grotesk-latin.woff2',
18+
'icons/icon-192.png',
19+
'icons/icon-512.png',
20+
];
21+
22+
self.addEventListener('install', (event) => {
23+
event.waitUntil(
24+
caches
25+
.open(CACHE)
26+
.then((cache) => cache.addAll(FILES))
27+
.then(() => self.skipWaiting())
28+
);
29+
});
30+
31+
self.addEventListener('activate', (event) => {
32+
event.waitUntil(self.clients.claim());
33+
});
34+
35+
self.addEventListener('fetch', (event) => {
36+
const { request } = event;
37+
if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return;
38+
event.respondWith(
39+
fetch(request)
40+
.then((response) => {
41+
if (response.ok) {
42+
const copy = response.clone();
43+
caches.open(CACHE).then((cache) => cache.put(request, copy));
44+
}
45+
return response;
46+
})
47+
// ignoreSearch: the page URL may carry a query string.
48+
.catch(() => caches.match(request, { ignoreSearch: true }))
49+
);
50+
});

0 commit comments

Comments
 (0)