diff --git a/README.md b/README.md
index d70e0ce..1a080ee 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,8 @@ To Be Played is split into three repositories:
priority first, reroll without repeats, and "Start playing" in one click.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements.
+- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
+ a platform and optional hours. Works without a Steam ID.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked.
@@ -34,6 +36,7 @@ To Be Played is split into three repositories:
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
+- **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
once and sends the request again; parallel requests and other tabs share one refresh.
@@ -88,7 +91,6 @@ npm run build
- Mood filter for "What to play next".
- A Content Security Policy in `nginx.conf`.
-- Installable PWA.
## License
diff --git a/index.html b/index.html
index b9eb454..e56ab60 100644
--- a/index.html
+++ b/index.html
@@ -3,8 +3,15 @@
+
+
+
+
+
+
+
- Game Log
+ ToBePlayed
diff --git a/nginx.conf b/nginx.conf
index 575f511..022f911 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -7,6 +7,12 @@ server {
add_header Cache-Control "public, max-age=31536000, immutable";
}
+ # Not in every nginx mime.types; browsers expect this type for the PWA manifest.
+ location = /manifest.webmanifest {
+ types { application/manifest+json webmanifest; }
+ add_header Cache-Control "no-cache";
+ }
+
# index.html and /i18n/*.json keep their names across deploys. Without
# no-cache the browser shows old translation keys or loads an old bundle.
location / {
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..af52f7c
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon.svg b/public/favicon.svg
index 6893eb1..2ffe218 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1 +1,10 @@
-
\ No newline at end of file
+
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 0000000..ac4040b
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..97b0dff
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png
new file mode 100644
index 0000000..2e3aaae
Binary files /dev/null and b/public/icon-maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..8da2ecb
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "id": "/",
+ "name": "ToBePlayed",
+ "short_name": "TBP",
+ "description": "Your game backlog: Steam library, games from other platforms, and what to play next.",
+ "start_url": "/dashboard",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#09090b",
+ "theme_color": "#09090b",
+ "icons": [
+ {"src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
+ {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
+ {"src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
+ ]
+}
diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx
index 5df46e4..3e10a2a 100644
--- a/src/context/PreferencesContext.jsx
+++ b/src/context/PreferencesContext.jsx
@@ -11,6 +11,8 @@ import {
} from '../utils/appearance.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
+// The --background of style.css; the installed app paints its title bar in this color.
+const THEME_COLORS = {dark: '#09090b', light: '#fafafa'};
const PreferencesContext = createContext(null);
@@ -28,6 +30,8 @@ export function PreferencesProvider({children}) {
} else {
document.documentElement.classList.remove('dark');
}
+ document.querySelector('meta[name="theme-color"]')
+ ?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]);
diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx
new file mode 100644
index 0000000..f4e910b
--- /dev/null
+++ b/src/context/PreferencesContext.test.jsx
@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, render} from '@testing-library/react';
+import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+
+let preferences;
+function Spy() {
+ preferences = usePreferences();
+ return null;
+}
+
+const themeColor = () => document.querySelector('meta[name="theme-color"]').content;
+
+beforeEach(() => {
+ document.head.innerHTML = '';
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe('PreferencesProvider theme color', () => {
+ it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
+ localStorage.setItem('tbp_dark_mode', 'false');
+ render();
+
+ expect(themeColor()).toBe('#fafafa');
+
+ act(() => preferences.setDarkMode(true));
+ expect(themeColor()).toBe('#09090b');
+ });
+});
diff --git a/src/pwa.test.js b/src/pwa.test.js
new file mode 100644
index 0000000..0ff0341
--- /dev/null
+++ b/src/pwa.test.js
@@ -0,0 +1,35 @@
+import {readFileSync} from 'node:fs';
+import path from 'node:path';
+import {describe, expect, it} from 'vitest';
+
+const root = path.resolve(import.meta.dirname, '..');
+const read = (file) => readFileSync(path.join(root, file));
+const manifest = JSON.parse(read('public/manifest.webmanifest'));
+
+// Width and height sit at bytes 16-23 of every PNG.
+const pngSize = (file) => {
+ const png = read(path.join('public', file));
+ return `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`;
+};
+
+describe('PWA manifest', () => {
+ it('links every icon to a PNG of the size it claims', () => {
+ for (const icon of manifest.icons) {
+ expect(pngSize(icon.src)).toBe(icon.sizes);
+ }
+ });
+
+ it('has the icons Chrome needs to offer the install', () => {
+ const sizes = manifest.icons.filter((icon) => icon.purpose === 'any').map((icon) => icon.sizes);
+ expect(sizes).toEqual(expect.arrayContaining(['192x192', '512x512']));
+ expect(manifest.display).toBe('standalone');
+ expect(manifest.icons.some((icon) => icon.purpose === 'maskable')).toBe(true);
+ });
+
+ it('is linked from index.html together with the iOS icon', () => {
+ const html = read('index.html').toString();
+ expect(html).toContain('');
+ expect(html).toContain('');
+ expect(pngSize('apple-touch-icon.png')).toBe('180x180');
+ });
+});