feat(sirius-x): language picker on the right with globe icon and flags

The native select sat mid-nav next to the font pill and was easy to miss.
Replace it with a custom dropdown placed just before the wallet button:
globe icon, the current language's flag and native name, a menu of all
languages with flags. Flags are tiny inline SVGs rather than emoji
(Windows renders flag emoji as bare letters) or a flag CDN (no third-party
request from the visitor's browser). Painted on window load so it lands
after the deferred nav scripts; keyboard: arrows, Escape, click outside.
This commit is contained in:
Local Dev 2026-09-17 00:36:55 +02:00
parent 08a0c8d33f
commit 085824faf6
5 changed files with 118 additions and 23 deletions

View file

@ -92,7 +92,7 @@
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
</script>
<script src="../js/i18n.js?v=20260917a"></script>
<script src="../js/i18n.js?v=20260917b"></script>
</head>
<body>

View file

@ -130,7 +130,7 @@
.verify dd{margin:2px 0 0;font-family:ui-monospace,monospace;font-size:12.5px;word-break:break-all;color:var(--ink)}
footer{border-top:1px solid var(--line);padding:2.4rem 0 3.5rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
</style>
<script src="./js/i18n.js?v=20260917a"></script>
<script src="./js/i18n.js?v=20260917b"></script>
</head>
<body>

View file

@ -203,50 +203,145 @@
}
// ---------- the dropdown ----------
var picker = null;
// A custom menu rather than a <select>: a native select cannot show
// flags, and emoji flags render as bare letters on Windows. Flags are
// tiny inline SVGs (3:2, simplified) so no third-party image request
// ever leaves the visitor's browser. Sits on the right of the top nav,
// just before the wallet button.
var FLAGS = {
en: '<rect width="60" height="40" fill="#012169"/><path d="M0 0L60 40M60 0L0 40" stroke="#fff" stroke-width="8"/><path d="M0 0L60 40M60 0L0 40" stroke="#C8102E" stroke-width="3"/><path d="M30 0V40M0 20H60" stroke="#fff" stroke-width="10"/><path d="M30 0V40M0 20H60" stroke="#C8102E" stroke-width="6"/>',
de: '<rect width="60" height="40" fill="#000"/><rect y="13.33" width="60" height="13.34" fill="#D00"/><rect y="26.67" width="60" height="13.33" fill="#FFCE00"/>',
el: '<rect width="60" height="40" fill="#0D5EAF"/><rect y="4.44" width="60" height="4.45" fill="#fff"/><rect y="13.33" width="60" height="4.45" fill="#fff"/><rect y="22.22" width="60" height="4.45" fill="#fff"/><rect y="31.11" width="60" height="4.45" fill="#fff"/><rect width="22.22" height="22.22" fill="#0D5EAF"/><rect x="8.89" width="4.44" height="22.22" fill="#fff"/><rect y="8.89" width="22.22" height="4.44" fill="#fff"/>',
ru: '<rect width="60" height="40" fill="#fff"/><rect y="13.33" width="60" height="13.34" fill="#0039A6"/><rect y="26.67" width="60" height="13.33" fill="#D52B1E"/>',
es: '<rect width="60" height="40" fill="#AA151B"/><rect y="10" width="60" height="20" fill="#F1BF00"/>',
fr: '<rect width="20" height="40" fill="#0055A4"/><rect x="20" width="20" height="40" fill="#fff"/><rect x="40" width="20" height="40" fill="#EF4135"/>',
pt: '<rect width="60" height="40" fill="#F00"/><rect width="24" height="40" fill="#060"/><circle cx="24" cy="20" r="7" fill="#FFE000"/><circle cx="24" cy="20" r="3.5" fill="#F00"/>',
};
var GLOBE = '<svg class="lp-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg>';
var CARET = '<svg class="lp-caret" viewBox="0 0 10 6" aria-hidden="true"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
function flagSvg(code) {
return '<svg class="lp-flag" viewBox="0 0 60 40" aria-hidden="true">' + (FLAGS[code] || "") + "</svg>";
}
var picker = null, pickerBtn = null, pickerMenu = null;
function closePicker() {
if (!picker) return;
picker.classList.remove("open");
pickerBtn.setAttribute("aria-expanded", "false");
}
function openPicker() {
if (!picker) return;
picker.classList.add("open");
pickerBtn.setAttribute("aria-expanded", "true");
var active = pickerMenu.querySelector('[aria-selected="true"]');
if (active) active.focus();
}
function paintPicker() {
if (picker) return;
var css = document.createElement("style");
css.textContent =
".lang-picker{margin:0 6px;padding:4px 26px 4px 10px;border-radius:999px;background:rgba(255,255,255,.04);" +
"border:1px solid rgba(255,255,255,.08);color:var(--mut,#b8c2d4);font:inherit;font-size:12px;cursor:pointer;" +
"-webkit-appearance:none;appearance:none;background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23b8c2d4' stroke-width='1.5'/></svg>\");" +
"background-repeat:no-repeat;background-position:right 10px center}" +
".lang-picker:hover{color:var(--ink,#f1f4fa);border-color:rgba(255,255,255,.18)}" +
".lang-picker option{background:#141a24;color:#f1f4fa}" +
".lang-picker-float{position:fixed;bottom:20px;left:20px;z-index:99;backdrop-filter:blur(10px)}";
".lang-picker{position:relative;margin-left:auto;font-family:var(--sans,inherit)}" +
".topnav .lang-picker + .profile-wrap,.topnav .lang-picker + a.portal{margin-left:8px}" +
".lp-btn{display:inline-flex;align-items:center;gap:7px;padding:5px 10px 5px 9px;border-radius:999px;" +
"background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);color:var(--mut,#b8c2d4);" +
"font:inherit;font-size:12.5px;line-height:1;cursor:pointer;transition:border-color .12s,color .12s,background .12s}" +
".lp-btn:hover,.lang-picker.open .lp-btn{color:var(--ink,#f1f4fa);border-color:rgba(214,255,61,.35);background:rgba(214,255,61,.08)}" +
".lp-globe{width:15px;height:15px;opacity:.85;flex:none}" +
".lp-flag{width:20px;height:14px;border-radius:2px;box-shadow:0 0 0 1px rgba(255,255,255,.14);flex:none}" +
".lp-caret{width:9px;height:9px;opacity:.7;transition:transform .15s;flex:none}" +
".lang-picker.open .lp-caret{transform:rotate(180deg)}" +
".lp-menu{position:absolute;right:0;top:calc(100% + 8px);min-width:200px;padding:6px;border-radius:12px;" +
"background:var(--panel,#141a24);border:1px solid rgba(255,255,255,.1);box-shadow:0 12px 32px rgba(0,0,0,.45);z-index:200;display:none}" +
".lang-picker.open .lp-menu{display:block}" +
".lp-opt{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:8px;" +
"background:transparent;color:var(--mut,#b8c2d4);font:inherit;font-size:13.5px;text-align:left;cursor:pointer}" +
".lp-opt:hover,.lp-opt:focus-visible{background:rgba(255,255,255,.06);color:var(--ink,#f1f4fa);outline:none}" +
'.lp-opt[aria-selected="true"]{color:var(--acid,#d6ff3d);background:rgba(214,255,61,.08)}' +
".lp-opt .lp-code{margin-left:auto;font-family:var(--mono,monospace);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;opacity:.55}" +
".lang-picker-float{position:fixed;bottom:20px;right:20px;z-index:99}" +
"@media (max-width:560px){.lp-name{display:none}}";
document.head.appendChild(css);
picker = document.createElement("select");
picker = document.createElement("div");
picker.className = "lang-picker";
picker.setAttribute("aria-label", "Language");
picker.setAttribute("translate", "no");
picker.title = "Language";
pickerBtn = document.createElement("button");
pickerBtn.type = "button";
pickerBtn.className = "lp-btn";
pickerBtn.setAttribute("aria-haspopup", "listbox");
pickerBtn.setAttribute("aria-expanded", "false");
pickerBtn.setAttribute("aria-label", "Language");
pickerBtn.title = "Language";
pickerMenu = document.createElement("div");
pickerMenu.className = "lp-menu";
pickerMenu.setAttribute("role", "listbox");
pickerMenu.setAttribute("aria-label", "Language");
var html = "";
for (var code in LANGS) html += '<option value="' + code + '">' + LANGS[code] + "</option>";
picker.innerHTML = html;
picker.addEventListener("change", function () { setLang(picker.value); });
for (var code in LANGS) {
html += '<button type="button" class="lp-opt" role="option" data-lang="' + code + '" aria-selected="false" tabindex="-1">' +
flagSvg(code) + "<span>" + LANGS[code] + '</span><span class="lp-code">' + code + "</span></button>";
}
pickerMenu.innerHTML = html;
picker.appendChild(pickerBtn);
picker.appendChild(pickerMenu);
pickerBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (picker.classList.contains("open")) closePicker(); else openPicker();
});
pickerMenu.addEventListener("click", function (e) {
var opt = e.target.closest ? e.target.closest("[data-lang]") : null;
if (!opt) return;
e.stopPropagation();
closePicker();
pickerBtn.focus();
setLang(opt.getAttribute("data-lang"));
});
picker.addEventListener("keydown", function (e) {
var opts = pickerMenu.querySelectorAll("[data-lang]");
var i = Array.prototype.indexOf.call(opts, document.activeElement);
if (e.key === "Escape") { closePicker(); pickerBtn.focus(); }
else if (e.key === "ArrowDown") { e.preventDefault(); if (!picker.classList.contains("open")) openPicker(); else opts[Math.min(i + 1, opts.length - 1)].focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); if (i > 0) opts[i - 1].focus(); }
});
document.addEventListener("click", closePicker);
var nav = document.querySelector(".topnav");
if (nav) {
var portalLink = nav.querySelector("a.portal");
var fontPicker = nav.querySelector(".font-picker");
nav.insertBefore(picker, fontPicker || portalLink || null);
// Right side: directly before the wallet button (profile-wrap once
// profile-menu.js has wrapped the portal link, the bare link before
// that), after everything else.
var wallet = nav.querySelector(".profile-wrap") || nav.querySelector("a.portal");
if (wallet) nav.insertBefore(picker, wallet); else nav.appendChild(picker);
} else {
picker.classList.add("lang-picker-float");
document.body.appendChild(picker);
}
refreshPicker();
}
function refreshPicker() { if (picker) picker.value = lang; }
function refreshPicker() {
if (!picker) return;
pickerBtn.innerHTML = GLOBE + flagSvg(lang) + '<span class="lp-name">' + (LANGS[lang] || lang) + "</span>" + CARET;
var opts = pickerMenu.querySelectorAll("[data-lang]");
for (var i = 0; i < opts.length; i++) {
opts[i].setAttribute("aria-selected", opts[i].getAttribute("data-lang") === lang ? "true" : "false");
}
}
// ---------- boot ----------
lang = detect();
var ready = lang === "en" ? Promise.resolve(null) : load(lang).then(function (d) { dict = d; return d; }).catch(function () { lang = "en"; return null; });
function boot() {
observe();
ready.then(function () { applyAll(); paintPicker(); });
ready.then(function () { applyAll(); });
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else boot();
// The picker is painted on `load`, after the deferred nav scripts
// (theme.js font pill, profile-menu.js wallet button) have laid out the
// top nav, so it lands in a stable spot on the right.
if (document.readyState === "complete") paintPicker();
else window.addEventListener("load", paintPicker);
window.siriusI18n = {
get lang() { return lang; },

View file

@ -166,7 +166,7 @@
.name-row{grid-template-columns:1fr auto}
}
</style>
<script src="./js/i18n.js?v=20260917a"></script>
<script src="./js/i18n.js?v=20260917b"></script>
</head>
<body>

View file

@ -115,7 +115,7 @@
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
</script>
<script src="./js/i18n.js?v=20260917a"></script>
<script src="./js/i18n.js?v=20260917b"></script>
</head>
<body>