diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 45b48a3..4a25a7b 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -1,6 +1,41 @@
// English translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
+
+ // Recovered from dead `t(k) || 'default'` fallbacks: t() returns the key when a string is
+ // missing, so those defaults never rendered and users saw the raw key instead.
+ 'admin.check_now': 'Check Now',
+ 'admin.checking': 'Checking...',
+ 'admin.copied': 'Copied!',
+ 'admin.copy': 'Copy',
+ 'admin.copy_command': 'Copy',
+ 'admin.latest_version': 'Latest Version',
+ 'admin.manual_update': 'Manual Update Required',
+ 'admin.manual_update_desc': 'Run this command on the server:',
+ 'admin.status': 'Status',
+ 'admin.up_to_date': 'Up to Date',
+ 'admin.update_available': 'Update Available',
+ 'admin.update_failed': 'Update Failed',
+ 'admin.update_now': 'Update Now',
+ 'admin.update_success': 'Update Successful',
+ 'admin.updating': 'Updating...',
+ 'wall.no_playlist': 'No playlist',
+ 'wall.playlist': 'Playlist',
+ 'wall.set_playlist': 'Set Playlist',
+ 'wall.toast.playlist_updated': 'Playlist updated',
+
+ // Calendar direct-manipulation strings. NOTE: t() returns the KEY when a string is missing,
+ // never undefined — so `t('x') || 'fallback'` can never fire and would ship the raw key to the
+ // user. These must exist here; a browser run caught 'schedule.ctx_new' rendering literally.
+ 'schedule.ctx_new': 'New schedule here…',
+ 'schedule.ctx_edit': 'Edit…',
+ 'schedule.ctx_duplicate': 'Duplicate',
+ 'schedule.ctx_delete': 'Delete',
+ 'schedule.confirm_series': 'This schedule repeats. Changing it here updates every occurrence. Continue?',
+ 'schedule.confirm_delete': 'Delete this schedule?',
+ 'schedule.toast.deleted': 'Schedule deleted',
+ 'schedule.drag_hint': 'Drag across a time to add a schedule, or right-click for options.',
+
// Getting-started checklist (components/getting-started.js). Driven by real account state,
// not a one-time flag, so it can tell someone what is actually left to do.
'gs.title': 'Get your first screen live',
diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js
index d83cc57..b05b8e3 100644
--- a/frontend/js/views/admin.js
+++ b/frontend/js/views/admin.js
@@ -409,16 +409,16 @@ async function loadSystem() {
const versionComparison = version.latest_version
? `
@@ -688,7 +688,7 @@ async function renderWallEditor(container, wallId) {
try {
await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ playlist_id: playlistId }) });
wall.playlist_id = playlistId;
- showToast(t('wall.toast.playlist_updated') || 'Playlist updated', 'success');
+ showToast(t('wall.toast.playlist_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); }
});
diff --git a/server/test/i18n-keys-exist.test.js b/server/test/i18n-keys-exist.test.js
new file mode 100644
index 0000000..4a04880
--- /dev/null
+++ b/server/test/i18n-keys-exist.test.js
@@ -0,0 +1,77 @@
+'use strict';
+
+// t() returns the KEY ITSELF when a string is missing — `registry[lang]?.[key] ?? fallback[key] ?? key`.
+// It never returns undefined. Two consequences, both of which have already bitten:
+//
+// 1. A missing key ships to the user as raw text. A browser run found a context menu whose only
+// item read "schedule.ctx_new".
+// 2. `t('x') || 'Some default'` looks like a safety net but is dead code, because the key string
+// is truthy. The default can never render, so it hides the missing key instead of covering it.
+//
+// Neither shows up in a unit test of the logic, or in a syntax check, or in review — only in front
+// of a user. So this walks the views for the keys they actually ask for and checks English has them.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const FRONTEND = path.join(__dirname, '..', '..', 'frontend', 'js');
+const EN = fs.readFileSync(path.join(FRONTEND, 'i18n', 'en.js'), 'utf8');
+
+// Keys defined in en.js, as written: 'some.key': '...'
+const defined = new Set([...EN.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
+
+function sourceFiles(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'i18n') out.push(...sourceFiles(p)); }
+ else if (e.name.endsWith('.js')) out.push(p);
+ }
+ return out;
+}
+
+// Only literal t('...') calls — a computed key cannot be checked statically, and pretending
+// otherwise would produce false failures.
+function referencedKeys(src) {
+ return [...src.matchAll(/\bt\(\s*'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/gi)].map(m => m[1]);
+}
+
+test('every literal t() key used by the app exists in English', () => {
+ const missing = [];
+ for (const file of sourceFiles(FRONTEND)) {
+ const src = fs.readFileSync(file, 'utf8');
+ for (const key of referencedKeys(src)) {
+ if (!defined.has(key)) missing.push(`${path.relative(FRONTEND, file)}: ${key}`);
+ }
+ }
+ assert.deepEqual(missing, [],
+ `these render as raw key text to the user:\n ${missing.join('\n ')}`);
+});
+
+test('no t() call carries a || default, which can never fire', () => {
+ // The pattern reads as a safety net and is the opposite: it guarantees the missing key is
+ // silently shipped instead of the readable default.
+ const offenders = [];
+ for (const file of sourceFiles(FRONTEND)) {
+ const src = fs.readFileSync(file, 'utf8');
+ for (const m of src.matchAll(/\bt\(\s*'[^']+'\s*(?:,[^)]*)?\)\s*\|\|\s*'/g)) {
+ const line = src.slice(0, m.index).split('\n').length;
+ offenders.push(`${path.relative(FRONTEND, file)}:${line}`);
+ }
+ }
+ assert.deepEqual(offenders, [],
+ `t() never returns falsy, so these defaults are dead:\n ${offenders.join('\n ')}`);
+});
+
+test('the getting-started checklist has all of its strings', () => {
+ // Called out separately because it is brand-new copy and entirely user-facing.
+ for (const k of ['gs.title', 'gs.progress', 'gs.dismiss',
+ 'gs.device.title', 'gs.device.desc', 'gs.device.cta',
+ 'gs.content.title', 'gs.content.desc', 'gs.content.cta',
+ 'gs.playlist.title', 'gs.playlist.desc', 'gs.playlist.cta',
+ 'gs.assign.title', 'gs.assign.desc', 'gs.assign.cta']) {
+ assert.ok(defined.has(k), `${k} is missing and would render literally`);
+ }
+});