mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
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:
parent
a635120769
commit
618af0811a
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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}">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 ')}`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue