diff --git a/web/public/assets/js/app/__tests__/mobile-menu.test.js b/web/public/assets/js/app/__tests__/mobile-menu.test.js
new file mode 100644
index 0000000..d03cde5
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/mobile-menu.test.js
@@ -0,0 +1,455 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { __test__, initializeMobileMenu } from '../mobile-menu.js';
+
+const { createMobileMenuController, resolveFocusableElements } = __test__;
+
+function createClassList() {
+ const values = new Set();
+ return {
+ add(...names) {
+ names.forEach(name => values.add(name));
+ },
+ remove(...names) {
+ names.forEach(name => values.delete(name));
+ },
+ contains(name) {
+ return values.has(name);
+ }
+ };
+}
+
+function createElement(tagName = 'div', initialId = '') {
+ const listeners = new Map();
+ const attributes = new Map();
+ if (initialId) {
+ attributes.set('id', String(initialId));
+ }
+ return {
+ tagName: tagName.toUpperCase(),
+ attributes,
+ classList: createClassList(),
+ dataset: {},
+ hidden: false,
+ parentNode: null,
+ nextSibling: null,
+ setAttribute(name, value) {
+ attributes.set(name, String(value));
+ },
+ getAttribute(name) {
+ return attributes.has(name) ? attributes.get(name) : null;
+ },
+ addEventListener(event, handler) {
+ listeners.set(event, handler);
+ },
+ dispatchEvent(event) {
+ const key = typeof event === 'string' ? event : event?.type;
+ const handler = listeners.get(key);
+ if (handler) {
+ handler(event);
+ }
+ },
+ appendChild(node) {
+ this.lastAppended = node;
+ return node;
+ },
+ insertBefore(node, nextSibling) {
+ this.lastInserted = { node, nextSibling };
+ return node;
+ },
+ focus() {
+ globalThis.document.activeElement = this;
+ },
+ querySelector() {
+ return null;
+ },
+ querySelectorAll() {
+ return [];
+ }
+ };
+}
+
+function createDomStub() {
+ const originalDocument = globalThis.document;
+ const registry = new Map();
+ const documentStub = {
+ body: createElement('body'),
+ activeElement: null,
+ querySelectorAll() {
+ return [];
+ },
+ getElementById(id) {
+ return registry.get(id) || null;
+ }
+ };
+ globalThis.document = documentStub;
+ return {
+ documentStub,
+ registry,
+ cleanup() {
+ globalThis.document = originalDocument;
+ }
+ };
+}
+
+function createWindowStub(matches = true) {
+ const listeners = new Map();
+ const mediaListeners = new Map();
+ return {
+ matchMedia() {
+ return {
+ matches,
+ addEventListener(event, handler) {
+ mediaListeners.set(event, handler);
+ }
+ };
+ },
+ addEventListener(event, handler) {
+ listeners.set(event, handler);
+ },
+ dispatchEvent(event) {
+ const key = typeof event === 'string' ? event : event?.type;
+ const handler = listeners.get(key);
+ if (handler) {
+ handler(event);
+ }
+ },
+ dispatchMediaChange() {
+ const handler = mediaListeners.get('change');
+ if (handler) {
+ handler();
+ }
+ }
+ };
+}
+
+function createWindowStubWithListener(matches = true) {
+ const listeners = new Map();
+ let mediaHandler = null;
+ return {
+ matchMedia() {
+ return {
+ matches,
+ addListener(handler) {
+ mediaHandler = handler;
+ }
+ };
+ },
+ addEventListener(event, handler) {
+ listeners.set(event, handler);
+ },
+ dispatchMediaChange() {
+ if (mediaHandler) {
+ mediaHandler();
+ }
+ }
+ };
+}
+
+test('mobile menu toggles open state and aria-expanded', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+ const closeButton = createElement('button');
+ const navLink = createElement('a');
+
+ menu.hidden = true;
+ menuPanel.classList.add('mobile-menu__panel');
+
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = selector => {
+ if (selector === '[data-mobile-menu-close]') return [closeButton];
+ if (selector === 'a') return [navLink];
+ return [];
+ };
+ menuPanel.querySelectorAll = () => [closeButton, navLink];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+ windowStub.dispatchMediaChange();
+
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ assert.equal(menu.hidden, false);
+ assert.equal(menuToggle.getAttribute('aria-expanded'), 'true');
+ assert.equal(documentStub.body.classList.contains('menu-open'), true);
+
+ navLink.dispatchEvent({ type: 'click' });
+ assert.equal(menu.hidden, true);
+
+ closeButton.dispatchEvent({ type: 'click' });
+ assert.equal(menu.hidden, true);
+ assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
+ } finally {
+ cleanup();
+ }
+});
+
+test('mobile menu closes on escape and route changes', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+ const closeButton = createElement('button');
+
+ menu.hidden = true;
+ menuPanel.classList.add('mobile-menu__panel');
+
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = selector => {
+ if (selector === '[data-mobile-menu-close]') return [closeButton];
+ return [];
+ };
+ menuPanel.querySelectorAll = () => [closeButton];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+
+ menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
+ assert.equal(menu.hidden, true);
+
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ assert.equal(menu.hidden, false);
+
+ menuPanel.dispatchEvent({ type: 'keydown', key: 'ArrowDown' });
+ assert.equal(menu.hidden, false);
+
+ menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
+ assert.equal(menu.hidden, true);
+
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ windowStub.dispatchEvent({ type: 'hashchange' });
+ assert.equal(menu.hidden, true);
+
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ windowStub.dispatchEvent({ type: 'popstate' });
+ assert.equal(menu.hidden, true);
+ } finally {
+ cleanup();
+ }
+});
+
+test('mobile menu traps focus within the panel', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+ const firstLink = createElement('a');
+ const lastButton = createElement('button');
+
+ menuPanel.classList.add('mobile-menu__panel');
+ menuPanel.querySelectorAll = () => [firstLink, lastButton];
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = () => [];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+
+ documentStub.activeElement = lastButton;
+ menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: false });
+ assert.equal(documentStub.activeElement, firstLink);
+
+ documentStub.activeElement = firstLink;
+ menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: true });
+ assert.equal(documentStub.activeElement, lastButton);
+ } finally {
+ cleanup();
+ }
+});
+
+test('resolveFocusableElements filters out aria-hidden nodes', () => {
+ const hiddenButton = createElement('button');
+ hiddenButton.getAttribute = name => (name === 'aria-hidden' ? 'true' : null);
+ const openLink = createElement('a');
+ const bareNode = { tagName: 'DIV' };
+ const container = {
+ querySelectorAll() {
+ return [hiddenButton, bareNode, openLink];
+ }
+ };
+
+ const focusables = resolveFocusableElements(container);
+ assert.equal(focusables.length, 1);
+ assert.equal(focusables[0], openLink);
+});
+
+test('resolveFocusableElements handles empty containers', () => {
+ assert.deepEqual(resolveFocusableElements(null), []);
+ assert.deepEqual(resolveFocusableElements({}), []);
+});
+
+test('mobile menu focuses the panel when no focusables exist', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+ const lastActive = createElement('button');
+
+ menuPanel.classList.add('mobile-menu__panel');
+ menuPanel.querySelectorAll = () => [];
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = () => [];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+ documentStub.activeElement = lastActive;
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ assert.equal(documentStub.activeElement, menuPanel);
+
+ menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
+ assert.equal(documentStub.activeElement, lastActive);
+ } finally {
+ cleanup();
+ }
+});
+
+test('mobile menu registers legacy media query listeners', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStubWithListener(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+
+ menuPanel.classList.add('mobile-menu__panel');
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = () => [];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+ windowStub.dispatchMediaChange();
+ assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
+ } finally {
+ cleanup();
+ }
+});
+
+test('mobile menu safely no-ops without required nodes', () => {
+ const { documentStub, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ try {
+ const controller = createMobileMenuController({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+
+ controller.initialize();
+ controller.openMenu();
+ controller.closeMenu();
+ controller.syncLayout();
+ assert.equal(documentStub.body.classList.contains('menu-open'), false);
+ } finally {
+ cleanup();
+ }
+});
+
+test('initializeMobileMenu returns a controller', () => {
+ const { documentStub, registry, cleanup } = createDomStub();
+ const windowStub = createWindowStub(true);
+
+ const menuToggle = createElement('button');
+ const menu = createElement('div');
+ const menuPanel = createElement('div');
+
+ menuPanel.classList.add('mobile-menu__panel');
+ menu.querySelector = selector => {
+ if (selector === '.mobile-menu__panel') return menuPanel;
+ return null;
+ };
+ menu.querySelectorAll = () => [];
+
+ registry.set('mobileMenuToggle', menuToggle);
+ registry.set('mobileMenu', menu);
+
+ try {
+ const controller = initializeMobileMenu({
+ documentObject: documentStub,
+ windowObject: windowStub
+ });
+ assert.equal(typeof controller.openMenu, 'function');
+ } finally {
+ cleanup();
+ }
+});
diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js
index e3f7944..7779de0 100644
--- a/web/public/assets/js/app/__tests__/node-page.test.js
+++ b/web/public/assets/js/app/__tests__/node-page.test.js
@@ -946,13 +946,19 @@ test('initializeNodeDetailPage reports an error when refresh fails', async () =>
throw new Error('boom');
};
const renderShortHtml = short => `${short}`;
- const result = await initializeNodeDetailPage({
- document: documentStub,
- refreshImpl,
- renderShortHtml,
- });
- assert.equal(result, false);
- assert.equal(element.innerHTML.includes('Failed to load'), true);
+ const originalError = console.error;
+ console.error = () => {};
+ try {
+ const result = await initializeNodeDetailPage({
+ document: documentStub,
+ refreshImpl,
+ renderShortHtml,
+ });
+ assert.equal(result, false);
+ assert.equal(element.innerHTML.includes('Failed to load'), true);
+ } finally {
+ console.error = originalError;
+ }
});
test('initializeNodeDetailPage handles missing reference payloads', async () => {
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index 0d4c4c1..d51f301 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -44,6 +44,7 @@ import {
formatChatPresetTag
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
+import { initializeMobileMenu } from './mobile-menu.js';
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
@@ -119,6 +120,8 @@ export function initializeApp(config) {
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
+
+ initializeMobileMenu({ documentObject: document, windowObject: window });
/**
* Column sorter configuration for the node table.
*
diff --git a/web/public/assets/js/app/mobile-menu.js b/web/public/assets/js/app/mobile-menu.js
new file mode 100644
index 0000000..c1a7ae8
--- /dev/null
+++ b/web/public/assets/js/app/mobile-menu.js
@@ -0,0 +1,271 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const MOBILE_MENU_MEDIA_QUERY = '(max-width: 900px)';
+const FOCUSABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])'
+].join(', ');
+
+/**
+ * Collect the elements that can receive focus within a container.
+ *
+ * @param {?Element} container DOM node hosting focusable descendants.
+ * @returns {Array} Ordered list of focusable elements.
+ */
+function resolveFocusableElements(container) {
+ if (!container || typeof container.querySelectorAll !== 'function') {
+ return [];
+ }
+ const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+ return candidates.filter(candidate => {
+ if (!candidate || typeof candidate.getAttribute !== 'function') {
+ return false;
+ }
+ return candidate.getAttribute('aria-hidden') !== 'true';
+ });
+}
+
+/**
+ * Build a menu controller for handling toggle state, focus trapping, and
+ * responsive layout swapping.
+ *
+ * @param {{
+ * documentObject?: Document,
+ * windowObject?: Window
+ * }} [options]
+ * @returns {{
+ * initialize: () => void,
+ * openMenu: () => void,
+ * closeMenu: () => void,
+ * syncLayout: () => void
+ * }}
+ */
+function createMobileMenuController(options = {}) {
+ const documentObject = options.documentObject || document;
+ const windowObject = options.windowObject || window;
+ const menuToggle = documentObject.getElementById('mobileMenuToggle');
+ const menu = documentObject.getElementById('mobileMenu');
+ const menuPanel = menu ? menu.querySelector('.mobile-menu__panel') : null;
+ const closeTriggers = menu ? Array.from(menu.querySelectorAll('[data-mobile-menu-close]')) : [];
+ const menuLinks = menu ? Array.from(menu.querySelectorAll('a')) : [];
+ const body = documentObject.body;
+ const mediaQuery = windowObject.matchMedia
+ ? windowObject.matchMedia(MOBILE_MENU_MEDIA_QUERY)
+ : null;
+ let isOpen = false;
+ let lastActive = null;
+
+ /**
+ * Toggle the ``aria-expanded`` state on the menu trigger.
+ *
+ * @param {boolean} expanded Whether the menu is open.
+ * @returns {void}
+ */
+ function setExpandedState(expanded) {
+ if (!menuToggle || typeof menuToggle.setAttribute !== 'function') {
+ return;
+ }
+ menuToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ }
+
+ /**
+ * Synchronize the meta row placement based on the active media query.
+ *
+ * @returns {void}
+ */
+ function syncLayout() {
+ return;
+ }
+
+ /**
+ * Open the slide-in menu and trap focus within the panel.
+ *
+ * @returns {void}
+ */
+ function openMenu() {
+ if (!menu || !menuToggle || !menuPanel) {
+ return;
+ }
+ syncLayout();
+ menu.hidden = false;
+ menu.classList.add('is-open');
+ if (body && body.classList) {
+ body.classList.add('menu-open');
+ }
+ setExpandedState(true);
+ isOpen = true;
+ lastActive = documentObject.activeElement || null;
+ const focusables = resolveFocusableElements(menuPanel);
+ const focusTarget = focusables[0] || menuPanel;
+ if (focusTarget && typeof focusTarget.focus === 'function') {
+ focusTarget.focus();
+ }
+ }
+
+ /**
+ * Close the menu and restore focus to the trigger.
+ *
+ * @returns {void}
+ */
+ function closeMenu() {
+ if (!menu || !menuToggle) {
+ return;
+ }
+ menu.classList.remove('is-open');
+ menu.hidden = true;
+ if (body && body.classList) {
+ body.classList.remove('menu-open');
+ }
+ setExpandedState(false);
+ isOpen = false;
+ if (lastActive && typeof lastActive.focus === 'function') {
+ lastActive.focus();
+ }
+ }
+
+ /**
+ * Toggle open or closed based on the trigger interaction.
+ *
+ * @param {Event} event Click event originating from the trigger.
+ * @returns {void}
+ */
+ function handleToggleClick(event) {
+ if (event && typeof event.preventDefault === 'function') {
+ event.preventDefault();
+ }
+ if (isOpen) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ }
+
+ /**
+ * Trap tab focus within the menu panel while open.
+ *
+ * @param {KeyboardEvent} event Keydown event from the panel.
+ * @returns {void}
+ */
+ function handleKeydown(event) {
+ if (!isOpen || !event) {
+ return;
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ closeMenu();
+ return;
+ }
+ if (event.key !== 'Tab') {
+ return;
+ }
+ const focusables = resolveFocusableElements(menuPanel);
+ if (!focusables.length) {
+ return;
+ }
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ const active = documentObject.activeElement;
+ if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ /**
+ * Close the menu when navigation state changes.
+ *
+ * @returns {void}
+ */
+ function handleRouteChange() {
+ if (isOpen) {
+ closeMenu();
+ }
+ }
+
+ /**
+ * Attach event listeners and sync initial layout.
+ *
+ * @returns {void}
+ */
+ function initialize() {
+ if (!menuToggle || !menu) {
+ return;
+ }
+ menuToggle.addEventListener('click', handleToggleClick);
+ closeTriggers.forEach(trigger => {
+ trigger.addEventListener('click', closeMenu);
+ });
+ menuLinks.forEach(link => {
+ link.addEventListener('click', closeMenu);
+ });
+ if (menuPanel && typeof menuPanel.addEventListener === 'function') {
+ menuPanel.addEventListener('keydown', handleKeydown);
+ }
+ if (mediaQuery) {
+ if (typeof mediaQuery.addEventListener === 'function') {
+ mediaQuery.addEventListener('change', syncLayout);
+ } else if (typeof mediaQuery.addListener === 'function') {
+ mediaQuery.addListener(syncLayout);
+ }
+ }
+ if (windowObject && typeof windowObject.addEventListener === 'function') {
+ windowObject.addEventListener('hashchange', handleRouteChange);
+ windowObject.addEventListener('popstate', handleRouteChange);
+ }
+ syncLayout();
+ setExpandedState(false);
+ }
+
+ return {
+ initialize,
+ openMenu,
+ closeMenu,
+ syncLayout,
+ };
+}
+
+/**
+ * Initialize the mobile menu using the live DOM environment.
+ *
+ * @param {{
+ * documentObject?: Document,
+ * windowObject?: Window
+ * }} [options]
+ * @returns {{
+ * initialize: () => void,
+ * openMenu: () => void,
+ * closeMenu: () => void,
+ * syncLayout: () => void
+ * }}
+ */
+export function initializeMobileMenu(options = {}) {
+ const controller = createMobileMenuController(options);
+ controller.initialize();
+ return controller;
+}
+
+export const __test__ = {
+ createMobileMenuController,
+ resolveFocusableElements,
+};
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index e0d9202..d44e2e4 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -215,25 +215,214 @@ h1 {
.site-header {
display: flex;
- flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 56px;
+ padding: 4px 0;
+ margin-bottom: 8px;
+}
+
+.site-header__left,
+.site-header__right {
+ display: flex;
align-items: center;
gap: 12px;
- margin-bottom: 8px;
+}
+
+.site-header__left {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.site-header__right {
+ flex: 0 0 auto;
+ margin-left: auto;
}
.site-title {
display: inline-flex;
align-items: center;
gap: 12px;
+ min-width: 0;
+}
+
+.site-title-text {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.site-title img {
- width: 52px;
- height: 52px;
+ width: 36px;
+ height: 36px;
display: block;
border-radius: 12px;
}
+.site-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.site-nav__link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ color: var(--fg);
+ text-decoration: none;
+ border: 1px solid transparent;
+ font-size: 14px;
+}
+
+.site-nav__link:hover {
+ background: var(--card);
+}
+
+.site-nav__link.is-active {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: transparent;
+ font-weight: 600;
+}
+
+.site-nav__link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.menu-toggle {
+ display: none;
+}
+
+.menu-toggle:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.mobile-menu {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: flex;
+ justify-content: flex-end;
+ pointer-events: none;
+}
+
+.mobile-menu[hidden] {
+ display: none;
+}
+
+.mobile-menu__backdrop {
+ flex: 1 1 auto;
+ background: rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ transition: opacity 200ms ease;
+}
+
+.mobile-menu__panel {
+ width: min(320px, 86vw);
+ background: var(--bg2);
+ color: var(--fg);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ height: 100%;
+ overflow-y: auto;
+ transform: translateX(100%);
+ transition: transform 220ms ease;
+ box-shadow: -12px 0 32px rgba(0, 0, 0, 0.3);
+}
+
+.mobile-menu__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.mobile-menu__title {
+ margin: 0;
+ font-size: 16px;
+}
+
+.mobile-menu__close:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.mobile-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.mobile-nav__link {
+ display: inline-flex;
+ align-items: center;
+ padding: 8px 10px;
+ border-radius: 10px;
+ color: var(--fg);
+ text-decoration: none;
+ border: 1px solid transparent;
+}
+
+.mobile-nav__link.is-active {
+ border-color: var(--accent);
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.mobile-nav__link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.mobile-menu.is-open {
+ pointer-events: auto;
+}
+
+.mobile-menu.is-open .mobile-menu__backdrop {
+ opacity: 1;
+}
+
+.mobile-menu.is-open .mobile-menu__panel {
+ transform: translateX(0);
+}
+
+.menu-open {
+ overflow: hidden;
+}
+
+.section-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ color: var(--fg);
+ text-decoration: none;
+ font-size: 14px;
+}
+
+.section-link:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.section-link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
.meta {
color: #555;
margin-bottom: 12px;
@@ -282,11 +471,29 @@ h1 {
@media (max-width: 900px) {
.site-header {
- flex-direction: column;
- align-items: flex-start;
+ margin-bottom: 4px;
+ }
+
+ .site-header__left {
+ flex-wrap: nowrap;
+ }
+
+ .site-header__left--federation {
+ flex-wrap: wrap;
+ }
+
+ .site-nav {
+ display: none;
+ }
+
+ .menu-toggle {
+ display: inline-flex;
+ }
+
+ .instance-selector {
+ flex: 0 1 auto;
}
- .instance-selector,
.instance-select {
width: 100%;
}
@@ -296,6 +503,7 @@ h1 {
}
}
+
.pill {
display: inline-block;
padding: 2px 8px;
@@ -1694,10 +1902,6 @@ input[type="radio"] {
gap: 12px;
}
- .controls--full-screen {
- grid-template-columns: minmax(0, 1fr) auto;
- }
-
.controls .filter-input {
width: 100%;
}
diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb
index b16fd24..1174858 100644
--- a/web/views/layouts/app.erb
+++ b/web/views/layouts/app.erb
@@ -75,16 +75,19 @@
main_classes = ["page-main"]
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
- show_header = !full_screen_view
+ show_header = true
show_meta_info = true
show_auto_refresh_controls = view_mode != :federation
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
- show_info_button = !full_screen_view
+ show_info_button = true
show_footer = !full_screen_view
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
+ nodes_nav_href = "/nodes"
+ nodes_nav_active = %i[nodes node_detail].include?(view_mode)
+ federation_nav_enabled = !private_mode && federation_enabled
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
@@ -101,24 +104,69 @@
">
<% if show_header %>