a6fa673e08
The Admin tab's team list was growing controls the way the user list did beforeac9af8e: a Rename button behind window.prompt, a Delete beside it, and -- on the Users page, of all places -- an invite form with a team picker in front of it. The picker was the admission that an invite is a fact about a team rather than about the server, and a prompt() is the wrong place to read a 409 about a name already taken. So a team is now a subject with a page, at /admin/teams/{id}, the mirror of /admin/users/{id}: when it was created, how many are in it and how much is open, a field to rename it, the members with their roles, the invites into it, and deletion. The list goes back to being a list, and the name in it is the way in. The member list is the one thing there that needed a new endpoint. GET /api/teams/{id}/members is requireTeamMember and answers 404 to an administrator who is not in the team, and that stays exactly as it is: member means membership and nothing else. Reading a team's shape is a different question from reading its work, so it gets an endpoint of its own under AdminOnly -- GET /api/admin/teams/{id}, returning {"team", "members"} -- rather than an exception carved into that rule. It is a wrapper and not a team with the members hung off it, because "members" already means a count on the list endpoint and one name must not be a number in one answer and an array in the next. The query and its ordering are copied from handleListTeamMembers so the two answers to "who is in this team" cannot disagree. An administrator still sees none of that team's incidents, alerts or rota. Nothing about what the flag may do changed; it could already rename and delete any team, and staff one it is not in. Rename now trims what it is given, as creation has always trimmed. Before this, " " was a legal name to rename a team to but not to create one with, which is one rule stated twice and applied once. Nobody has looked at this in a browser, the caveatac9af8eand07914d5both carried. What is checked is the wiring: admin_test.go covers the new endpoint for an administrator outside the team, the 404 the member-only endpoint still gives that same administrator, the 403 for a member who is not one, a 404 for a team that does not exist, a 400 for an id that is not a number, and the trim; the module graph evaluates at /admin/teams/{id}, and the server serves index.html there, so a reload survives. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
803 lines
32 KiB
CSS
803 lines
32 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; }
|
|
|
|
/* A name in an admin table is the way to that row's own page -- a person's or
|
|
a team's. */
|
|
.row-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
|
.row-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, one team --------------------------------------------------
|
|
Both subject pages share this: .user-head and .user-facts are generic
|
|
despite the names, and a team fills them with its own facts. */
|
|
.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); }
|
|
|
|
/* --- admin sub-navigation ------------------------------------------------
|
|
A strip of links across the top of every admin page, one per sub-section.
|
|
Deliberately not .chip: chips filter what a page already shows, here and in
|
|
the queue, and these four go somewhere. Same aria-current convention as the
|
|
tab bar, so the state lives on the attribute rather than in a class. */
|
|
.subnav {
|
|
display: flex; gap: 2px;
|
|
margin: 12px auto 0;
|
|
border-bottom: 1px solid var(--border);
|
|
overflow-x: auto; scrollbar-width: none;
|
|
}
|
|
.subnav::-webkit-scrollbar { display: none; }
|
|
.subnav-link {
|
|
flex: none;
|
|
padding: 8px 12px; margin-bottom: -1px;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
|
|
}
|
|
.subnav-link:hover { color: var(--text); }
|
|
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
|
|
|
/* The overview at /admin. The strip above already links to the three, so these
|
|
carry the counts, which is the part a menu cannot say. */
|
|
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
|
|
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
|
.admin-menu .card + .card { margin-top: 0; }
|
|
.admin-menu-item { display: block; padding: 14px; }
|
|
.admin-menu-item:hover { background: var(--surface-hover); }
|
|
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
|
|
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
|
.admin-menu-item p { margin: 4px 0 0; }
|