Replace the mobile tab bar with a hamburger menu

Six tabs (Queue, On-call, Alerts, Team, Admin, Account) had already
outgrown the bottom bar once: 56b8191 let it auto-size its columns to
fit however many there were, but on a phone that only left each tab
55-65px wide. Squeezing further wasn't an option, so the bar is gone
on mobile and a hamburger button in the topbar opens a menu instead.

The menu reuses the sheet + menu-item pattern already used for the
snooze and assign-to actions in incident.js, rather than a new overlay
component. It lists the same sections the sidebar does, including
hiding Admin for non-admins, and the triggered-incident badge that
used to sit on the Queue tab icon now shows on the hamburger button.

Desktop (>=900px) is untouched: the sidebar is the same markup, CSS
alone hides it below 900px and shows it above, so nothing there
changed behaviourally.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-22 21:47:15 +02:00
parent 429d5fdda3
commit e536fdd2c0
5 changed files with 86 additions and 29 deletions
+4 -4
View File
@@ -48,10 +48,10 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
The server serves a web UI at `/`: the incident queue, each incident's alerts The server serves a web UI at `/`: the incident queue, each incident's alerts
and timeline with every action (acknowledge, assign, snooze, note, resolve, and timeline with every action (acknowledge, assign, snooze, note, resolve,
archive), who is on call, the alert feed, and an *Account* tab for your own archive), who is on call, the alert feed, and an *Account* tab for your own
password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action password and the ntfy topic your pages go to. It is built for a phone first. On a phone
bar, it follows the system's dark mode, and it can be added to the home screen. it navigates through a hamburger menu and has a sticky action bar, it follows the
From 900px wide it switches to a sidebar with the queue and the incident side by system's dark mode, and it can be added to the home screen. From 900px wide it switches
side. Statistics remain in to a sidebar with the queue and the incident side by side. Statistics remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now. [terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
You sign in with a username and password. Users have no password until one is You sign in with a username and password. Users have no password until one is
+16 -21
View File
@@ -48,7 +48,11 @@
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--topbar-h: 52px; --topbar-h: 52px;
--tabbar-h: 58px; /* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
variable rather than deleted since .view, .toast and .nav's own height
calc still read it. */
--tabbar-h: 0px;
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
} }
@@ -196,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
-webkit-backdrop-filter: saturate(1.4) blur(12px); -webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; } .topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.topbar-title {
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
#menu-btn { position: relative; }
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
.open-pill { .open-pill {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -209,15 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.open-pill.has-triggered::before { background: var(--crit); } .open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); } .open-pill.all-acked::before { background: var(--warn); }
/* Bottom tab bar on phones. */ /* Hidden on phones — mobile navigates through the hamburger menu in the
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
left sidebar from 900px, where the desktop block below redeclares display. */
.nav { .nav {
display: none;
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
/* One column per link, however many there are. This was repeat(4, 1fr) when
there were four tabs; Team and Admin arriving pushed six items into four
columns, which on a phone is how they stopped fitting. Auto columns mean
the next tab cannot break the row either — and Admin is only rendered for
an administrator, so the count genuinely varies between viewers. */
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--tabbar-h) + var(--safe-bottom)); height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom); padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent); background: color-mix(in srgb, var(--surface) 92%, transparent);
@@ -239,16 +246,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
} }
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
the widest label, "On-call", is about 38px at this size, so nothing has to
be hidden. The ellipsis above is the backstop if a future tab is named
something longer. */
@media (max-width: 420px) {
.nav-link { font-size: 10px; gap: 1px; }
.nav-link svg { width: 21px; height: 21px; }
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
}
.nav-link[aria-current="page"] { color: var(--accent); } .nav-link[aria-current="page"] { color: var(--accent); }
.nav-badge { .nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px); position: absolute; top: 6px; left: calc(50% + 6px);
@@ -601,8 +598,6 @@ kbd {
/* ---------- desktop ---------- */ /* ---------- desktop ---------- */
@media (min-width: 900px) { @media (min-width: 900px) {
:root { --tabbar-h: 0px; }
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; } .app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
.nav { .nav {
+7 -1
View File
@@ -108,7 +108,13 @@
</nav> </nav>
<header class="topbar"> <header class="topbar">
<h1 class="topbar-title" id="topbar-title">Queue</h1> <div class="topbar-left">
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
<span class="open-pill" id="open-pill" hidden></span> <span class="open-pill" id="open-pill" hidden></span>
</header> </header>
+47 -3
View File
@@ -30,6 +30,17 @@ const SECTIONS = {
more: { title: 'Account', view: account }, more: { title: 'Account', view: account },
}; };
// The mobile hamburger menu's contents — the same sections the desktop
// sidebar's .nav-link list carries in index.html, in the same order.
const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
];
function parseRoute(pathname) { function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/); const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) }; if (m) return { section: 'queue', incident: Number(m[1]) };
@@ -132,6 +143,35 @@ function render() {
updateTitle(); updateTitle();
} }
// ---------- nav menu ----------
// The mobile hamburger menu: same shape as the sheet-based action menus in
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
function openNavMenu() {
const current = SECTIONS[route.section].nav || route.section;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
ui.openSheet(() => [
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
ui.h('li', {}, ui.h('button', {
class: 'menu-item',
type: 'button',
role: 'menuitemradio',
'aria-checked': String(n.section === current),
onclick: () => ui.closeSheet(n.path),
},
ui.icon(n.icon),
n.label,
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
))),
),
]).then((path) => {
if (path) navigate(path);
});
}
// ---------- refresh + badges ---------- // ---------- refresh + badges ----------
async function refresh() { async function refresh() {
@@ -158,9 +198,12 @@ function updateBadges() {
pill.classList.toggle('has-triggered', triggered > 0); pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0); pill.classList.toggle('all-acked', open > 0 && triggered === 0);
const badge = document.querySelector('[data-badge]'); // Two badges carry this count: the sidebar's Queue tab (desktop) and the
badge.hidden = triggered === 0; // hamburger button (mobile) — only one of the two is ever visible at once.
badge.textContent = String(triggered); for (const badge of document.querySelectorAll('[data-badge]')) {
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
}
updateTitle(); updateTitle();
} }
@@ -180,6 +223,7 @@ async function boot() {
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin); $('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup); $('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
// /signup is the one route that works without a session. // /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') { if (location.pathname.replace(/\/$/, '') === '/signup') {
+12
View File
@@ -34,6 +34,10 @@ export function clear(el, ...children) {
const ICONS = { const ICONS = {
back: ['M15 18l-6-6 6-6'], back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'], more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'],
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
check: ['M5 12.5l4.5 4.5L19 7'], check: ['M5 12.5l4.5 4.5L19 7'],
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'], checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'], undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
@@ -64,6 +68,14 @@ export function icon(name, cls = 'icon') {
node.setAttribute('cx', cx); node.setAttribute('cx', cx);
node.setAttribute('cy', cy); node.setAttribute('cy', cy);
node.setAttribute('r', r); node.setAttribute('r', r);
} else if (d.startsWith('rect:')) {
const [x, y, w, hgt, rx] = d.slice(5).split(',');
node = document.createElementNS(SVG, 'rect');
node.setAttribute('x', x);
node.setAttribute('y', y);
node.setAttribute('width', w);
node.setAttribute('height', hgt);
if (rx) node.setAttribute('rx', rx);
} else { } else {
node = document.createElementNS(SVG, 'path'); node = document.createElementNS(SVG, 'path');
node.setAttribute('d', d); node.setAttribute('d', d);