From 0a9a749475f28785024a7664c99d66219aeed3c9 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 19:37:49 -0500 Subject: [PATCH] Make help tips reachable, and explain the pages that had none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of every view turned up two problems with the in-product help. The tips only appeared on :hover. On a tablet or a phone there is no hover, so the entire explanation layer was invisible to touch users — a large share of the people administering signage — and unreachable from a keyboard. Tapping a marker now opens it, Escape or a tap elsewhere closes it, and the marker is focusable so Tab reaches it and a screen reader announces it. Bound once at the document level and applied by observing the DOM, because views render from about twenty call sites and modals appear later still; hooking each one would have left the next new route silently unreachable again. Four views had no tip at all. Playlists is the important one: a playlist is the concept the reported confusion was actually about, and the page said nothing about what one is or how it reaches a screen. Activity and Settings now have one too. Help does not, because it is the help. The schedule tip described a product that no longer exists — it said to click Add Schedule, predating the drag, resize and right-click gestures. Rewritten. All four are translated into every active locale rather than left to fall back to English, since a tip falling back is a non-English user being handed an English paragraph at the moment they are confused. hi.js stays deliberately empty per the note in that file. Tests now check that every tip is translated everywhere, and that a tip marker never names a string that does not exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/css/main.css | 7 ++++- frontend/js/app.js | 42 +++++++++++++++++++++++++++++ frontend/js/i18n/de.js | 5 +++- frontend/js/i18n/en.js | 5 +++- frontend/js/i18n/es.js | 5 +++- frontend/js/i18n/fr.js | 5 +++- frontend/js/i18n/it.js | 5 +++- frontend/js/i18n/pt.js | 5 +++- frontend/js/views/activity.js | 2 +- frontend/js/views/playlists.js | 2 +- frontend/js/views/settings.js | 2 +- server/test/i18n-keys-exist.test.js | 39 +++++++++++++++++++++++++++ 12 files changed, 114 insertions(+), 10 deletions(-) diff --git a/frontend/css/main.css b/frontend/css/main.css index e81b6c9..e3f5024 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -1406,7 +1406,12 @@ body { flex-shrink: 0; } -.help-tip:hover::after { +/* A help tip must be reachable without a mouse. :hover alone made every one of these + invisible on a tablet or phone — which is a large share of the people administering a + signage install — and unreachable by keyboard. Tapping toggles .is-open; focus shows it too. */ +.help-tip:hover::after, +.help-tip:focus-visible::after, +.help-tip.is-open::after { content: attr(data-tip); position: absolute; bottom: 100%; diff --git a/frontend/js/app.js b/frontend/js/app.js index 966386b..8c1feb9 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -253,6 +253,47 @@ async function refreshCurrentUser() { } catch {} } +// Help tips are the main in-product explanation, and they were reachable by HOVER only — +// invisible on a tablet or phone, and unreachable from a keyboard. Bound once at the document +// level so every view's tips work without each having to opt in: tapping toggles one, Escape or +// a tap elsewhere closes it, and the marker is made focusable so Tab can reach it. +let tipsBound = false; +function enableHelpTips() { + document.querySelectorAll('.help-tip:not([tabindex])').forEach((el) => { + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + el.setAttribute('aria-label', el.dataset.tip || 'Help'); + }); + if (tipsBound) return; + tipsBound = true; + // Views render from ~20 call sites and modals appear later still, so watch the DOM rather + // than trying to call this after each one — a tip added by a route nobody remembered to hook + // would otherwise be keyboard-unreachable again. + const host = document.getElementById('app') || document.body; + let pending = null; + new MutationObserver(() => { + clearTimeout(pending); + pending = setTimeout(() => { + document.querySelectorAll('.help-tip:not([tabindex])').forEach((el) => { + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + el.setAttribute('aria-label', el.dataset.tip || 'Help'); + }); + }, 50); + }).observe(host, { childList: true, subtree: true }); + document.addEventListener('click', (e) => { + const tip = e.target.closest('.help-tip'); + document.querySelectorAll('.help-tip.is-open').forEach((o) => { if (o !== tip) o.classList.remove('is-open'); }); + if (tip) { e.preventDefault(); tip.classList.toggle('is-open'); } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') document.querySelectorAll('.help-tip.is-open').forEach((o) => o.classList.remove('is-open')); + if ((e.key === 'Enter' || e.key === ' ') && e.target.classList && e.target.classList.contains('help-tip')) { + e.preventDefault(); e.target.classList.toggle('is-open'); + } + }); +} + function route() { // Cleanup previous view if (currentView && currentView.cleanup) currentView.cleanup(); @@ -676,6 +717,7 @@ if (isAuthenticated()) { }, 60000); } window.addEventListener('hashchange', route); +enableHelpTips(); route(); // Close-modal buttons (replaces inline onclick handlers — required for CSP). diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index bfd41cb..988c085 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -2,6 +2,9 @@ // standard for B2B software in DACH). Native review recommended before // publicizing as fully supported. export default { + '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.', + 'activity.help_tip': 'Protokoll darüber, was sich geändert hat und wer es geändert hat: Anmeldungen, hochgeladene Inhalte, Gerätebefehle und Warnungen. Hilfreich, um nachzuvollziehen, warum ein Bildschirm etwas anderes angezeigt hat.', + 'playlist.help_tip': 'Eine Playlist ist die Reihenfolge, die ein Bildschirm in einer Schleife abspielt. Fügen Sie Inhalte hinzu, legen Sie deren Anzeigedauer fest und weisen Sie die Playlist einem Bildschirm oder einer Gruppe zu. Ein Bildschirm spielt jeweils eine Playlist ab, sofern kein Zeitplan sie überschreibt.', // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zone aus einem anderen Layout — neu zuweisen', 'device.pl_item.orphan_zone_tip': 'Die Zone dieses Elements gehört nicht zum aktuellen Layout des Geräts. Es wird weiterhin abgespielt (in die größte Zone verschoben), sollte aber einer Zone dieses Layouts neu zugewiesen werden.', @@ -888,7 +891,7 @@ export default { // Schedule 'schedule.title': 'Zeitplan', 'schedule.subtitle': 'Inhaltsplanungs-Kalender', - 'schedule.help_tip': 'Visueller Wochenkalender für Inhaltsplanung. Klicken Sie auf Zeitplan hinzufügen, um Zeitfenster zu erstellen. Wiederholungen für regelmäßige Inhalte. Höhere Priorität überschreibt niedrigere. Geräteebene überschreibt Gruppenebene.', + 'schedule.help_tip': 'Wochenkalender. Ziehen Sie über eine Zeitspanne, um einen Zeitplan anzulegen, ziehen Sie einen Block, um ihn zu verschieben, ziehen Sie seine Unterkante, um das Ende zu ändern, oder klicken Sie mit der rechten Maustaste für weitere Optionen. Höhere Priorität gewinnt, und der eigene Zeitplan eines Bildschirms überschreibt den seiner Gruppe.', 'schedule.prev_week': '< Zurück', 'schedule.next_week': 'Weiter >', 'schedule.add_schedule': 'Zeitplan hinzufügen', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4d6df1c..a29e4cd 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1,6 +1,9 @@ // 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 { + 'settings.help_tip': 'Your profile, password and two-factor sign-in, plus API tokens for integrations and tools to export or import your data.', + 'activity.help_tip': 'A record of what changed and who changed it — logins, content uploads, device commands and alerts. Useful for working out why a screen changed what it was showing.', + 'playlist.help_tip': 'A playlist is the running order a screen loops through. Add content items, set how long each shows, then assign the playlist to a screen or group. A screen plays one playlist at a time unless a schedule overrides it.', 'schedule.overnight_no_drag': 'This schedule runs past midnight — open it to change its times.', 'schedule.overnight_note': 'Runs past midnight — shown as two blocks.', @@ -1330,7 +1333,7 @@ export default { // Schedule 'schedule.title': 'Schedule', 'schedule.subtitle': 'Content scheduling calendar', - 'schedule.help_tip': 'Visual weekly calendar for content scheduling. Click Add Schedule to create time slots. Set recurrence for repeating content. Higher priority overrides lower. Device-level schedules override group-level.', + 'schedule.help_tip': 'Weekly calendar. Drag across a time to create a schedule, drag a block to move it, drag its lower edge to change when it ends, or right-click for more. Higher priority wins, and a screen\'s own schedule overrides its group\'s.', 'schedule.prev_week': '< Prev', 'schedule.next_week': 'Next >', 'schedule.add_schedule': 'Add Schedule', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 8a92ca3..e4ed977 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -1,6 +1,9 @@ // Spanish translations. Reviewed for UI register (informal tú). // Native review still recommended before publicizing as fully supported. export default { + '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.', + 'activity.help_tip': 'Registro de qué cambió y quién lo cambió: inicios de sesión, subidas de contenido, comandos a dispositivos y alertas. Útil para averiguar por qué una pantalla cambió lo que mostraba.', + 'playlist.help_tip': 'Una lista de reproducción es el orden que una pantalla repite en bucle. Añade contenidos, define cuánto dura cada uno y asigna la lista a una pantalla o a un grupo. Una pantalla reproduce una sola lista salvo que una programación la anule.', // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zona de otro diseño — reasignar', 'device.pl_item.orphan_zone_tip': 'La zona de este elemento no pertenece al diseño actual del dispositivo. Se sigue reproduciendo (recuperado en la zona más grande), pero reasígnalo a una zona de este diseño.', @@ -918,7 +921,7 @@ export default { // Schedule 'schedule.title': 'Horario', 'schedule.subtitle': 'Calendario de programación de contenido', - 'schedule.help_tip': 'Calendario semanal visual para programación. Haz clic en Agregar horario para crear franjas. Configura recurrencia para repetir contenido. La prioridad mayor anula la menor. Los horarios de dispositivo anulan los de grupo.', + 'schedule.help_tip': 'Calendario semanal. Arrastra sobre una franja horaria para crear una programación, arrastra un bloque para moverlo, arrastra su borde inferior para cambiar cuándo termina, o haz clic derecho para más opciones. La prioridad más alta gana, y la programación propia de una pantalla anula la de su grupo.', 'schedule.prev_week': '< Anterior', 'schedule.next_week': 'Siguiente >', 'schedule.add_schedule': 'Agregar horario', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 00ef318..ae52491 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -2,6 +2,9 @@ // standard for software UIs in France; tu would feel underdressed for a B2B tool). // Native review recommended before publicizing as fully supported. export default { + '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.', + 'activity.help_tip': 'Journal de ce qui a changé et par qui : connexions, envois de contenu, commandes aux appareils et alertes. Utile pour comprendre pourquoi un écran a changé d\'affichage.', + 'playlist.help_tip': 'Une playlist est l\'ordre de lecture qu\'un écran répète en boucle. Ajoutez des contenus, définissez la durée de chacun, puis affectez la playlist à un écran ou à un groupe. Un écran lit une seule playlist à la fois, sauf si une programmation la remplace.', // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zone d\'une autre mise en page — réattribuer', 'device.pl_item.orphan_zone_tip': 'La zone de cet élément ne fait pas partie de la mise en page actuelle de l\'appareil. Il continue de s\'afficher (récupéré dans la plus grande zone), mais réattribuez-le à une zone de cette mise en page.', @@ -888,7 +891,7 @@ export default { // Schedule 'schedule.title': 'Calendrier', 'schedule.subtitle': 'Calendrier de programmation du contenu', - 'schedule.help_tip': 'Calendrier hebdomadaire visuel pour la programmation. Cliquez sur Ajouter une plage pour créer des créneaux. La récurrence permet de répéter du contenu. La priorité plus haute prime. Les plages au niveau appareil priment sur celles de groupe.', + 'schedule.help_tip': 'Calendrier hebdomadaire. Faites glisser sur une plage horaire pour créer une programmation, déplacez un bloc pour le décaler, tirez son bord inférieur pour changer sa fin, ou faites un clic droit pour plus d\'options. La priorité la plus élevée l\'emporte, et la programmation propre à un écran remplace celle de son groupe.', 'schedule.prev_week': '< Préc', 'schedule.next_week': 'Suiv >', 'schedule.add_schedule': 'Ajouter une plage', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index 072edee..945fd42 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -1,6 +1,9 @@ // 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 { + '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.', + 'activity.help_tip': 'Registro di che cosa è cambiato e di chi lo ha cambiato: accessi, caricamenti di contenuti, comandi ai dispositivi e avvisi. Utile per capire perché uno schermo ha cambiato ciò che mostrava.', + 'playlist.help_tip': 'Una playlist è l\'ordine di riproduzione che uno schermo ripete in ciclo. Aggiungi contenuti, imposta per quanto tempo ciascuno viene mostrato e assegna la playlist a uno schermo o a un gruppo. Uno schermo riproduce una sola playlist alla volta, a meno che una pianificazione non la sostituisca.', // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zona di un altro layout — riassegna', 'device.pl_item.orphan_zone_tip': 'La zona di questo elemento non fa parte del layout attuale del dispositivo. Continua a essere riprodotto (recuperato nella zona più grande), ma riassegnalo a una zona di questo layout.', @@ -846,7 +849,7 @@ export default { // Schedule 'schedule.title': 'Programmazione', 'schedule.subtitle': 'Calendario di programmazione contenuti', - 'schedule.help_tip': 'Calendario settimanale visivo per la programmazione dei contenuti. Clicca su "Aggiungi Programmazione" per creare fasce orarie. Imposta la ricorrenza per i contenuti ripetitivi. La priorità più alta prevale su quella più bassa. Le programmazioni a livello di dispositivo prevalgono su quelle di gruppo.', + 'schedule.help_tip': 'Calendario settimanale. Trascina su una fascia oraria per creare una pianificazione, trascina un blocco per spostarlo, trascina il bordo inferiore per cambiarne la fine, oppure fai clic con il tasto destro per altre opzioni. La priorità più alta prevale e la pianificazione dello schermo sostituisce quella del suo gruppo.', 'schedule.prev_week': '< Prec', 'schedule.next_week': 'Succ >', 'schedule.add_schedule': 'Aggiungi Programmazione', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 70ba0a5..cb90cb1 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -2,6 +2,9 @@ // Reviewed for UI register (informal você). Native review recommended before // publicizing as fully supported. export default { + '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.', + 'activity.help_tip': 'Registo do que mudou e de quem o mudou: inícios de sessão, carregamentos de conteúdo, comandos a dispositivos e alertas. Útil para perceber por que razão um ecrã mudou o que estava a mostrar.', + 'playlist.help_tip': 'Uma playlist é a ordem de reprodução que um ecrã repete em ciclo. Adicione conteúdos, defina quanto tempo cada um aparece e atribua a playlist a um ecrã ou a um grupo. Um ecrã reproduz uma playlist de cada vez, a não ser que um agendamento a substitua.', // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zona de outro layout — reatribuir', 'device.pl_item.orphan_zone_tip': 'A zona deste item não faz parte do layout atual do dispositivo. Ele continua sendo reproduzido (recuperado na maior zona), mas reatribua-o a uma zona deste layout.', @@ -888,7 +891,7 @@ export default { // Schedule 'schedule.title': 'Agenda', 'schedule.subtitle': 'Calendário de programação de conteúdo', - 'schedule.help_tip': 'Calendário semanal visual para agendamento. Clique em Adicionar para criar slots. Defina recorrência para conteúdo recorrente. Prioridade maior sobrepõe menor. Agendas de dispositivo sobrepõem as de grupo.', + 'schedule.help_tip': 'Calendário semanal. Arraste sobre um intervalo para criar um agendamento, arraste um bloco para o mover, arraste a margem inferior para alterar quando termina, ou clique com o botão direito para mais opções. A prioridade mais alta prevalece, e o agendamento do próprio ecrã substitui o do seu grupo.', 'schedule.prev_week': '< Anterior', 'schedule.next_week': 'Próxima >', 'schedule.add_schedule': 'Adicionar agenda', diff --git a/frontend/js/views/activity.js b/frontend/js/views/activity.js index a4f9f8c..715fb8c 100644 --- a/frontend/js/views/activity.js +++ b/frontend/js/views/activity.js @@ -7,7 +7,7 @@ const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${l export async function render(container) { container.innerHTML = `

${t('common.loading')}

diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index e3507a0..e6bb700 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -71,7 +71,7 @@ async function renderList(container) { container.innerHTML = `