Translate the labels that never went through t()

A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".

The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.

A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-07-28 19:52:52 -05:00
parent a635120769
commit 618af0811a
17 changed files with 65 additions and 12 deletions

View file

@ -14,7 +14,7 @@ export function openCreateOrgModal(opts = {}) {
<div class="modal">
<div class="modal-header">
<h3>${t('admin.create_org.title')}</h3>
<button class="btn-icon" type="button" data-org-close aria-label="Close">
<button class="btn-icon" type="button" data-org-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>

View file

@ -30,7 +30,7 @@ export function openManageWorkspacesModal(user, opts = {}) {
<div class="modal">
<div class="modal-header">
<h3>${t('manage_ws.title', { user: esc(user.name || user.email) })}</h3>
<button class="btn-icon" type="button" data-mws-close aria-label="Close">
<button class="btn-icon" type="button" data-mws-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>

View file

@ -13,7 +13,7 @@ export function openTypeToConfirmModal(opts = {}) {
<div class="modal">
<div class="modal-header">
<h3>${esc(title || '')}</h3>
<button class="btn-icon" type="button" data-ttc-close aria-label="Close">
<button class="btn-icon" type="button" data-ttc-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>

View file

@ -66,7 +66,7 @@ export function openAddUserModal(workspace, opts = {}) {
<div class="modal">
<div class="modal-header">
<h3>${title}</h3>
<button class="btn-icon" type="button" data-add-close aria-label="Close">
<button class="btn-icon" type="button" data-add-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>

View file

@ -31,7 +31,7 @@ export function openInviteMemberModal(workspace, opts = {}) {
<div class="modal">
<div class="modal-header">
<h3>${t('members.modal.invite_title', { workspace: esc(workspace.name) })}</h3>
<button class="btn-icon" type="button" data-invite-close aria-label="Close">
<button class="btn-icon" type="button" data-invite-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>

View file

@ -1,3 +1,4 @@
import { t } from '../i18n.js';
import { api } from '../api.js';
// Open a rename modal for the given workspace. Uses the existing .modal-overlay
@ -10,7 +11,7 @@ export function openWorkspaceRenameModal(workspace) {
<div class="modal">
<div class="modal-header">
<h3>Rename workspace</h3>
<button class="btn-icon" type="button" data-rename-close aria-label="Close">
<button class="btn-icon" type="button" data-rename-close aria-label="${t('common.close')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>

View file

@ -21,7 +21,7 @@ function formatResourceCount(n, keyBase, zeroKey) {
function adminIconsHtml(w) {
if (!w.can_admin) return '';
return `
<button class="workspace-switcher-members" type="button" data-members-id="${esc(w.id)}" aria-label="Manage members" title="Manage members">
<button class="workspace-switcher-members" type="button" data-members-id="${esc(w.id)}" aria-label="${t('switcher.manage_members')}" title="${t('switcher.manage_members')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
@ -29,7 +29,7 @@ function adminIconsHtml(w) {
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="${t('switcher.rename')}" title="${t('switcher.rename')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
</svg>

View file

@ -2,6 +2,12 @@
// standard for B2B software in DACH). Native review recommended before
// publicizing as fully supported.
export default {
'common.close': 'Schließen',
'switcher.manage_members': 'Mitglieder verwalten',
'switcher.rename': 'Workspace umbenennen',
'wall.rename': 'Wand umbenennen',
'wall.remove_from_wall': 'Von der Wand entfernen',
'dashboard.select_for_wall': 'Für Videowand auswählen',
'team.unavailable_desc': 'Diese Funktion ist nicht verfügbar, während sie für die Zusammenarbeit mit Workspaces neu gestaltet wird. Vorhandene Teamdaten bleiben erhalten.',
'team.unavailable_title': 'Teams wird überarbeitet',
'settings.help_tip': 'Ihr Profil, Passwort und die Zwei-Faktor-Anmeldung sowie API-Tokens für Integrationen und Werkzeuge zum Export und Import Ihrer Daten.',

View file

@ -1,6 +1,12 @@
// 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 {
'common.close': 'Close',
'switcher.manage_members': 'Manage members',
'switcher.rename': 'Rename workspace',
'wall.rename': 'Rename wall',
'wall.remove_from_wall': 'Remove from wall',
'dashboard.select_for_wall': 'Select for video wall',
'team.unavailable_desc': 'This feature is unavailable while it is redesigned to work with Workspaces. Existing team data is preserved.',
'team.unavailable_title': 'Teams is being rebuilt',
'settings.help_tip': 'Your profile, password and two-factor sign-in, plus API tokens for integrations and tools to export or import your data.',

View file

@ -1,6 +1,12 @@
// Spanish translations. Reviewed for UI register (informal tú).
// Native review still recommended before publicizing as fully supported.
export default {
'common.close': 'Cerrar',
'switcher.manage_members': 'Gestionar miembros',
'switcher.rename': 'Renombrar espacio de trabajo',
'wall.rename': 'Renombrar muro',
'wall.remove_from_wall': 'Quitar del muro',
'dashboard.select_for_wall': 'Seleccionar para el videomuro',
'team.unavailable_desc': 'Esta función no está disponible mientras se rediseña para funcionar con los Espacios de trabajo. Los datos de los equipos existentes se conservan.',
'team.unavailable_title': 'Equipos está en rediseño',
'settings.help_tip': 'Tu perfil, contraseña y verificación en dos pasos, además de tokens de API para integraciones y herramientas para exportar o importar tus datos.',

View file

@ -2,6 +2,12 @@
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
// Native review recommended before publicizing as fully supported.
export default {
'common.close': 'Fermer',
'switcher.manage_members': 'Gérer les membres',
'switcher.rename': 'Renommer l\'espace de travail',
'wall.rename': 'Renommer le mur',
'wall.remove_from_wall': 'Retirer du mur',
'dashboard.select_for_wall': 'Sélectionner pour le mur d\'images',
'team.unavailable_desc': 'Cette fonctionnalité est indisponible pendant sa refonte pour fonctionner avec les espaces de travail. Les données des équipes existantes sont conservées.',
'team.unavailable_title': 'Les équipes sont en cours de refonte',
'settings.help_tip': 'Votre profil, votre mot de passe et la double authentification, ainsi que les jetons d\'API pour les intégrations et les outils d\'export ou d\'import de vos données.',

View file

@ -1,6 +1,12 @@
// Italian translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
'common.close': 'Chiudi',
'switcher.manage_members': 'Gestisci membri',
'switcher.rename': 'Rinomina spazio di lavoro',
'wall.rename': 'Rinomina parete',
'wall.remove_from_wall': 'Rimuovi dalla parete',
'dashboard.select_for_wall': 'Seleziona per la videowall',
'team.unavailable_desc': 'Questa funzione non è disponibile mentre viene riprogettata per funzionare con gli Spazi di lavoro. I dati dei team esistenti vengono conservati.',
'team.unavailable_title': 'I team sono in fase di riprogettazione',
'settings.help_tip': 'Il tuo profilo, la password e l\'accesso a due fattori, oltre ai token API per le integrazioni e agli strumenti per esportare o importare i tuoi dati.',

View file

@ -2,6 +2,12 @@
// Reviewed for UI register (informal você). Native review recommended before
// publicizing as fully supported.
export default {
'common.close': 'Fechar',
'switcher.manage_members': 'Gerir membros',
'switcher.rename': 'Mudar o nome da área de trabalho',
'wall.rename': 'Mudar o nome do mural',
'wall.remove_from_wall': 'Remover do mural',
'dashboard.select_for_wall': 'Selecionar para o mural de vídeo',
'team.unavailable_desc': 'Esta funcionalidade está indisponível enquanto é reformulada para funcionar com as Áreas de trabalho. Os dados das equipas existentes são preservados.',
'team.unavailable_title': 'As equipas estão a ser reformuladas',
'settings.help_tip': 'O seu perfil, palavra-passe e autenticação de dois fatores, além de tokens de API para integrações e ferramentas para exportar ou importar os seus dados.',

View file

@ -87,7 +87,7 @@ function renderDeviceCard(device) {
const checked = selectedDeviceIds.has(device.id);
return `
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
<label class="device-card-select" title="Select for wall" onclick="event.stopPropagation()">
<label class="device-card-select" title="${t('dashboard.select_for_wall')}" onclick="event.stopPropagation()">
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
</label>
<div class="device-card-preview" id="preview-${device.id}">

View file

@ -423,7 +423,7 @@ async function openAiSettings() {
<div class="modal" style="max-width:520px;width:95vw">
<div class="modal-header">
<h3>${t('designer.ai.settings_title')}</h3>
<button class="btn-icon" data-ai-close aria-label="Close"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="btn-icon" data-ai-close aria-label="${t('common.close')}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('designer.ai.settings_desc')}</p>

View file

@ -192,7 +192,7 @@ async function renderWallEditor(container, wallId) {
<div class="page-header" style="margin-bottom:12px">
<h1 style="display:flex;align-items:center;gap:10px">
<span id="wallTitleText">${esc(wall.name)}</span>
<button class="btn btn-sm" id="renameWallBtn" title="Rename wall" style="padding:2px 8px;font-size:12px"></button>
<button class="btn btn-sm" id="renameWallBtn" title="${t('wall.rename')}" style="padding:2px 8px;font-size:12px"></button>
</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-sm" id="centerViewBtn" title="Re-center and fit content to the viewport">Center</button>
@ -387,7 +387,7 @@ async function renderWallEditor(container, wallId) {
</div>
${renderNoteFor(s.device_id) ? `<div class="wall-screen-rendernote" style="font-size:9px;color:var(--warning,#e0a800);margin-top:2px;line-height:1.2">${esc(renderNoteFor(s.device_id))}</div>` : ''}
</div>
<button class="wall-screen-remove" title="Remove from wall">×</button>
<button class="wall-screen-remove" title="${t('wall.remove_from_wall')}">×</button>
${resizeHandlesHtml()}
`;
el.querySelector('.wall-screen-remove').addEventListener('click', (ev) => {

View file

@ -114,3 +114,19 @@ test('a tip marker in a view always names a real string', () => {
}
assert.deepEqual(missing, [], missing.join('\n '));
});
test('user-facing labels are translated, not hardcoded English', () => {
// A title= is a tooltip the user reads and an aria-label is what a screen reader says. Both
// were hardcoded English in a dozen places, so a French user hovering the only route to
// workspace members heard "Manage members". They are invisible to the key checks above
// precisely because they never call t().
const offenders = [];
for (const file of sourceFiles(FRONTEND)) {
const src = fs.readFileSync(file, 'utf8');
for (const m of src.matchAll(/(aria-label|title)="([A-Z][a-zA-Z ]{3,40})"/g)) {
offenders.push(`${path.relative(FRONTEND, file)}: ${m[1]}="${m[2]}"`);
}
}
assert.deepEqual(offenders, [],
`these ship English regardless of language:\n ${offenders.join('\n ')}`);
});