Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 404 additions & 0 deletions docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions src/static/css/pad/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,21 @@
text-align: right;
text-decoration: none;
cursor: pointer;
background: transparent;
border: 0;
padding: 0;
font-family: inherit;
line-height: 1;
}
#titlebar .stick-to-screen-btn {
font-size: 10px;
padding-top: 2px;
}
#titlebar .stick-to-screen-btn:focus-visible,
#titlebar .hide-reduce-btn:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}

/* -- MESSAGES -- */
#chattext {
Expand Down Expand Up @@ -125,6 +135,12 @@
cursor: pointer;
display: none;
padding: 5px;
font: inherit;
color: inherit;
}
#chaticon:focus-visible {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
#chaticon a {
text-decoration: none
Expand Down
13 changes: 13 additions & 0 deletions src/static/js/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,19 @@ exports.chat = (() => {

// initial messages are loaded in pad.js' _afterHandshake

$('#chaticon').on('click', (e) => {
e.preventDefault();
this.show();
});
$('#titlecross').on('click', (e) => {
e.preventDefault();
this.hide();
});
$('#titlesticky').on('click', (e) => {
e.preventDefault();
this.stickToScreen(true);
});

$('#chatcounter').text(0);
$('#chatloadmessagesbutton').on('click', () => {
const start = Math.max(this.historyPointer - 20, 0);
Expand Down
38 changes: 37 additions & 1 deletion src/static/js/pad_editbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ exports.padeditbar = new class {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
this._lastTrigger = null;
}

init() {
Expand All @@ -145,7 +146,8 @@ exports.padeditbar = new class {
});

$('.show-more-icon-btn').on('click', () => {
$('.toolbar').toggleClass('full-icons');
const expanded = $('.toolbar').toggleClass('full-icons').hasClass('full-icons');
$('.show-more-icon-btn').attr('aria-expanded', String(expanded));
});
this.checkAllIconsAreDisplayedInToolbar();
$(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
Expand Down Expand Up @@ -208,6 +210,15 @@ exports.padeditbar = new class {
$('.nice-select').removeClass('open');
$('.toolbar-popup').removeClass('popup-show');

// Remember the trigger so we can restore focus when the dialog closes.
const wasAnyOpen = $('.popup.popup-show').length > 0;
if (!wasAnyOpen && moduleName !== 'none') {
const active = document.activeElement;
if (active && active !== document.body) this._lastTrigger = active;
}
Comment on lines +227 to +236
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Focus restore captures wrong trigger 🐞 Bug ≡ Correctness

toggleDropDown() tries to remember the trigger via document.activeElement, but toolbar clicks
blur the focused element before executing the command, so _lastTrigger is frequently unset or not
the actual opener and focus is not reliably restored on close/Escape.
Agent Prompt
### Issue description
`toggleDropDown()` stores the trigger based on `document.activeElement`, but toolbar click handling blurs the currently focused element before calling the command, so `document.activeElement` is often `body` (or otherwise not the intended trigger). This makes focus restoration unreliable.

### Issue Context
The toolbar click handler intentionally blurs `:focus` before executing the dropdown command. `toggleDropDown()` should therefore not depend on `document.activeElement` at that moment.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-72]
- src/static/js/pad_editbar.ts[202-218]

### Suggested fix
- Capture the actual trigger element from the originating UI event (e.g., the clicked button) and store it before blurring, then pass it through to `toggleDropDown()` (or store it on the padeditbar instance before invoking the command).
- Alternatively, derive the trigger deterministically from `moduleName` (e.g., locate `li[data-key=<moduleName>] button`) instead of using `document.activeElement`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


let openedModule = null;

// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
for (const thisModuleName of this.dropdowns) {
Expand Down Expand Up @@ -236,9 +247,27 @@ exports.padeditbar = new class {
} else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
openedModule = module;
}
}
}

if (openedModule) {
// Move focus into the now-visible popup so keyboard users land inside the dialog.
const target = openedModule;
requestAnimationFrame(() => {
const focusable = target.find(
'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
'[tabindex]:not([tabindex="-1"]):visible').first();
if (focusable.length) focusable[0].focus();
});
Comment on lines +273 to +295
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Embed focus overridden 🐞 Bug ≡ Correctness

toggleDropDown() now focuses the first focusable element in the opened popup on the next animation
frame, which overrides existing command-specific focus logic. In the Embed popup this steals focus
from #linkinput (which the embed command intentionally focuses/selects) and moves it to the readonly
checkbox instead.
Agent Prompt
### Issue description
`toggleDropDown()` auto-focuses the first focusable control in the opened popup via `requestAnimationFrame()`. This can override existing command handlers that deliberately focus a particular element (notably the Embed command focuses/selects `#linkinput`).

### Issue Context
In `pad.html`, the Embed dialog has `#readonlyinput` before `#linkinput`, so the auto-focus targets `#readonlyinput`, stealing focus from `#linkinput`.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[264-273]
- src/static/js/pad_editbar.ts[429-433]
- src/templates/pad.html[328-338]

### Implementation notes
Modify the rAF auto-focus behavior to be conditional, for example:
- In the rAF callback, only focus the first element if focus is still on the trigger/body (or not already inside the opened popup), OR
- Allow callers to opt out (e.g., `toggleDropDown(moduleName, {autoFocus: false})`) and use that for `embed`, OR
- Prefer a more specific focus target for certain dialogs (Embed -> `#linkinput`).
Preserve the new general behavior for dialogs that don’t explicitly manage focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} else if ($('.popup.popup-show').length === 0 && this._lastTrigger) {
// All popups closed — restore focus to the element that opened the first one.
const trigger = this._lastTrigger;
this._lastTrigger = null;
if (document.body.contains(trigger)) trigger.focus();
}
Comment on lines +273 to +305
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale focus restoration 🐞 Bug ≡ Correctness

padeditbar.toggleDropDown('none') now restores focus to this._lastTrigger whenever no popups are
open, even if no popup was previously open. Because _lastTrigger is set on every toolbar button
click, background calls to toggleDropDown('none') (e.g., connection-state handling) can unexpectedly
move focus from the editor to a stale toolbar button.
Agent Prompt
### Issue description
`toggleDropDown('none')` restores focus to `this._lastTrigger` even when no popup was previously open, which can steal focus during programmatic calls (e.g., connectivity state changes).

### Issue Context
- `_lastTrigger` is set on every toolbar button click.
- Multiple code paths call `toggleDropDown('none')` as a cleanup step regardless of whether a popup is open.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-81]
- src/static/js/pad_editbar.ts[210-287]

### Suggested fix
- Gate the focus-restore block so it only runs when a popup was actually open at the start of the function (e.g., `wasAnyOpen === true`) and is now closed.
- Consider clearing `_lastTrigger` when handling non-dropdown toolbar commands, or only setting `_lastTrigger` for dropdown-opening commands, to avoid stale values lingering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} catch (err) {
cbErr = err || new Error(err);
} finally {
Expand Down Expand Up @@ -289,6 +318,13 @@ exports.padeditbar = new class {
}

_bodyKeyEvent(evt) {
// Escape from inside any open popup: close the popup and let
// toggleDropDown('none') restore focus to the trigger.
if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
this.toggleDropDown('none');
evt.preventDefault();
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Users escape close broken 🐞 Bug ≡ Correctness

Pressing Escape inside the Users popup calls toggleDropDown('none'), but toggleDropDown('none')
explicitly skips the users module, so the Users dialog won’t close. Because the handler
preventsDefault() and returns, the existing Escape behavior won’t run either, leaving keyboard users
stuck in the Users popup.
Agent Prompt
### Issue description
Escape-to-close calls `toggleDropDown('none')`, but `toggleDropDown('none')` intentionally skips the `users` dropdown, so Escape does not close the Users popup.

### Issue Context
The new Escape handler is meant to close any open popup and restore focus. The Users popup is treated specially in `toggleDropDown('none')` and is skipped.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[329-336]
- src/static/js/pad_editbar.ts[232-246]

### Implementation notes
One of:
1) Remove the `users` skip in the `moduleName === 'none'` branch, but preserve the `stickyUsers` behavior (do not close if sticky), or
2) In the Escape handler, detect if `#users` is open and not sticky, and explicitly close it (remove `popup-show`, remove `.selected` from the toolbar button) instead of calling `toggleDropDown('none')`.
Ensure focus restoration still works after close.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +356 to +384
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Escape won't close colorpicker 🐞 Bug ≡ Correctness

padeditbar._bodyKeyEvent() intercepts Escape whenever any .popup has .popup-show, but it only
closes dropdown popups via toggleDropDown('none') (and special-cases #users). Popups that are
opened outside toggleDropDown (such as #mycolorpicker) keep .popup-show, so Escape becomes a
no-op while still calling preventDefault() and returning early.
Agent Prompt
### Issue description
Escape handling in `pad_editbar._bodyKeyEvent()` triggers for any `.popup.popup-show`, but the close logic only affects dropdown popups (and partially `#users`). Popups opened by other code paths (e.g. `#mycolorpicker` from `pad_userlist.ts`) remain open, so Escape is swallowed (preventDefault + early return) without dismissing the visible popup.

### Issue Context
`#mycolorpicker` is opened by directly adding `.popup-show` and is not part of `padeditbar.dropdowns`, so `toggleDropDown('none')` cannot close it.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[346-363]
- src/static/js/pad_editbar.ts[235-266]
- src/static/js/pad_userlist.ts[587-616]

### Suggested fix
Update the Escape branch to remove `.popup-show` from *all* open popups that should be dismissible via Escape (at least including `#mycolorpicker`, and excluding pinned/sticky cases such as `#users.stickyUsers`). For example:
- Identify all `$('.popup.popup-show')` elements.
- Filter out popups that should remain open (e.g. `#users.stickyUsers`).
- Remove `.popup-show` from the remaining open popups.
- Clear any corresponding toolbar `selected` states as needed.
- Then call `toggleDropDown('none')` (or equivalent) purely for shared cleanup + focus restore, ensuring it does not re-swallow Escape when no popups remain.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
Expand Down
8 changes: 7 additions & 1 deletion src/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<%
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs;
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
%>
<!doctype html>
<html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>">

<title><%=settings.title%></title>
<meta charset="utf-8">
Expand Down
69 changes: 36 additions & 33 deletions src/templates/pad.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
, pluginUtils = require('ep_etherpad-lite/static/js/pluginfw/shared')
;
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
%>
<!doctype html>
<html translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<head>
<% e.begin_block("htmlHead"); %>
<% e.end_block(); %>
Expand Down Expand Up @@ -71,7 +74,7 @@
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
<% e.end_block(); %>
</ul>
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
</div>

<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
Expand Down Expand Up @@ -114,11 +117,11 @@
<!-- SETTINGS POPUP (change font, language, chat parameters) -->
<!------------------------------------------------------------->

<div id="settings" class="popup"><div class="popup-content">
<div id="settings" class="popup" role="dialog" aria-modal="true" aria-labelledby="settings-title"><div class="popup-content">
<% if (settings.enablePadWideSettings) { %>
<h1 data-l10n-id="pad.settings.title">Settings</h1>
<h1 id="settings-title" data-l10n-id="pad.settings.title">Settings</h1>
<% } else { %>
<h1 data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
<h1 id="settings-title" data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
<% } %>
<div class="settings-sections">
<div id="user-settings-section" class="settings-section">
Expand Down Expand Up @@ -252,8 +255,8 @@ <h2 data-l10n-id="pad.settings.about">About</h2>
<!-- IMPORT EXPORT POPUP -->
<!------------------------->

<div id="import_export" class="popup"><div class="popup-content">
<h1 data-l10n-id="pad.importExport.import_export"></h1>
<div id="import_export" class="popup" role="dialog" aria-modal="true" aria-labelledby="importexport-title"><div class="popup-content">
<h1 id="importexport-title" data-l10n-id="pad.importExport.import_export"></h1>
<div class="acl-write">
<% e.begin_block("importColumn"); %>
<h2 data-l10n-id="pad.importExport.import"></h2>
Expand All @@ -277,23 +280,23 @@ <h2 data-l10n-id="pad.importExport.import"></h2>
<div id="exportColumn">
<h2 data-l10n-id="pad.importExport.export"></h2>
<% e.begin_block("exportColumn"); %>
<a id="exportetherpada" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad"></span>
<a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
<span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
</a>
<a id="exporthtmla" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
<a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
<span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml" aria-hidden="true"></span>
</a>
<a id="exportplaina" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain"></span>
<a id="exportplaina" target="_blank" class="exportlink" aria-label="Export as plain text">
<span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain" aria-hidden="true"></span>
</a>
<a id="exportworda" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword"></span>
<a id="exportworda" target="_blank" class="exportlink" aria-label="Export as Microsoft Word">
<span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword" aria-hidden="true"></span>
</a>
<a id="exportpdfa" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></span>
<a id="exportpdfa" target="_blank" class="exportlink" aria-label="Export as PDF">
<span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf" aria-hidden="true"></span>
</a>
<a id="exportopena" target="_blank" class="exportlink">
<span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen"></span>
<a id="exportopena" target="_blank" class="exportlink" aria-label="Export as ODF (Open Document Format)">
<span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen" aria-hidden="true"></span>
</a>
<% e.end_block(); %>
</div>
Expand All @@ -304,7 +307,7 @@ <h2 data-l10n-id="pad.importExport.export"></h2>
<!-- CONNECTIVITY POPUP (when you get disconnected) -->
<!---------------------------------------------------->

<div id="connectivity" class="popup"><div class="popup-content">
<div id="connectivity" class="popup" role="dialog" aria-modal="true" aria-label="Connection status"><div class="popup-content">
<% e.begin_block("modals"); %>
<div class="connected visible">
<h2 data-l10n-id="pad.modals.connected"></h2>
Expand Down Expand Up @@ -387,9 +390,9 @@ <h2 data-l10n-id="pad.modals.disconnected.explanation"></h2>
<!-- EMBED POPUP (Share, embed) -->
<!-------------------------------->

<div id="embed" class="popup"><div class="popup-content">
<div id="embed" class="popup" role="dialog" aria-modal="true" aria-labelledby="embed-title"><div class="popup-content">
<% e.begin_block("embedPopup"); %>
<h1 data-l10n-id="pad.share"></h1>
<h1 id="embed-title" data-l10n-id="pad.share"></h1>
<div id="embedreadonly" class="acl-write">
<input type="checkbox" id="readonlyinput">
<label for="readonlyinput" data-l10n-id="pad.share.readonly"></label>
Expand All @@ -411,11 +414,11 @@ <h2 data-l10n-id="pad.share.emebdcode"></h2>
<!-- USERS POPUP (set username, color, see other users names & color) -->
<!---------------------------------------------------------------------->

<div id="users" class="popup"><div class="popup-content">
<div id="users" class="popup" role="dialog" aria-modal="true" aria-label="Users on this pad"><div class="popup-content">
<% e.begin_block("userlist"); %>
<div id="connectionstatus"></div>
<div id="myuser">
<div id="mycolorpicker" class="popup"><div class="popup-content">
<div id="mycolorpicker" class="popup" role="dialog" aria-modal="true" aria-label="Choose your author color"><div class="popup-content">
<div id="colorpicker"></div>
<div class="btn-container">
<button id="mycolorpickersave" data-l10n-id="pad.colorpicker.save" class="btn btn-primary"></button>
Expand All @@ -428,7 +431,7 @@ <h2 data-l10n-id="pad.share.emebdcode"></h2>
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
</div>
</div>
<div id="otherusers" role="document">
<div id="otherusers" role="region" aria-live="polite" aria-label="Active users on this pad">
<table id="otheruserstable" cellspacing="0" cellpadding="0" border="0">
<tr><td></td></tr>
</table>
Expand All @@ -442,18 +445,18 @@ <h2 data-l10n-id="pad.share.emebdcode"></h2>
<!----------- CHAT ------------>
<!----------------------------->

<div id="chaticon" class="visible" onclick="chat.show();return false;" title="Chat (Alt C)">
<button type="button" id="chaticon" class="visible" title="Chat (Alt C)" aria-label="Open chat" data-l10n-id="pad.chat.title">
<span id="chatlabel" data-l10n-id="pad.chat"></span>
<span class="buttonicon buttonicon-chat"></span>
<span id="chatcounter">0</span>
</div>
<span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
<span id="chatcounter" aria-label="Unread messages">0</span>
</button>

<div id="chatbox">
<div class="chat-content">
<div id="titlebar">
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">-&nbsp;</a>
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">█&nbsp;&nbsp;</a>
<button type="button" id="titlecross" class="hide-reduce-btn" aria-label="Close chat">&minus;</button>
<button type="button" id="titlesticky" class="stick-to-screen-btn" aria-label="Pin chat to screen" data-l10n-id="pad.chat.stick.title">&#9608;</button>
</div>
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
Expand All @@ -472,8 +475,8 @@ <h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->
<!------------------------------------------------------------------>
<% if (settings.skinName == 'colibris') { %>
<div id="skin-variants" class="popup"><div class="popup-content">
<h1>Skin Builder</h1>
<div id="skin-variants" class="popup" role="dialog" aria-modal="true" aria-labelledby="skin-variants-title"><div class="popup-content">
<h1 id="skin-variants-title">Skin Builder</h1>

<div class="dropdowns-container">
<% containers = [ "toolbar", "background", "editor" ]; %>
Expand Down
5 changes: 4 additions & 1 deletion src/templates/timeslider.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<%
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
%>
<!doctype html>
<html translate="no" class="pad <%=settings.skinVariants%>">
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
<head>
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
<script>
Expand Down
Loading
Loading