Files
terdut-server/internal/web/static/app.css
T
Niklas Ye ac9af8e4f5 Manage a person's account and teams from one page
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.

A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.

Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.

One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.

No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.

So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.

TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.

The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:42 +02:00

768 lines
31 KiB
CSS

/* terdut web UI.
*
* Mobile first: one column, a top bar and a bottom tab bar. From 900px the tab
* bar becomes a sidebar and the queue shows list and detail side by side.
* Colour is reserved for severity and status; everything else is neutral.
*/
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
--border: #e2e5ea;
--border-strong: #cfd3da;
--text: #16181d;
--muted: #5b626e;
--faint: #8a909b;
--accent: #2f5bd3;
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze-soft: #efedfb;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
--shadow-lg: 0 12px 32px rgb(16 24 40 / 18%);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--topbar-h: 52px;
--tabbar-h: 58px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a20;
--surface-2: #1f232b;
--surface-hover: #1c2027;
--border: #2a2f38;
--border-strong: #394050;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #737a87;
--accent: #6d8ff0;
--accent-text: #0b0d12;
--accent-soft: #1d2640;
--crit: #ff6b61;
--crit-soft: #3a1c1b;
--warn: #f0b140;
--warn-soft: #362a14;
--info: #74a3ff;
--info-soft: #1a2640;
--ok: #4cc488;
--ok-soft: #15301f;
--snooze: #a89bff;
--snooze-soft: #262245;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.45 var(--font);
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
code { font-family: var(--mono); font-size: 0.9em; }
h1, h2, h3 { margin: 0; line-height: 1.25; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------- boot + login ---------- */
.boot { display: grid; place-items: center; min-height: 100dvh; }
.spinner {
width: 26px; height: 26px; border-radius: 50%;
border: 3px solid var(--border); border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.login {
min-height: 100dvh;
display: grid; place-items: center;
padding: calc(24px + var(--safe-top)) 16px calc(24px + var(--safe-bottom));
}
.login-card {
width: 100%; max-width: 360px;
display: grid; gap: 14px;
}
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
label { display: grid; gap: 6px; }
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
input, textarea, select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 16px; /* 16px stops iOS zooming into the field */
}
textarea { min-height: 110px; resize: vertical; line-height: 1.45; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.form-error {
margin: 0; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; padding: 0 16px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600; font-size: 15px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover { background: var(--surface-hover); }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
.btn-danger:hover { background: var(--crit); filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); }
.btn-block { width: 100%; }
.btn-icon { width: 44px; padding: 0; }
.btn-sm { min-height: 32px; padding: 0 8px; font-size: 13px; }
.btn-sm svg { width: 16px; height: 16px; }
.btn svg, .icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ---------- app frame ---------- */
.app { min-height: 100dvh; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
height: calc(var(--topbar-h) + var(--safe-top));
padding: var(--safe-top) 16px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
.open-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px;
font-size: 13px; font-weight: 600;
background: var(--surface-2); color: var(--muted);
}
.open-pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* Bottom tab bar on phones. */
.nav {
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));
padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-link {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
}
.nav-label {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 999px; background: var(--crit); color: #fff;
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
}
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
/* Phone detail: the incident takes the whole screen with its own action bar,
so the tab bar and top bar step aside. */
.app.detail-open .nav,
.app.detail-open .topbar { display: none; }
.app.detail-open .pane-list { display: none; }
.app.detail-open .view-queue { padding-bottom: 0; }
.view-queue:not(.has-detail) .pane-detail { display: none; }
/* ---------- chips ---------- */
.chips {
display: flex; gap: 6px;
padding: 12px 16px 8px;
overflow-x: auto; scrollbar-width: none;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex: none;
min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; cursor: pointer;
}
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* ---------- lists ---------- */
.list { padding: 0 16px 16px; display: grid; gap: 8px; }
.row {
position: relative;
display: grid; grid-template-columns: 1fr auto; gap: 4px 12px;
padding: 11px 14px 11px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
cursor: pointer;
overflow: hidden;
}
.row:hover { background: var(--surface-hover); }
.row[aria-current="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.row.kbd-focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.row::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--sev, var(--border-strong));
}
.row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta {
grid-column: 1 / -1;
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
color: var(--muted); font-size: 13px;
min-width: 0;
}
.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); }
/* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team {
padding: 1px 6px; border-radius: 4px;
background: var(--surface-2); border: 1px solid var(--border);
color: var(--muted); font-size: 12px; white-space: nowrap;
}
.row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); }
.sev-warning { --sev: var(--warn); }
.sev-info { --sev: var(--info); }
.empty {
padding: 48px 16px; text-align: center; color: var(--muted);
}
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
.load-error {
margin: 12px 16px; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- badges ---------- */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 1px 8px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
background: var(--surface-2); color: var(--muted);
white-space: nowrap;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; }
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); }
/* ---------- incident detail ---------- */
.detail { padding: 0 16px calc(96px + var(--safe-bottom)); }
.detail-head {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 4px;
height: calc(var(--topbar-h) + var(--safe-top));
margin: 0 -16px; padding: var(--safe-top) 8px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card + .card, .section + .section { margin-top: 14px; }
.card-pad { padding: 14px; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; padding: 14px; font-size: 14px; }
.facts dt { color: var(--muted); }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.facts .sub { color: var(--faint); }
.section { margin-top: 22px; }
.section-title {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); margin: 0 2px 8px;
}
.labels-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.label {
display: inline-flex; max-width: 100%;
font-family: var(--mono); font-size: 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
overflow: hidden;
}
.label > span { padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label > span:first-child { background: var(--surface-2); color: var(--muted); }
.alert-item { padding: 12px 14px; display: grid; gap: 6px; }
.alert-item + .alert-item { border-top: 1px solid var(--border); }
.alert-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.alert-item-name { font-weight: 650; overflow-wrap: anywhere; }
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details[open] > summary { margin-bottom: 8px; }
.timeline { list-style: none; margin: 0; padding: 4px 0; }
.tl-item {
position: relative;
display: grid; grid-template-columns: 20px 1fr; gap: 10px;
padding: 8px 14px;
}
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
}
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
.tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
.tl-time { color: var(--faint); font-size: 12px; }
.tl-note .note {
margin-top: 6px; padding: 10px 12px;
background: var(--surface-2); border-radius: var(--radius-sm);
white-space: pre-wrap; overflow-wrap: anywhere;
}
.note-actions { display: flex; justify-content: flex-end; }
.note-actions .btn { color: var(--muted); }
/* The action bar sits at the bottom of the screen on a phone, and at the
bottom of the detail pane on desktop. */
.actionbar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 15;
display: flex; gap: 8px;
padding: 10px 16px calc(10px + var(--safe-bottom));
background: var(--surface);
border-top: 1px solid var(--border);
}
.actionbar .btn { min-height: 48px; }
.actionbar .btn-primary { flex: 1; font-size: 16px; }
.detail-placeholder {
display: grid; place-items: center; height: 100%;
color: var(--faint); text-align: center; padding: 32px;
}
/* ---------- sheet (dialog) ---------- */
.sheet {
width: 100%; max-width: 100%;
max-height: 88dvh;
margin: auto 0 0; padding: 0;
border: 0; border-radius: 16px 16px 0 0;
background: var(--surface); color: var(--text);
box-shadow: var(--shadow-lg);
overflow: auto;
}
.sheet::backdrop { background: rgb(0 0 0 / 40%); }
.sheet[open] { animation: sheet-up 0.18s ease-out; }
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
.sheet-form { display: grid; gap: 12px; }
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
.sheet-actions .btn { flex: 1; }
.menu { list-style: none; margin: 0 -4px; padding: 0; }
.menu-item {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 50px; padding: 0 12px;
background: none; border: 0; border-radius: var(--radius-sm);
text-align: left; font-size: 16px; cursor: pointer;
}
.menu-item:hover { background: var(--surface-2); }
.menu-item .icon { color: var(--muted); flex: none; }
.menu-item .menu-sub { margin-left: auto; color: var(--faint); font-size: 13px; }
.menu-item.danger, .menu-item.danger .icon { color: var(--crit); }
.menu-item[aria-checked="true"] { font-weight: 700; }
.menu-item[aria-checked="true"]::after { content: "✓"; margin-left: 8px; color: var(--accent); }
.menu-sep { height: 1px; background: var(--border); margin: 6px 12px; }
/* ---------- toast ---------- */
.toast {
position: fixed; left: 50%; z-index: 50;
bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px);
transform: translateX(-50%);
max-width: calc(100% - 32px);
padding: 10px 16px; border-radius: var(--radius);
background: var(--text); color: var(--bg);
font-size: 14px; font-weight: 600;
box-shadow: var(--shadow-lg);
}
.toast.error { background: var(--crit); color: #fff; }
.app.detail-open ~ .toast { bottom: calc(80px + var(--safe-bottom)); }
/* ---------- on-call ---------- */
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.avatar {
flex: none; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
.now-name { font-size: 20px; font-weight: 750; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.days { list-style: none; margin: 0; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); }
.day-name { font-weight: 650; }
.day-date { color: var(--faint); font-size: 13px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; }
.day.today { background: var(--accent-soft); }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.day.today .day-name { color: var(--accent); }
.day.past { opacity: 0.6; }
.shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
.shift-list li + li { border-top: 1px solid var(--border); }
.shift-list .muted { color: var(--faint); }
/* ---------- alerts page ---------- */
.view-alerts .chips { padding-left: 0; padding-right: 0; }
.view-alerts .list { padding-left: 0; padding-right: 0; }
.row.st-firing { --sev: var(--crit); }
.row.st-resolved { --sev: var(--ok); }
.row.no-link { cursor: default; }
/* ---------- account ---------- */
.account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; }
.form-ok {
margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok);
border-radius: var(--radius-sm); font-size: 14px;
}
.kbd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.kbd-table td { padding: 8px 14px; border-top: 1px solid var(--border); }
.kbd-table tr:first-child td { border-top: 0; }
kbd {
display: inline-block; min-width: 1.6em; padding: 1px 6px;
font-family: var(--mono); font-size: 12px; text-align: center;
background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom-width: 2px;
border-radius: 4px;
}
.only-desktop { display: none; }
.foot-note { color: var(--faint); font-size: 13px; text-align: center; margin: 24px auto; }
/* ---------- desktop ---------- */
@media (min-width: 900px) {
:root { --tabbar-h: 0px; }
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
.nav {
position: static; grid-row: 1 / span 2;
display: flex; flex-direction: column; gap: 2px;
height: auto; padding: 16px 12px;
background: var(--surface);
border-top: 0; border-right: 1px solid var(--border);
backdrop-filter: none;
}
.nav-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
color: var(--muted); font-size: 14px;
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
.topbar { display: none; }
.view { padding-bottom: 0; overflow: auto; height: 100dvh; }
.view-page { padding: 8px 32px 32px; }
.view-queue {
display: grid; grid-template-columns: minmax(340px, 420px) 1fr;
overflow: hidden;
}
.view-queue .pane { overflow: auto; height: 100dvh; }
.pane-list { border-right: 1px solid var(--border); }
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
.app.detail-open .nav { display: flex; }
.app.detail-open .pane-list { display: block; }
.detail-head .back { display: none; }
.detail-head { padding-left: 16px; }
.pane-detail { position: relative; display: flex; flex-direction: column; }
.detail { flex: 1; padding: 0 32px 24px; max-width: 900px; width: 100%; }
.detail-head { margin: 0 -32px; padding-left: 32px; }
.actionbar {
position: sticky; bottom: 0;
padding: 12px 32px;
}
.actionbar .btn-primary { flex: 0 1 240px; }
.sheet {
width: min(440px, calc(100% - 32px));
margin: auto; border-radius: 14px;
}
.sheet-grab { display: none; }
.sheet-inner { padding: 20px; }
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.only-desktop { display: block; }
}
/* --- admin ---------------------------------------------------------------
The admin page is three tables of things you act on, so it needs table
styling the rest of the app never did: the queue is a list of links and the
account page is a form. */
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
}
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
/* A disabled account stays readable — it is still the name on old
acknowledgements — but should not look like a working one. */
.disabled-row td { opacity: 0.55; }
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
.inline-form input { flex: 1; min-width: 0; }
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
.admin-settings .setting-unit { max-width: 8em; }
.admin-settings button[type="submit"] { margin-top: 12px; }
.small { font-size: 13px; }
/* The name in the user list is the way to that person's page. */
.user-link { color: var(--text); font-weight: 650; text-decoration: none; }
.user-link:hover { color: var(--accent); text-decoration: underline; }
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
/* The link is shown once and never stored, so it has to be selectable and
wrap rather than scroll off the side of a phone. */
.invite-out { margin-top: 12px; font-size: 13px; }
.invite-link {
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
word-break: break-all; user-select: all;
}
/* --- one user ------------------------------------------------------------ */
.back-link {
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
color: var(--muted); font-size: 14px; text-decoration: none;
}
.back-link:hover { color: var(--text); }
.back-link svg { width: 18px; height: 18px; }
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.user-head h2 { margin: 0; }
.user-facts {
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
margin: 12px 0 0; font-size: 14px;
}
.user-facts dt { color: var(--muted); }
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.admin-table .row-actions { margin-top: 0; gap: 6px; }
/* --- team settings -------------------------------------------------------
Forms with a label above each control, rather than the queue's rows of
links. The escalation ladder is the only nested structure in the app, so it
gets a little indentation to make the levels read as an order. */
.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; }
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
.ladder-level {
border-left: 3px solid var(--border-strong);
padding: 8px 0 8px 12px; margin: 12px 0;
}
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
/* An integration key is shown exactly once, so it should look like something
to act on rather than another row of text. */
.key-panel {
margin-top: 12px; padding: 12px;
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
}
.key-panel pre {
overflow-x: auto; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: 8px; font-size: 12px;
}
.key-url code { word-break: break-all; }
/* --- onboarding checklist ------------------------------------------------
Sits above the queue until it is finished or hidden. Deliberately plain:
it is a list of things to do, not a celebration. */
.onboarding { border-left: 3px solid var(--accent); }
.onboarding-head { display: flex; align-items: center; gap: 10px; }
.onboarding-head h2 { flex: 1; margin: 0; }
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
.checklist .step p { margin: 2px 0 0; }
.step-mark {
flex: none; width: 20px; height: 20px; border-radius: 50%;
border: 1px solid var(--border-strong); color: var(--accent);
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.step.done .step-mark { border-color: var(--accent); }
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }