3663 lines
82 KiB
CSS
3663 lines
82 KiB
CSS
/* One vocabulary for the whole console.
|
||
Every screen is built from the components below — card, tile, field, tag, table, list.
|
||
A page that needs a look of its own is a missing component, not a licence for a style
|
||
attribute: there are no inline styles in the page components, on purpose, and no CSS
|
||
modules either. One stylesheet, read top to bottom, is what a strict no-dependency
|
||
console has instead of a design system nobody can see the whole of.
|
||
|
||
The palette takes its cues from GitHub's dark interface: near-black chrome, a cool
|
||
charcoal work area, restrained borders and one deliberate green for live or primary
|
||
actions. An operator can scan dense operational data without the interface competing
|
||
for attention.
|
||
|
||
The console is darker than the television app rather than lighter. What makes a card
|
||
hard to find is not the darkness of the page behind it but the absence of anything
|
||
*between* them — so the page drops to near-black and the separation is done properly,
|
||
by a card that sits a measurable step above it and a hairline visible on a bad monitor.
|
||
Four steps, and they are the whole scale: page, card, raised (a control), high (a
|
||
control under the pointer). A fifth value is a decision that has not been made yet.
|
||
|
||
The accent is spent only on what is live, selected or primary. Beside it are three
|
||
secondary tones with no verdict attached — info (blue), note (violet), data (teal) —
|
||
which is what lets a page distinguish one kind of thing from another without every
|
||
coloured element reading as a warning. Green means good, amber means look, red means
|
||
wrong; the other three mean nothing at all, which is the point. A tone is passed, never
|
||
derived: the library is teal, a person violet, a television blue, on every page.
|
||
|
||
Beside the accent is one quieter green, `idle`, which is not a seventh meaning but the
|
||
accent's meaning held at lower confidence: good, but not this minute. It exists so that
|
||
"fine and not connected right now" can stop borrowing amber, which is reserved for
|
||
something an operator should look at. Nothing derives it either. */
|
||
|
||
:root {
|
||
color-scheme: dark;
|
||
--oled: #000;
|
||
--bg: #080b10;
|
||
--rail-bg: var(--oled);
|
||
--top-bg: rgba(1, 4, 9, 0.94);
|
||
--surface: #0d1117;
|
||
--surface-lift: #161b22;
|
||
--surface-hi: #21262d;
|
||
--line: #30363d;
|
||
--line-soft: #21262d;
|
||
--text: #f0f6fc;
|
||
--muted: #b1bac4;
|
||
--quiet: #7d8590;
|
||
--accent: #2ea043;
|
||
--accent-ink: #56d364;
|
||
--accent-wash: rgba(46, 160, 67, 0.16);
|
||
/* The quiet green: fine, but not this minute. Same hue as the accent and deliberately
|
||
duller, because it is the accent's own meaning at lower confidence rather than a
|
||
verdict of its own — a television in ordinary use that happens to be switched off is
|
||
not something an operator has to do anything about, and amber said it was. */
|
||
--idle: #4a8f60;
|
||
--idle-ink: #86c79a;
|
||
--idle-wash: rgba(74, 143, 96, 0.13);
|
||
--danger: #e5534b;
|
||
--danger-ink: #ff9b94;
|
||
--danger-wash: rgba(229, 83, 75, 0.13);
|
||
--warn: #e0ad4e;
|
||
--warn-ink: #f2cb78;
|
||
--warn-wash: rgba(239, 196, 107, 0.13);
|
||
--info: #4f9cd8;
|
||
--info-ink: #93cbef;
|
||
--info-wash: rgba(79, 156, 216, 0.14);
|
||
--note: #a07ce8;
|
||
--note-ink: #bda1f5;
|
||
--note-wash: rgba(160, 124, 232, 0.14);
|
||
--data: #3fc2b6;
|
||
--data-ink: #6fdcd0;
|
||
--data-wash: rgba(63, 194, 182, 0.13);
|
||
--rail: 236px;
|
||
--safe-top: env(safe-area-inset-top, 0px);
|
||
--safe-right: env(safe-area-inset-right, 0px);
|
||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||
--safe-left: env(safe-area-inset-left, 0px);
|
||
--top: calc(58px + var(--safe-top));
|
||
--radius: 8px;
|
||
--radius-sm: 6px;
|
||
--radius-xs: 4px;
|
||
--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;
|
||
--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
[hidden] {
|
||
display: none !important;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background:
|
||
radial-gradient(circle at 72% -20%, rgba(63, 194, 182, 0.045), transparent 38rem),
|
||
var(--bg);
|
||
color: var(--text);
|
||
font: 16.5px/1.55 var(--sans);
|
||
font-weight: 450;
|
||
letter-spacing: 0;
|
||
-webkit-font-smoothing: antialiased;
|
||
min-width: 320px;
|
||
min-height: 100dvh;
|
||
background: var(--bg);
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
a {
|
||
color: var(--accent-ink);
|
||
}
|
||
|
||
/* Minimal animation, and none of it decorative. The only movement in the console is a
|
||
thing appearing (a toast, a panel) and the one spinner that says a request is in
|
||
flight. Anybody honouring the platform's reduce-motion setting gets neither. */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
animation-duration: 0.001ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.001ms !important;
|
||
}
|
||
}
|
||
|
||
.skip {
|
||
position: absolute;
|
||
left: -9999px;
|
||
top: 8px;
|
||
z-index: 60;
|
||
padding: 8px 14px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
}
|
||
.skip:focus {
|
||
left: 8px;
|
||
}
|
||
|
||
/* Focus is visible everywhere. The console is driven from a keyboard as much as a mouse,
|
||
and a focus ring that only some controls have is worse than none. */
|
||
:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
border-radius: var(--radius-xs);
|
||
}
|
||
|
||
/* ---------- top bar ---------- */
|
||
|
||
/* The bar spans the whole width above both the rail and the page, and carries the things
|
||
that are true of the console rather than of any one screen: which gateway this is,
|
||
whether it is answering, the search, the notification bell and the way out. */
|
||
.topbar {
|
||
position: fixed;
|
||
inset: 0 0 auto 0;
|
||
z-index: 30;
|
||
height: var(--top);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: var(--safe-top) max(clamp(14px, 2vw, 22px), var(--safe-right)) 0 var(--safe-left);
|
||
background: var(--top-bg);
|
||
border-bottom: 1px solid var(--line);
|
||
box-shadow: 0 1px 0 rgba(240, 246, 252, 0.02), 0 8px 24px rgba(0, 0, 0, 0.2);
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
.topbar-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 11px;
|
||
flex: 0 0 var(--rail);
|
||
/* 22px is the rail's own padding plus a link's, so the mark sits in the same column as
|
||
the navigation icons under it rather than a few pixels to their left. */
|
||
height: 100%;
|
||
padding: 0 22px;
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
}
|
||
/* Round, because the wordmark beside it is the only other thing in this corner and two
|
||
rounded rectangles side by side read as a control rather than as a signature. */
|
||
.brand-mark {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
flex: 0 0 30px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.06em;
|
||
}
|
||
.brand-word {
|
||
font-size: 17px;
|
||
font-weight: 650;
|
||
letter-spacing: -0.01em;
|
||
white-space: nowrap;
|
||
}
|
||
.topbar-spacer {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.topbar-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
.topbar .omni {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: min(820px, calc(100vw - var(--rail) - 380px));
|
||
}
|
||
/* The version is set in the console's monospace because it is read character by character
|
||
when somebody is working out which tree a container was deployed from. */
|
||
.topbar-version {
|
||
font: 11.5px/1 var(--mono);
|
||
color: var(--quiet);
|
||
white-space: nowrap;
|
||
}
|
||
.topbar-status {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 38px;
|
||
height: 38px;
|
||
flex: 0 0 38px;
|
||
padding: 0;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
transition: background .16s ease, border-color .16s ease, color .16s ease;
|
||
}
|
||
.topbar-status:hover:not(:disabled),
|
||
.topbar-status:focus-visible {
|
||
background: var(--surface-lift);
|
||
}
|
||
.topbar-status:disabled {
|
||
cursor: default;
|
||
opacity: 1;
|
||
}
|
||
.topbar-status .dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
background: var(--quiet);
|
||
box-shadow: 0 0 0 3px rgba(125, 133, 144, .12);
|
||
}
|
||
.topbar-status[data-tone="ok"] .dot {
|
||
background: var(--accent-ink);
|
||
box-shadow: 0 0 0 3px var(--accent-wash), 0 0 9px rgba(86, 211, 100, .28);
|
||
}
|
||
.topbar-status[data-tone="bad"] .dot {
|
||
background: var(--danger);
|
||
box-shadow: 0 0 0 3px var(--danger-wash);
|
||
}
|
||
|
||
/* The account is an identity first and an exit second. Keeping sign-out in this menu
|
||
prevents an unexplained door icon from competing with the operational controls. */
|
||
.account-menu {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
height: 38px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.account-trigger {
|
||
max-width: 190px;
|
||
height: 38px;
|
||
padding: 0 9px 0 5px;
|
||
border-color: var(--line);
|
||
background: var(--surface);
|
||
color: var(--muted);
|
||
}
|
||
.account-trigger:hover:not(:disabled),
|
||
.account-menu[data-open="true"] .account-trigger {
|
||
border-color: rgba(86, 211, 100, .5);
|
||
background: var(--surface-hi);
|
||
color: var(--text);
|
||
}
|
||
.account-avatar {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 27px;
|
||
height: 27px;
|
||
flex: 0 0 27px;
|
||
border: 1px solid rgba(86, 211, 100, .36);
|
||
border-radius: 50%;
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
font-size: 11px;
|
||
font-weight: 750;
|
||
}
|
||
.account-name {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 12.5px;
|
||
}
|
||
.account-caret {
|
||
width: 12px;
|
||
height: 12px;
|
||
transition: transform .16s ease;
|
||
}
|
||
.account-menu[data-open="true"] .account-caret {
|
||
transform: rotate(180deg);
|
||
}
|
||
.account-panel {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
width: min(280px, calc(100vw - 24px));
|
||
padding: 7px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: var(--surface);
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, .55);
|
||
z-index: 40;
|
||
}
|
||
.account-identity {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 11px;
|
||
min-width: 0;
|
||
padding: 9px 10px 12px;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
}
|
||
.account-avatar-large {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex-basis: 34px;
|
||
font-size: 13px;
|
||
}
|
||
.account-identity span:last-child {
|
||
min-width: 0;
|
||
}
|
||
.account-identity small,
|
||
.account-identity b {
|
||
display: block;
|
||
}
|
||
.account-identity small {
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
}
|
||
.account-identity b {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 13.5px;
|
||
}
|
||
/* A link and a submit button sit in this menu side by side, and they have to be one row
|
||
shape: the operator reads them as two entries of the same list, not as a link above a
|
||
form. */
|
||
.account-panel > a,
|
||
.account-panel form button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
height: 38px;
|
||
padding: 0 10px;
|
||
border-radius: 8px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
text-decoration: none;
|
||
}
|
||
.account-panel > a:hover {
|
||
background: var(--surface-hi);
|
||
color: var(--text);
|
||
}
|
||
.account-panel > a {
|
||
margin-top: 6px;
|
||
}
|
||
.account-panel form {
|
||
margin-top: 2px;
|
||
}
|
||
.account-panel form button {
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
height: 38px;
|
||
border-color: transparent;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
}
|
||
.account-panel form button:hover:not(:disabled) {
|
||
background: var(--surface-hi);
|
||
color: var(--text);
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.topbar-version {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
/* ---------- omni search ---------- */
|
||
|
||
.omni {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
.omni-input {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
height: 38px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
}
|
||
.omni[data-open="true"] .omni-input {
|
||
border-color: var(--accent);
|
||
}
|
||
.omni-input input[type="search"] {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
height: 100%;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 0;
|
||
background: none;
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
}
|
||
.omni-input input::placeholder {
|
||
color: var(--quiet);
|
||
}
|
||
.omni-input .ico {
|
||
color: var(--quiet);
|
||
flex: 0 0 15px;
|
||
}
|
||
/* The shortcut is printed on the control because it is the only thing on the page saying
|
||
the console can be driven from the keyboard. */
|
||
.omni-key {
|
||
flex: 0 0 auto;
|
||
padding: 2px 6px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 4px;
|
||
font: 10.5px/1.3 var(--mono);
|
||
color: var(--quiet);
|
||
}
|
||
.omni-panel {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
right: auto;
|
||
left: 0;
|
||
width: 100%;
|
||
max-height: min(50vh, 420px);
|
||
overflow-y: auto;
|
||
padding: 6px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||
z-index: 40;
|
||
}
|
||
.omni-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
}
|
||
.omni-item .ico {
|
||
color: var(--quiet);
|
||
flex: 0 0 16px;
|
||
}
|
||
.omni-item.on,
|
||
.omni-item:hover {
|
||
background: var(--surface-hi);
|
||
}
|
||
.omni-item b {
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
}
|
||
.omni-item small {
|
||
display: block;
|
||
color: var(--quiet);
|
||
font-size: 11.5px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.omni-item span {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.omni-group {
|
||
flex: 0 0 auto;
|
||
color: var(--quiet);
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* ---------- notification bell ---------- */
|
||
|
||
.bell {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
height: 38px;
|
||
}
|
||
.bell-button {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 38px;
|
||
height: 38px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
.bell-button:hover {
|
||
background: var(--surface-hi);
|
||
color: var(--text);
|
||
}
|
||
.bell[data-open="true"] .bell-button {
|
||
border-color: var(--accent);
|
||
color: var(--text);
|
||
}
|
||
/* The badge counts unread events. It is a count rather than a dot because "there is news"
|
||
and "there are fourteen things you have not read" are different amounts of urgency. */
|
||
.bell-badge {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
min-width: 17px;
|
||
height: 17px;
|
||
padding: 0 4px;
|
||
border-radius: 9px;
|
||
background: var(--danger);
|
||
color: #fff;
|
||
font: 700 10.5px/17px var(--sans);
|
||
text-align: center;
|
||
}
|
||
.bell-panel {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
width: min(420px, 92vw);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||
z-index: 40;
|
||
overflow: hidden;
|
||
}
|
||
.bell-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 11px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.bell-head b {
|
||
font-size: 13px;
|
||
}
|
||
.bell-list {
|
||
max-height: min(58vh, 460px);
|
||
overflow-y: auto;
|
||
}
|
||
.bell-item {
|
||
display: flex;
|
||
gap: 11px;
|
||
padding: 11px 14px;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
}
|
||
.bell-item:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.bell-item:hover {
|
||
background: var(--surface-lift);
|
||
}
|
||
/* An unread row wears a bar down its left edge rather than a different background: the
|
||
list is read by scanning, and a row that changes colour when it is read makes the whole
|
||
panel reflow visually the moment it is opened. */
|
||
.bell-item[data-unread="true"] {
|
||
box-shadow: inset 2px 0 0 var(--accent);
|
||
}
|
||
.bell-item .glyph {
|
||
flex: 0 0 auto;
|
||
}
|
||
.bell-body {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.bell-body b {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.bell-body p {
|
||
margin: 2px 0 0;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.bell-body time {
|
||
display: block;
|
||
margin-top: 3px;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
}
|
||
.bell-foot {
|
||
padding: 9px 14px;
|
||
border-top: 1px solid var(--line);
|
||
text-align: center;
|
||
}
|
||
.bell-foot a {
|
||
font-size: 12.5px;
|
||
text-decoration: none;
|
||
}
|
||
|
||
/* ---------- rail ---------- */
|
||
|
||
.rail {
|
||
position: fixed;
|
||
top: var(--top);
|
||
bottom: 0;
|
||
left: 0;
|
||
width: var(--rail);
|
||
padding: 12px 12px max(20px, var(--safe-bottom));
|
||
overflow-y: auto;
|
||
background: var(--rail-bg);
|
||
border-right: 1px solid var(--line);
|
||
z-index: 20;
|
||
}
|
||
.rail-group + .rail-group {
|
||
margin-top: 4px;
|
||
}
|
||
.rail-group:first-child {
|
||
margin-bottom: 9px;
|
||
padding-bottom: 9px;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
}
|
||
.rail-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 100%;
|
||
padding: 9px 10px 5px;
|
||
border: 0;
|
||
background: none;
|
||
color: var(--quiet);
|
||
font: 600 10.5px/1 var(--sans);
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
cursor: pointer;
|
||
}
|
||
.rail-head .caret {
|
||
margin-left: auto;
|
||
transition: transform 0.12s ease;
|
||
}
|
||
.rail-head[aria-expanded="false"] .caret {
|
||
transform: rotate(-90deg);
|
||
}
|
||
.rail-head-static {
|
||
cursor: default;
|
||
color: var(--muted);
|
||
}
|
||
.rail a {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 10px;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--muted);
|
||
font-size: 13.5px;
|
||
text-decoration: none;
|
||
}
|
||
.rail a:hover {
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
}
|
||
.rail a[aria-current="page"] {
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
font-weight: 600;
|
||
}
|
||
.rail a .ico {
|
||
flex: 0 0 17px;
|
||
opacity: 0.85;
|
||
}
|
||
.rail a[aria-current="page"] .ico {
|
||
opacity: 1;
|
||
}
|
||
/* A rail item may carry the same badge the bell does, so news is visible without the
|
||
panel being open. */
|
||
.rail-badge {
|
||
margin-left: auto;
|
||
min-width: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9px;
|
||
background: var(--danger);
|
||
color: #fff;
|
||
font: 700 10.5px/17px var(--sans);
|
||
text-align: center;
|
||
}
|
||
|
||
/* ---------- page ---------- */
|
||
|
||
.page {
|
||
width: min(calc(100vw - var(--rail)), 1920px);
|
||
min-width: 0;
|
||
margin: var(--top) 0 0 calc(var(--rail) + max(0px, (100vw - var(--rail) - 1920px) / 2));
|
||
padding: clamp(24px, 2.4vw, 44px) clamp(22px, 3vw, 56px) 80px;
|
||
}
|
||
.page-head {
|
||
margin-bottom: 20px;
|
||
}
|
||
.page-head h1 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 650;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.page-head p {
|
||
margin: 5px 0 0;
|
||
max-width: 68ch;
|
||
color: var(--muted);
|
||
font-size: 13.5px;
|
||
}
|
||
.page-head-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.page-head-title {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
.page-head-icon {
|
||
display: grid;
|
||
place-items: center;
|
||
flex: 0 0 40px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
}
|
||
.page-head-icon .ico {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
.page-head-text {
|
||
min-width: 0;
|
||
}
|
||
.page-head-row .page-head-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.crumbs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
color: var(--quiet);
|
||
font-size: 12px;
|
||
}
|
||
.crumbs a {
|
||
color: var(--muted);
|
||
text-decoration: none;
|
||
}
|
||
.crumbs a:hover {
|
||
color: var(--text);
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
:root {
|
||
--rail: 0px;
|
||
}
|
||
.rail {
|
||
transform: translateX(-100%);
|
||
transition: transform 0.2s cubic-bezier(.22, 1, .36, 1);
|
||
width: min(320px, calc(100vw - 72px));
|
||
padding-right: 14px;
|
||
padding-left: max(14px, var(--safe-left));
|
||
background: rgba(8, 12, 18, .98);
|
||
box-shadow: none;
|
||
}
|
||
.rail[data-open="true"] {
|
||
transform: none;
|
||
box-shadow: 24px 0 80px rgba(0, 0, 0, 0.58);
|
||
}
|
||
.topbar-brand {
|
||
flex: 0 0 auto;
|
||
padding: 0 4px 0 12px;
|
||
}
|
||
.page {
|
||
margin-left: 0;
|
||
}
|
||
}
|
||
|
||
.rail-scrim {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
.rail-scrim {
|
||
position: fixed;
|
||
inset: var(--top) 0 0 0;
|
||
z-index: 19;
|
||
display: block;
|
||
width: auto;
|
||
height: auto;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 0;
|
||
background: rgba(1, 4, 9, .58);
|
||
backdrop-filter: blur(5px);
|
||
animation: rail-scrim-in .16s ease-out;
|
||
}
|
||
.rail-scrim:hover:not(:disabled) {
|
||
border: 0;
|
||
background: rgba(1, 4, 9, .58);
|
||
}
|
||
}
|
||
|
||
@keyframes rail-scrim-in {
|
||
from { opacity: 0; }
|
||
}
|
||
|
||
.rail-toggle {
|
||
display: none;
|
||
width: 34px;
|
||
height: 34px;
|
||
margin-left: 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
@media (max-width: 1400px) {
|
||
.rail-toggle {
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
}
|
||
|
||
/* ---------- error banner ---------- */
|
||
|
||
.banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
padding: 11px 14px;
|
||
border: 1px solid var(--danger);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
font-size: 13.5px;
|
||
}
|
||
.banner button {
|
||
margin-left: auto;
|
||
border: 0;
|
||
background: none;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
/* ---------- cards ---------- */
|
||
|
||
.card {
|
||
padding: 16px 18px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
}
|
||
.card + .card,
|
||
.grid + .card,
|
||
.card + .grid,
|
||
.grid + .grid {
|
||
margin-top: 16px;
|
||
}
|
||
.card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 11px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.card-head h2 {
|
||
margin: 0;
|
||
font-size: 14.5px;
|
||
font-weight: 620;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.card-head p {
|
||
margin: 2px 0 0;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.card-head-text {
|
||
min-width: 0;
|
||
}
|
||
.card-head-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.card-foot {
|
||
margin-top: 14px;
|
||
padding-top: 13px;
|
||
border-top: 1px solid var(--line-soft);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||
}
|
||
.grid[data-cols="2"] {
|
||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||
}
|
||
.grid[data-cols="wide"] {
|
||
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.grid[data-cols="wide"] {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* ---------- tiles ---------- */
|
||
|
||
.tiles {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||
margin-bottom: 16px;
|
||
}
|
||
.tile {
|
||
padding: 13px 15px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
}
|
||
.tile .glyph {
|
||
/* Summary cards share tile padding, so their marker always starts at the same top-left
|
||
inset. The SVG itself remains centred by .glyph's grid treatment. */
|
||
margin: 0 0 9px;
|
||
}
|
||
.tile b {
|
||
display: block;
|
||
font-size: 24px;
|
||
font-weight: 650;
|
||
letter-spacing: -0.03em;
|
||
line-height: 1.15;
|
||
}
|
||
.tile b.small {
|
||
font-size: 14.5px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
line-height: 1.4;
|
||
}
|
||
.tile span {
|
||
display: block;
|
||
margin-top: 3px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
/* A tile that links somewhere behaves like one; a tile that does not must not look like
|
||
it does. */
|
||
a.tile {
|
||
color: inherit;
|
||
text-decoration: none;
|
||
}
|
||
a.tile:hover {
|
||
border-color: var(--accent);
|
||
background: var(--surface-lift);
|
||
}
|
||
|
||
/* ---------- glyphs, tags, chips ---------- */
|
||
|
||
.ico {
|
||
display: block;
|
||
width: 1em;
|
||
height: 1em;
|
||
font-size: 17px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.7;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
flex: 0 0 auto;
|
||
}
|
||
.glyph {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
flex: 0 0 30px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-hi);
|
||
color: var(--muted);
|
||
}
|
||
.tile .glyph .ico { width: 18px; height: 18px; margin: auto; }
|
||
|
||
.loading-page { display: grid; gap: 20px; }
|
||
.loading-heading { display: grid; gap: 10px; max-width: 48rem; }
|
||
.loading-heading .skeleton:first-child { height: 34px; width: 28%; }
|
||
.loading-heading .skeleton:last-child { height: 18px; width: 72%; }
|
||
.loading-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||
.loading-tiles .skeleton { height: 132px; }
|
||
.loading-card { display: grid; gap: 12px; min-height: 180px; padding: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||
.loading-card .skeleton:first-child { height: 20px; width: 32%; }
|
||
.loading-card .skeleton:not(:first-child) { height: 14px; }
|
||
@media (max-width: 820px) { .loading-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
.glyph .ico {
|
||
display: block;
|
||
}
|
||
.glyph[data-tone="ok"] {
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
}
|
||
.glyph[data-tone="idle"] {
|
||
background: var(--idle-wash);
|
||
color: var(--idle-ink);
|
||
}
|
||
.glyph[data-tone="warn"] {
|
||
background: var(--warn-wash);
|
||
color: var(--warn-ink);
|
||
}
|
||
.glyph[data-tone="bad"] {
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
}
|
||
.glyph[data-tone="info"] {
|
||
background: var(--info-wash);
|
||
color: var(--info-ink);
|
||
}
|
||
.glyph[data-tone="note"] {
|
||
background: var(--note-wash);
|
||
color: var(--note-ink);
|
||
}
|
||
.glyph[data-tone="data"] {
|
||
background: var(--data-wash);
|
||
color: var(--data-ink);
|
||
}
|
||
|
||
.tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 2px 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: var(--surface-lift);
|
||
color: var(--muted);
|
||
font-size: 11.5px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
/* A verdict tag carries a dot in its own colour: tone alone is no signal to somebody who
|
||
cannot separate the green from the amber. */
|
||
.tag[data-tone]::before {
|
||
content: "";
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}
|
||
.tag[data-tone="ok"] {
|
||
border-color: rgba(82, 181, 75, 0.35);
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
}
|
||
.tag[data-tone="idle"] {
|
||
border-color: rgba(74, 143, 96, 0.35);
|
||
background: var(--idle-wash);
|
||
color: var(--idle-ink);
|
||
}
|
||
.tag[data-tone="warn"] {
|
||
border-color: rgba(239, 196, 107, 0.35);
|
||
background: var(--warn-wash);
|
||
color: var(--warn-ink);
|
||
}
|
||
.tag[data-tone="bad"] {
|
||
border-color: rgba(229, 83, 75, 0.35);
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
}
|
||
.tag[data-tone="info"] {
|
||
border-color: rgba(79, 156, 216, 0.35);
|
||
background: var(--info-wash);
|
||
color: var(--info-ink);
|
||
}
|
||
.tag[data-tone="note"] {
|
||
border-color: rgba(160, 124, 232, 0.35);
|
||
background: var(--note-wash);
|
||
color: var(--note-ink);
|
||
}
|
||
.tag[data-tone="data"] {
|
||
border-color: rgba(63, 194, 182, 0.35);
|
||
background: var(--data-wash);
|
||
color: var(--data-ink);
|
||
}
|
||
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 9px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-xs);
|
||
background: var(--surface-lift);
|
||
color: var(--muted);
|
||
font: 11.5px/1.5 var(--mono);
|
||
white-space: nowrap;
|
||
}
|
||
.chip[data-tone="ok"] {
|
||
color: var(--accent-ink);
|
||
}
|
||
.chip[data-tone="idle"] {
|
||
color: var(--idle-ink);
|
||
}
|
||
.chip[data-tone="warn"] {
|
||
color: var(--warn-ink);
|
||
}
|
||
.chip[data-tone="bad"] {
|
||
color: var(--danger-ink);
|
||
}
|
||
.chip[data-tone="info"] {
|
||
color: var(--info-ink);
|
||
}
|
||
.chip[data-tone="note"] {
|
||
color: var(--note-ink);
|
||
}
|
||
.chip[data-tone="data"] {
|
||
color: var(--data-ink);
|
||
}
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
/* ---------- tables ---------- */
|
||
|
||
/* Dense but readable, which is most of what this console is for. The wrapper is what
|
||
scrolls, so a wide table never makes the page scroll sideways. */
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
overscroll-behavior-inline: contain;
|
||
-webkit-overflow-scrolling: touch;
|
||
margin: 0 -18px -16px;
|
||
padding: 0 18px 16px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
min-width: max-content;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* Tablets get a drawer shell rather than a squeezed desktop rail. The controls remain in
|
||
one calm bar in both orientations; only the least useful labels disappear as space gets
|
||
tighter, while the live status itself always remains visible. */
|
||
@media (max-width: 1400px) {
|
||
.topbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
height: var(--top);
|
||
padding: var(--safe-top) max(16px, var(--safe-right)) 0 max(16px, var(--safe-left));
|
||
}
|
||
.topbar-brand {
|
||
padding: 0;
|
||
}
|
||
.rail-toggle {
|
||
margin-left: 0;
|
||
}
|
||
.topbar-spacer {
|
||
display: none;
|
||
}
|
||
.topbar .omni {
|
||
position: relative;
|
||
left: auto;
|
||
transform: none;
|
||
flex: 1 1 auto;
|
||
width: auto;
|
||
max-width: 640px;
|
||
min-width: 0;
|
||
}
|
||
.omni-key,
|
||
.topbar-version {
|
||
display: none;
|
||
}
|
||
.topbar-tools {
|
||
display: contents;
|
||
}
|
||
.bell-button,
|
||
.rail-toggle {
|
||
width: 40px;
|
||
height: 40px;
|
||
}
|
||
.topbar-status {
|
||
width: 40px;
|
||
flex-basis: 40px;
|
||
min-width: 40px;
|
||
height: 40px;
|
||
border: 1px solid var(--line);
|
||
background: var(--surface);
|
||
}
|
||
.omni-input,
|
||
.bell,
|
||
.bell-button,
|
||
.account-menu,
|
||
.account-trigger {
|
||
height: 40px;
|
||
}
|
||
.page {
|
||
width: 100%;
|
||
padding: clamp(24px, 3vw, 34px) max(24px, calc(var(--safe-right) + 10px)) max(64px, calc(var(--safe-bottom) + 36px)) max(24px, calc(var(--safe-left) + 10px));
|
||
}
|
||
.table-wrap {
|
||
margin: 0 -18px -16px;
|
||
padding: 0 18px 18px;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
table {
|
||
font-size: 13px;
|
||
}
|
||
th,
|
||
td {
|
||
padding-right: 16px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.account-trigger {
|
||
width: 40px;
|
||
padding: 0;
|
||
}
|
||
.account-name,
|
||
.account-caret {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.brand-word {
|
||
display: none;
|
||
}
|
||
.topbar-status {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 40px;
|
||
padding: 0;
|
||
}
|
||
.page {
|
||
padding: 22px max(16px, var(--safe-right)) max(56px, calc(var(--safe-bottom) + 28px)) max(16px, var(--safe-left));
|
||
}
|
||
.grid,
|
||
.grid[data-cols="2"],
|
||
.grid[data-cols="wide"],
|
||
.grid.two {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
.tiles {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.table-wrap {
|
||
margin-right: -16px;
|
||
margin-left: -16px;
|
||
padding-right: 16px;
|
||
padding-left: 16px;
|
||
}
|
||
}
|
||
|
||
/* A phone keeps every global control on one row. Search is the only flexible item: the
|
||
navigation and identity controls retain useful targets while the field gives up width
|
||
before the header can wrap. Its relative wrapper still anchors the results directly
|
||
beneath the field at exactly the width available here. */
|
||
@media (max-width: 680px) {
|
||
:root {
|
||
--top: calc(60px + var(--safe-top));
|
||
}
|
||
.topbar {
|
||
align-items: center;
|
||
flex-wrap: nowrap;
|
||
gap: clamp(4px, 1.5vw, 8px);
|
||
padding: calc(var(--safe-top) + 8px) max(8px, var(--safe-right)) 8px max(8px, var(--safe-left));
|
||
}
|
||
.rail-toggle { order: 1; }
|
||
.topbar-brand {
|
||
order: 2;
|
||
height: 40px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.topbar .omni {
|
||
order: 3;
|
||
flex: 1 1 96px;
|
||
width: 0;
|
||
min-width: 0;
|
||
max-width: none;
|
||
}
|
||
.topbar-status {
|
||
order: 4;
|
||
margin-left: 0;
|
||
}
|
||
.bell { order: 5; }
|
||
.account-menu { order: 6; }
|
||
.brand-mark {
|
||
width: 30px;
|
||
height: 30px;
|
||
flex-basis: 30px;
|
||
}
|
||
.account-panel,
|
||
.bell-panel {
|
||
position: fixed;
|
||
top: calc(var(--top) + 8px);
|
||
}
|
||
.account-panel {
|
||
right: max(12px, var(--safe-right));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 370px) {
|
||
.tiles {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
th {
|
||
padding: 0 12px 8px 0;
|
||
border-bottom: 1px solid var(--line);
|
||
color: var(--quiet);
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
}
|
||
td {
|
||
padding: 9px 12px 9px 0;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
vertical-align: middle;
|
||
}
|
||
tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
tbody tr:hover td {
|
||
background: var(--surface-lift);
|
||
}
|
||
th:last-child,
|
||
td:last-child {
|
||
padding-right: 0;
|
||
}
|
||
td.num,
|
||
th.num {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
td.mono {
|
||
font: 12px/1.5 var(--mono);
|
||
color: var(--muted);
|
||
}
|
||
td.nowrap,
|
||
th.nowrap {
|
||
white-space: nowrap;
|
||
}
|
||
td.muted {
|
||
color: var(--muted);
|
||
}
|
||
/* A sortable header is a button, so it is reachable from a keyboard. */
|
||
th button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
border: 0;
|
||
padding: 0;
|
||
background: none;
|
||
color: inherit;
|
||
font: inherit;
|
||
letter-spacing: inherit;
|
||
text-transform: inherit;
|
||
cursor: pointer;
|
||
}
|
||
th button:hover {
|
||
color: var(--text);
|
||
}
|
||
th[aria-sort] button {
|
||
color: var(--accent-ink);
|
||
}
|
||
.table-row-link {
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
.table-row-link:hover {
|
||
color: var(--accent-ink);
|
||
}
|
||
|
||
/* ---------- lists ---------- */
|
||
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 11px 0;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
}
|
||
.list-item:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.list-item .list-body {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.list-item b {
|
||
display: block;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
}
|
||
.list-item p {
|
||
margin: 2px 0 0;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.list-item .list-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ---------- controls ---------- */
|
||
|
||
button,
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
height: 32px;
|
||
padding: 0 13px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
color: var(--text);
|
||
font: 600 13px/1 var(--sans);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
button:hover:not(:disabled),
|
||
.btn:hover {
|
||
background: var(--surface-hi);
|
||
border-color: #2c3542;
|
||
}
|
||
button:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
button[data-variant="primary"] {
|
||
border-color: var(--accent);
|
||
background: var(--accent);
|
||
color: #06240a;
|
||
}
|
||
button[data-variant="primary"]:hover:not(:disabled) {
|
||
background: var(--accent-ink);
|
||
border-color: var(--accent-ink);
|
||
}
|
||
button[data-variant="danger"] {
|
||
border-color: rgba(229, 83, 75, 0.4);
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
}
|
||
button[data-variant="danger"]:hover:not(:disabled) {
|
||
background: rgba(229, 83, 75, 0.22);
|
||
}
|
||
button[data-variant="quiet"] {
|
||
border-color: transparent;
|
||
background: none;
|
||
color: var(--muted);
|
||
}
|
||
button[data-variant="quiet"]:hover:not(:disabled) {
|
||
background: var(--surface-lift);
|
||
color: var(--text);
|
||
}
|
||
button[data-size="sm"] {
|
||
height: 26px;
|
||
padding: 0 9px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.field {
|
||
display: block;
|
||
min-width: 0;
|
||
}
|
||
.field > span {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.field > small {
|
||
display: block;
|
||
margin-top: 5px;
|
||
color: var(--quiet);
|
||
font-size: 11.5px;
|
||
}
|
||
input[type="text"],
|
||
input[type="password"],
|
||
input[type="search"],
|
||
input[type="number"],
|
||
input[type="date"],
|
||
input[type="time"],
|
||
input[type="datetime-local"],
|
||
input[type="url"],
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 13px;
|
||
}
|
||
textarea {
|
||
height: auto;
|
||
min-height: 74px;
|
||
padding: 8px 10px;
|
||
resize: vertical;
|
||
}
|
||
input:focus,
|
||
select:focus,
|
||
textarea:focus {
|
||
border-color: var(--accent);
|
||
outline: none;
|
||
}
|
||
input::placeholder,
|
||
textarea::placeholder {
|
||
color: var(--quiet);
|
||
}
|
||
select {
|
||
appearance: none;
|
||
padding-right: 26px;
|
||
background-image: linear-gradient(45deg, transparent 50%, var(--quiet) 50%),
|
||
linear-gradient(135deg, var(--quiet) 50%, transparent 50%);
|
||
background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px;
|
||
background-size: 5px 5px;
|
||
background-repeat: no-repeat;
|
||
}
|
||
|
||
.fields {
|
||
display: grid;
|
||
gap: 13px;
|
||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||
}
|
||
.field-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* A switch, not a checkbox: the console is full of on/off policy and a control that says
|
||
which way it is pointing from across the room is worth the extra markup. */
|
||
.check {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 11px;
|
||
padding: 10px 0;
|
||
cursor: pointer;
|
||
}
|
||
.check input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.check .switch {
|
||
position: relative;
|
||
width: 34px;
|
||
height: 20px;
|
||
flex: 0 0 34px;
|
||
margin-top: 1px;
|
||
border-radius: 999px;
|
||
background: var(--surface-hi);
|
||
border: 1px solid var(--line);
|
||
transition: background 0.12s ease, border-color 0.12s ease;
|
||
}
|
||
.check .switch::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--muted);
|
||
transition: transform 0.12s ease, background 0.12s ease;
|
||
}
|
||
.check input:checked + .switch {
|
||
background: var(--accent-wash);
|
||
border-color: var(--accent);
|
||
}
|
||
.check input:checked + .switch::after {
|
||
transform: translateX(14px);
|
||
background: var(--accent);
|
||
}
|
||
.check input:focus-visible + .switch {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
.check input:disabled + .switch {
|
||
opacity: 0.45;
|
||
}
|
||
.check-body b {
|
||
display: block;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
}
|
||
.check-body p {
|
||
margin: 2px 0 0;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
/* A row of exclusive choices — a date window, a severity — where a select would hide the
|
||
options behind a click. */
|
||
.segments {
|
||
display: inline-flex;
|
||
padding: 2px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
}
|
||
.segments button {
|
||
height: 26px;
|
||
border: 0;
|
||
background: none;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
.segments button[aria-pressed="true"] {
|
||
background: var(--surface-hi);
|
||
color: var(--text);
|
||
}
|
||
|
||
/* ---------- filter bar ---------- */
|
||
|
||
/* Fast filtering is most of what makes a table useful, so the controls sit above it in a
|
||
row of their own rather than behind a "filters" disclosure nobody opens. */
|
||
.filters {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 14px;
|
||
padding: 13px 15px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
}
|
||
.filters .field {
|
||
flex: 1 1 160px;
|
||
max-width: 260px;
|
||
}
|
||
.filters .field.grow {
|
||
flex: 2 1 240px;
|
||
max-width: none;
|
||
}
|
||
.filters .filter-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.filter-summary {
|
||
color: var(--quiet);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ---------- charts ---------- */
|
||
|
||
/* One bar chart, drawn with divs. A charting library would be the single largest
|
||
dependency in the console for the sake of four bar charts. */
|
||
.bars {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 3px;
|
||
height: 92px;
|
||
padding-top: 6px;
|
||
}
|
||
.bar {
|
||
position: relative;
|
||
flex: 1 1 0;
|
||
min-width: 3px;
|
||
border-radius: 2px 2px 0 0;
|
||
background: var(--accent-wash);
|
||
border-top: 2px solid var(--accent);
|
||
min-height: 2px;
|
||
}
|
||
.bar[data-tone="bad"] {
|
||
background: var(--danger-wash);
|
||
border-top-color: var(--danger);
|
||
}
|
||
.bar[data-empty="true"] {
|
||
background: var(--line-soft);
|
||
border-top-color: var(--line);
|
||
}
|
||
.bars-axis {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 6px;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* A proportion bar, for one number against a total. */
|
||
.meter {
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: var(--surface-hi);
|
||
overflow: hidden;
|
||
}
|
||
.meter > div {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
}
|
||
.meter[data-tone="warn"] > div {
|
||
background: var(--warn);
|
||
}
|
||
.meter[data-tone="bad"] > div {
|
||
background: var(--danger);
|
||
}
|
||
|
||
/* ---------- log / code ---------- */
|
||
|
||
/* The log table.
|
||
|
||
Six columns, in the order the questions are asked: when, how bad, which part of the
|
||
server, what happened, did it work, how long did it take. The columns are the whole
|
||
design — an operator learns where to look once and then reads down a column rather than
|
||
across a line, which is what makes thirty seconds of watching this page worth anything.
|
||
|
||
Monospace is spent deliberately rather than applied to the row. The time, the method,
|
||
the status and the duration are values to be compared down a column and are set in it;
|
||
the summary is a sentence and is set in the sans face, because a sentence in monospace
|
||
is slower to read and looks like a dump of something. */
|
||
|
||
.logbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.logbar-filters {
|
||
display: flex;
|
||
flex: 1 1 520px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.logbar-filters select {
|
||
width: auto;
|
||
min-width: 0;
|
||
height: 30px;
|
||
padding: 0 26px 0 9px;
|
||
font-size: 12px;
|
||
}
|
||
/* The actions are secondary to the filters and are marked so by weight, not by distance:
|
||
the previous bar gave Pause and Export the same prominence as the controls that decide
|
||
what the table contains. */
|
||
.logbar-actions {
|
||
display: flex;
|
||
flex: 0 0 auto;
|
||
gap: 6px;
|
||
margin-left: auto;
|
||
}
|
||
.logsearch {
|
||
position: relative;
|
||
display: flex;
|
||
flex: 1 1 260px;
|
||
align-items: center;
|
||
min-width: 200px;
|
||
}
|
||
.logsearch svg {
|
||
position: absolute;
|
||
left: 9px;
|
||
width: 14px;
|
||
height: 14px;
|
||
color: var(--quiet);
|
||
pointer-events: none;
|
||
}
|
||
.logsearch input {
|
||
width: 100%;
|
||
height: 30px;
|
||
padding-left: 28px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* One chip per narrowing in force. They are the only place a filter can be removed one at
|
||
a time, which is what makes drilling in by clicking a value in the table reversible. */
|
||
.logchips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.logchip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 24px;
|
||
padding: 0 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: var(--surface-lift);
|
||
color: var(--muted);
|
||
font: 500 11px/1 var(--sans);
|
||
}
|
||
.logchip svg {
|
||
width: 11px;
|
||
height: 11px;
|
||
opacity: .7;
|
||
}
|
||
.logchip:hover:not(:disabled) {
|
||
border-color: var(--danger);
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
}
|
||
.logchip-clear {
|
||
border-style: dashed;
|
||
color: var(--quiet);
|
||
}
|
||
|
||
.logshell {
|
||
position: relative;
|
||
}
|
||
.logview {
|
||
height: 62vh;
|
||
min-height: 320px;
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: #080a0e;
|
||
font: 12px/1.5 var(--sans);
|
||
contain: layout paint style;
|
||
}
|
||
/* Every track but the event is a fixed width, and that is the whole of why the columns
|
||
line up. A row is its own grid container — there are twenty thousand of them and no
|
||
shared table — so a `minmax()` track sized from its own content gave each row a slightly
|
||
different Service and Result column, and the Result column moved as the widest verdict
|
||
on screen changed. Fixed tracks cannot move; content inside them truncates instead.
|
||
|
||
Vertical rhythm is set here too: cells start at the top of the row rather than being
|
||
centred in it, so the Time, Level, Service and Result of a two-line event sit on the
|
||
same line as its summary instead of dropping half a line to the middle of the pair. */
|
||
.loghead,
|
||
.logrow {
|
||
display: grid;
|
||
grid-template-columns: 92px 52px 190px minmax(240px, 1fr) 150px 68px;
|
||
gap: 12px;
|
||
padding: 0 12px;
|
||
}
|
||
.loghead {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
align-items: center;
|
||
height: 31px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: #10131a;
|
||
color: var(--quiet);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
min-width: 900px;
|
||
}
|
||
.loghead > :last-child {
|
||
text-align: right;
|
||
}
|
||
.logbody {
|
||
position: relative;
|
||
min-width: 900px;
|
||
}
|
||
|
||
/* The date is a divider between days rather than a column repeated on every row. A log
|
||
watched live is almost always one day; printing the date 20,000 times to cover the
|
||
handful of rows where it changes is the trade the compact timestamp exists to avoid. */
|
||
.logday {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
left: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
height: 26px;
|
||
padding: 0 12px;
|
||
background: linear-gradient(to bottom, rgba(255, 255, 255, .03), transparent);
|
||
color: var(--quiet);
|
||
font: 700 10px/1 var(--sans);
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.logday span {
|
||
padding-right: 10px;
|
||
background: #080a0e;
|
||
}
|
||
.logday::after {
|
||
content: '';
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--line-soft);
|
||
}
|
||
|
||
/* The figures below are the twin of the row geometry in `pages/Logs.tsx`: 7px of padding,
|
||
a 16px first line, a 2px gap and a 15px second line, over a 1px hairline. The row's
|
||
height is set inline from those constants, so a cell drawn taller than its share here is
|
||
text sliced through the middle rather than a row that grows. Every first-line cell is
|
||
therefore given `line-height: 16px` explicitly, whatever its font size. */
|
||
.logrow {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
left: 0;
|
||
align-items: start;
|
||
padding-top: 7px;
|
||
padding-bottom: 7px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, .03);
|
||
contain: strict;
|
||
}
|
||
.logrow > *,
|
||
.logrow-line {
|
||
line-height: 16px;
|
||
}
|
||
.logrow:hover {
|
||
background: rgba(255, 255, 255, .035);
|
||
}
|
||
.logrow[data-selected] {
|
||
background: var(--accent-wash);
|
||
}
|
||
/* A failure is marked on the row and not only in a column, because the thing an operator
|
||
is scanning for is a row, and a hairline at the start of one is found faster than a word
|
||
two thirds of the way along it. INFO is deliberately unmarked: most of the table is INFO
|
||
and a mark every row carries is a mark that says nothing. */
|
||
.logrow[data-level="ERROR"] {
|
||
box-shadow: inset 2px 0 0 var(--danger);
|
||
background: rgba(229, 83, 75, .05);
|
||
}
|
||
.logrow[data-level="WARN"] {
|
||
box-shadow: inset 2px 0 0 var(--warn);
|
||
}
|
||
|
||
.logrow-time {
|
||
color: var(--quiet);
|
||
font: 11px/16px var(--mono);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* Every structured value in the table is also the control that filters to it. They are
|
||
buttons rather than links because nothing navigates: the table narrows in place. */
|
||
.logfacet {
|
||
height: auto;
|
||
min-width: 0;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
border: 0;
|
||
background: none;
|
||
color: inherit;
|
||
font: inherit;
|
||
text-align: left;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.logfacet:hover:not(:disabled) {
|
||
border: 0;
|
||
background: none;
|
||
text-decoration: underline;
|
||
text-underline-offset: 3px;
|
||
}
|
||
.logfacet:focus-visible {
|
||
outline: 1px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.logrow-level {
|
||
color: var(--quiet);
|
||
font: 700 10px/16px var(--sans);
|
||
letter-spacing: .06em;
|
||
}
|
||
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); }
|
||
.logrow-level[data-level="WARN"] { color: var(--warn-ink); }
|
||
.logrow-level[data-level="INFO"] { color: var(--muted); }
|
||
.logrow-level[data-level="DEBUG"],
|
||
.logrow-level[data-level="TRACE"] { color: var(--quiet); }
|
||
|
||
/* Service and component. The service is the recognisable half — one word, upper case,
|
||
always the same colour for the same subsystem — and the component sits beside it in the
|
||
ordinary text colour, so the pair reads as a place rather than as two tags. The tones
|
||
are the console's secondary palette only: green, amber and red mean good, look and wrong
|
||
on every other page and must not start meaning "playback" on this one. */
|
||
.logrow-place {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
min-width: 0;
|
||
height: 16px;
|
||
overflow: hidden;
|
||
}
|
||
.logrow-service {
|
||
flex: 0 0 auto;
|
||
max-width: 96px;
|
||
overflow: hidden;
|
||
color: var(--quiet);
|
||
font: 700 10px/16px var(--sans);
|
||
letter-spacing: .07em;
|
||
text-overflow: ellipsis;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
.logrow-service[data-tone="info"] { color: var(--info-ink); }
|
||
.logrow-service[data-tone="note"] { color: var(--note-ink); }
|
||
.logrow-service[data-tone="data"] { color: var(--data-ink); }
|
||
.logrow-service[data-tone="idle"] { color: var(--idle-ink); }
|
||
.logrow-service[data-tone="quiet"] { color: var(--muted); }
|
||
.logrow-sep {
|
||
flex: 0 0 auto;
|
||
color: var(--line);
|
||
}
|
||
.logrow-component {
|
||
flex: 1 1 auto;
|
||
color: var(--quiet);
|
||
font-size: 11.5px;
|
||
line-height: 16px;
|
||
}
|
||
|
||
/* The summary is the row. It is a button because selecting the row is what opens the
|
||
record, and the whole width of the column should be the target. */
|
||
.logrow-summary {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
align-items: stretch;
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
border: 0;
|
||
background: none;
|
||
font: inherit;
|
||
text-align: left;
|
||
}
|
||
.logrow-summary:hover:not(:disabled) {
|
||
border: 0;
|
||
background: none;
|
||
}
|
||
.logrow-summary:focus-visible {
|
||
outline: 1px solid var(--accent);
|
||
outline-offset: -1px;
|
||
}
|
||
.logrow-line {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
height: 16px;
|
||
}
|
||
.logrow-action {
|
||
flex: 0 0 auto;
|
||
color: var(--muted);
|
||
font: 600 10.5px/16px var(--mono);
|
||
letter-spacing: .04em;
|
||
}
|
||
.logrow-action[data-method="POST"],
|
||
.logrow-action[data-method="PUT"],
|
||
.logrow-action[data-method="PATCH"] { color: var(--info-ink); }
|
||
.logrow-action[data-method="DELETE"] { color: var(--danger-ink); }
|
||
.logrow-text {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: var(--text);
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
/* Identity on a request row, kept on the summary's own line. It gives way before the path
|
||
does — knowing which route was called matters more than which television called it, and
|
||
the drawer holds both either way. */
|
||
.logrow-trail {
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
max-width: 38%;
|
||
overflow: hidden;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* The second line: the reason a failure needs no drawer, or the person and position that
|
||
make an application event mean something. Exactly one line tall, 15px, which is the
|
||
figure the row was measured with — it can truncate but it can never wrap, because a wrap
|
||
is a row overflowing into the one below it. */
|
||
.logrow-second {
|
||
height: 15px;
|
||
overflow: hidden;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
line-height: 15px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.logrow-second[data-tone="error"] {
|
||
color: var(--danger-ink);
|
||
}
|
||
|
||
/* A result is a word, not a badge. A success is understated to the point of being ignorable
|
||
— which is the correct amount of attention for the four hundredth 200 in a row — and only
|
||
a failure is given a fill. */
|
||
.logrow-result {
|
||
min-width: 0;
|
||
height: 16px;
|
||
overflow: hidden;
|
||
}
|
||
.logrow-verdict {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
color: var(--quiet);
|
||
font: 500 11px/14px var(--mono);
|
||
}
|
||
.logrow-verdict[data-tone="ok"] { color: var(--muted); }
|
||
.logrow-verdict[data-tone="info"] { color: var(--info-ink); }
|
||
.logrow-verdict[data-tone="data"] { color: var(--data-ink); }
|
||
/* A fill still has to fit the row's line box: 14px of text and 1px either side is the
|
||
16px every other cell on the line occupies. */
|
||
.logrow-verdict[data-tone="warn"],
|
||
.logrow-verdict[data-tone="bad"] {
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-xs);
|
||
font-weight: 600;
|
||
}
|
||
.logrow-verdict[data-tone="warn"] {
|
||
background: var(--warn-wash);
|
||
color: var(--warn-ink);
|
||
}
|
||
.logrow-verdict[data-tone="bad"] {
|
||
background: var(--danger-wash);
|
||
color: var(--danger-ink);
|
||
}
|
||
|
||
.logrow-duration {
|
||
color: var(--quiet);
|
||
font: 11px/16px var(--mono);
|
||
font-variant-numeric: tabular-nums;
|
||
text-align: right;
|
||
}
|
||
.logrow-duration[data-tone="warn"] { color: var(--warn-ink); }
|
||
.logrow-duration[data-tone="bad"] {
|
||
color: var(--danger-ink);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Offered only once the operator has left the tail, which is the only time following the
|
||
log automatically would be taking the page away from them. */
|
||
.logtail {
|
||
position: absolute;
|
||
right: 18px;
|
||
bottom: 14px;
|
||
z-index: 3;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--accent);
|
||
border-radius: 999px;
|
||
background: var(--surface-lift);
|
||
color: var(--accent-ink);
|
||
font: 600 11px/1 var(--sans);
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, .5);
|
||
}
|
||
.logtail svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
transform: rotate(90deg);
|
||
}
|
||
.logtail:hover:not(:disabled) {
|
||
border-color: var(--accent);
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
}
|
||
|
||
/* The drawer answers "exactly how and why". Sections rather than one alphabetical list,
|
||
and a field with no value is omitted rather than printed as `unknown`. */
|
||
.logdrawer {
|
||
margin-top: 12px;
|
||
padding: 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface);
|
||
}
|
||
.logdrawer-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.logdrawer-head > div:first-child {
|
||
min-width: 0;
|
||
}
|
||
.logdrawer-head b {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 14px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.logdrawer-place {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 5px;
|
||
margin: 0;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
}
|
||
.logdrawer-error {
|
||
margin: 6px 0 0;
|
||
color: var(--danger-ink);
|
||
font: 12px/1.5 var(--mono);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.logdrawer-actions {
|
||
display: flex;
|
||
flex: 0 0 auto;
|
||
gap: 6px;
|
||
}
|
||
.logdrawer-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
gap: 16px 24px;
|
||
margin-top: 12px;
|
||
}
|
||
.logdrawer-section h4 {
|
||
margin: 0 0 6px;
|
||
color: var(--quiet);
|
||
font: 700 10px/1 var(--sans);
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.logdrawer-section dl {
|
||
display: grid;
|
||
grid-template-columns: minmax(88px, max-content) minmax(0, 1fr);
|
||
gap: 4px 12px;
|
||
margin: 0;
|
||
font: 12px/1.5 var(--mono);
|
||
}
|
||
.logdrawer-section dt { color: var(--muted); }
|
||
.logdrawer-section dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||
.logdrawer-raw {
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
.logdrawer-raw summary {
|
||
cursor: pointer;
|
||
color: var(--quiet);
|
||
font: 700 10px/1 var(--sans);
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.logdrawer-raw pre {
|
||
max-height: 320px;
|
||
margin: 10px 0 0;
|
||
overflow: auto;
|
||
padding: 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-xs);
|
||
background: #080a0e;
|
||
font: 11px/1.6 var(--mono);
|
||
}
|
||
|
||
/* Columns are given up in the order they are least missed: the duration first, since it is
|
||
the only one of the six whose absence costs nothing to understanding what happened, and
|
||
the timestamp after it. Level, service and the summary are never dropped — a log that
|
||
cannot say how bad, where, or what is not a log. */
|
||
@media (max-width: 1180px) {
|
||
.loghead,
|
||
.logrow {
|
||
grid-template-columns: 84px 46px 170px minmax(200px, 1fr) 130px;
|
||
gap: 10px;
|
||
}
|
||
.loghead > :last-child,
|
||
.logrow-duration {
|
||
display: none;
|
||
}
|
||
.loghead,
|
||
.logbody {
|
||
min-width: 680px;
|
||
}
|
||
}
|
||
@media (max-width: 900px) {
|
||
.loghead,
|
||
.logrow {
|
||
grid-template-columns: 46px 140px minmax(180px, 1fr) 110px;
|
||
gap: 8px;
|
||
}
|
||
.loghead > :first-child,
|
||
.logrow-time {
|
||
display: none;
|
||
}
|
||
.loghead,
|
||
.logbody {
|
||
min-width: 520px;
|
||
}
|
||
}
|
||
.logdetails {
|
||
min-width: 0;
|
||
}
|
||
.logdetails summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.logdetails summary::-webkit-details-marker { display: none; }
|
||
.logdetails summary::before {
|
||
content: '›';
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
color: var(--accent);
|
||
}
|
||
.logdetails[open] summary::before { transform: rotate(90deg); }
|
||
.logdetails dl {
|
||
display: grid;
|
||
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
|
||
gap: 4px 12px;
|
||
margin: 7px 0 2px;
|
||
padding: 8px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: rgba(255, 255, 255, .025);
|
||
}
|
||
.logdetails dt { color: var(--muted); }
|
||
.logdetails dd { min-width: 0; margin: 0; color: var(--text); overflow-wrap: anywhere; }
|
||
|
||
pre.code {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: #080a0e;
|
||
color: var(--muted);
|
||
font: 12px/1.6 var(--mono);
|
||
overflow-x: auto;
|
||
}
|
||
|
||
/* ---------- misc ---------- */
|
||
|
||
.empty {
|
||
margin: 0;
|
||
padding: 22px 0;
|
||
color: var(--quiet);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
}
|
||
.muted {
|
||
color: var(--muted);
|
||
}
|
||
.quiet {
|
||
color: var(--quiet);
|
||
}
|
||
.mono {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
}
|
||
.note {
|
||
margin: 0;
|
||
padding: 10px 13px;
|
||
border: 1px solid var(--line);
|
||
border-left: 2px solid var(--info);
|
||
border-radius: var(--radius-xs);
|
||
background: var(--surface-lift);
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.note[data-tone="warn"] {
|
||
border-left-color: var(--warn);
|
||
}
|
||
.note[data-tone="bad"] {
|
||
border-left-color: var(--danger);
|
||
}
|
||
.note[data-tone="ok"] {
|
||
border-left-color: var(--accent);
|
||
}
|
||
.stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.row.tight {
|
||
gap: 6px;
|
||
}
|
||
.spacer {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
/* One spinner, and it is the only thing in the console that moves on its own. */
|
||
.spinner {
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 2px solid var(--line);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* The page's own loading state: a shape rather than a spinner, so the layout does not
|
||
jump when the data lands. */
|
||
.skeleton {
|
||
border-radius: var(--radius-sm);
|
||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-lift) 50%, var(--surface) 75%);
|
||
background-size: 300% 100%;
|
||
animation: shimmer 1.3s ease-in-out infinite;
|
||
}
|
||
@keyframes shimmer {
|
||
to {
|
||
background-position: -300% 0;
|
||
}
|
||
}
|
||
|
||
/* ---------- toasts ---------- */
|
||
|
||
/* A mutation says what happened here rather than in the error banner, which is reserved
|
||
for the console failing to talk to the gateway at all. */
|
||
.toasts {
|
||
position: fixed;
|
||
right: 18px;
|
||
bottom: 18px;
|
||
z-index: 50;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-width: min(420px, 90vw);
|
||
}
|
||
.toast {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 11px 14px;
|
||
border: 1px solid var(--line);
|
||
border-left: 3px solid var(--accent);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
|
||
font-size: 13px;
|
||
animation: toast-in 0.14s ease;
|
||
}
|
||
.toast[data-tone="bad"] {
|
||
border-left-color: var(--danger);
|
||
}
|
||
.toast[data-tone="warn"] {
|
||
border-left-color: var(--warn);
|
||
}
|
||
.toast button {
|
||
margin-left: auto;
|
||
height: auto;
|
||
padding: 0;
|
||
border: 0;
|
||
background: none;
|
||
color: var(--quiet);
|
||
}
|
||
@keyframes toast-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(6px);
|
||
}
|
||
}
|
||
|
||
/* ---------- dialog ---------- */
|
||
|
||
.scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 45;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 20px;
|
||
background: rgba(4, 6, 9, 0.7);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
.dialog {
|
||
width: min(520px, 100%);
|
||
max-height: 86vh;
|
||
overflow-y: auto;
|
||
padding: 20px 22px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
|
||
}
|
||
.dialog h2 {
|
||
margin: 0 0 6px;
|
||
font-size: 16px;
|
||
}
|
||
.dialog p {
|
||
margin: 0 0 16px;
|
||
color: var(--muted);
|
||
font-size: 13.5px;
|
||
}
|
||
.dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* ---------- key/value rows ---------- */
|
||
|
||
/* A label on the left, a verdict on the right. The console's densest way of stating a
|
||
handful of facts about one subject, and the shape the overview is built from. */
|
||
.kv {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.kv-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 9px 0;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
font-size: 13.5px;
|
||
}
|
||
.kv-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.kv-row > span:first-child {
|
||
color: var(--muted);
|
||
}
|
||
.kv-row > span:last-child {
|
||
margin-left: auto;
|
||
text-align: right;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* A tile strip with no card around it, for a group of numbers inside one. */
|
||
.tiles.plain {
|
||
margin-bottom: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
}
|
||
.tiles.plain .tile {
|
||
padding: 10px 12px;
|
||
border: 0;
|
||
background: var(--surface-lift);
|
||
}
|
||
.tiles.plain .tile b {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.hint {
|
||
margin: 0;
|
||
color: var(--quiet);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* A two-column card grid that does not collapse as eagerly as the default. */
|
||
.grid.two {
|
||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||
}
|
||
@media (max-width: 980px) {
|
||
.grid.two {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* ---------- linkable list rows ---------- */
|
||
|
||
/* A row that is a destination. The whole row is the target rather than a link inside it,
|
||
because a list of people is read by scanning names and clicking one. */
|
||
.list-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 18px;
|
||
border-bottom: 1px solid var(--line-soft);
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
}
|
||
.list-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.list-row:hover {
|
||
background: var(--surface-lift);
|
||
}
|
||
.list-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.list-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
.list-meta {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.list-row .list-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
/* A card that holds nothing but a list: the rows carry their own padding, so the card
|
||
must not add a second inset around them. */
|
||
.card.flush {
|
||
padding: 0;
|
||
}
|
||
|
||
/* Initials behind a name. Emby has no avatar for most households, and a row of identical
|
||
grey circles says nothing — the letters are what makes one row distinguishable from the
|
||
next at a glance. */
|
||
.avatar {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: 0 0 34px;
|
||
border-radius: 50%;
|
||
background: var(--note-wash);
|
||
color: var(--note-ink);
|
||
font-size: 12.5px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
/* The presence dot: four-valued, and the tone is passed rather than derived. The two
|
||
greens are the point — one set is connected now and the other was an hour ago, and both
|
||
are fine, so they read as the same answer at two strengths rather than as two answers.
|
||
The dot is 7px and colour alone is never the whole signal here: every one of these
|
||
carries the wording in its title. */
|
||
.dot-state {
|
||
width: 7px;
|
||
height: 7px;
|
||
flex: 0 0 7px;
|
||
border-radius: 50%;
|
||
background: var(--quiet);
|
||
}
|
||
.dot-state[data-tone="ok"] {
|
||
background: var(--accent);
|
||
}
|
||
.dot-state[data-tone="idle"] {
|
||
background: var(--idle);
|
||
}
|
||
.dot-state[data-tone="warn"] {
|
||
background: var(--warn);
|
||
}
|
||
.dot-state[data-tone="bad"] {
|
||
background: var(--danger);
|
||
}
|
||
|
||
/* A column of switches, for a list of people or of options. */
|
||
.checks.columns {
|
||
display: grid;
|
||
gap: 0 24px;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
}
|
||
|
||
.crumb {
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
.crumb:hover {
|
||
color: var(--accent-ink);
|
||
}
|
||
|
||
/* A run of small chips inside a table cell — a device's build history. */
|
||
.versions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* ---------- journey visits ---------- */
|
||
|
||
/* One visit read from its first step to its last. A timeline rather than a table because
|
||
the shape of a visit — how many steps, and how far apart — is most of what is being
|
||
read, and a table of five rows hides it. */
|
||
.visit {
|
||
padding: 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
}
|
||
.visit + .visit {
|
||
margin-top: 10px;
|
||
}
|
||
.visit header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.visit header b {
|
||
font-size: 13.5px;
|
||
}
|
||
.visit header span {
|
||
display: block;
|
||
color: var(--quiet);
|
||
font-size: 12px;
|
||
}
|
||
.visit header .tag {
|
||
margin-left: auto;
|
||
}
|
||
.journey-answers {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 1px;
|
||
margin: 0 0 15px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-xs);
|
||
background: var(--line);
|
||
}
|
||
.journey-answer {
|
||
position: relative;
|
||
min-width: 0;
|
||
padding: 11px 12px 11px 38px;
|
||
background: var(--surface);
|
||
}
|
||
.journey-answer .ico {
|
||
position: absolute;
|
||
top: 13px;
|
||
left: 12px;
|
||
width: 16px;
|
||
height: 16px;
|
||
color: var(--quiet);
|
||
}
|
||
.journey-answer[data-kind="entry"] .ico,
|
||
.journey-answer[data-kind="outcome"] .ico { color: var(--accent); }
|
||
.journey-answer[data-kind="selection"] .ico { color: var(--note-ink); }
|
||
.journey-answer span,
|
||
.journey-answer b {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.journey-answer span {
|
||
margin-bottom: 3px;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
font-weight: 650;
|
||
letter-spacing: .04em;
|
||
text-transform: uppercase;
|
||
}
|
||
.journey-answer b {
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.journey-timeline {
|
||
margin: 0;
|
||
padding: 0 0 0 14px;
|
||
list-style: none;
|
||
border-left: 1px solid var(--line);
|
||
}
|
||
.journey-timeline li {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
padding: 7px 0 7px 5px;
|
||
font-size: 12.5px;
|
||
}
|
||
.timeline-dot {
|
||
position: absolute;
|
||
left: -18px;
|
||
top: 12px;
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
}
|
||
.timeline-dot[data-action="select"],
|
||
.timeline-dot[data-action="open"] { background: var(--note-ink); }
|
||
.timeline-dot[data-action="close"] { background: var(--quiet); }
|
||
.journey-timeline li > div { min-width: 0; }
|
||
.journey-timeline li b {
|
||
display: inline;
|
||
font-weight: 600;
|
||
}
|
||
.journey-timeline li div span {
|
||
display: block;
|
||
color: var(--muted);
|
||
}
|
||
.journey-timeline li time {
|
||
margin-left: auto;
|
||
padding-left: 10px;
|
||
color: var(--quiet);
|
||
white-space: nowrap;
|
||
}
|
||
.visits {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.journey-answers { grid-template-columns: 1fr; }
|
||
.journey-timeline li { align-items: flex-start; flex-wrap: wrap; }
|
||
.journey-timeline li time { width: 100%; margin: 2px 0 0; padding-left: 0; }
|
||
}
|
||
|
||
/* A headline number over a proportion bar, for one figure that has a denominator. */
|
||
.summary {
|
||
padding: 13px 15px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
}
|
||
.summary-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
margin-bottom: 9px;
|
||
}
|
||
.summary-head b {
|
||
font-size: 13px;
|
||
}
|
||
.summary-head strong {
|
||
margin-left: auto;
|
||
font-size: 22px;
|
||
font-weight: 650;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.summary p {
|
||
margin: 9px 0 0;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.summary-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.table-sub {
|
||
display: block;
|
||
color: var(--quiet);
|
||
font-size: 11.5px;
|
||
}
|
||
.route-arrow {
|
||
color: var(--quiet);
|
||
}
|
||
|
||
details summary {
|
||
cursor: pointer;
|
||
font-size: 12.5px;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
/* ---------- theme swatch ---------- */
|
||
|
||
/* A palette rendered as its own three colours. The custom properties are the only inline
|
||
style in the console, and they are values rather than layout: what a colour scheme looks
|
||
like cannot be described in a class name. */
|
||
.swatch {
|
||
position: relative;
|
||
width: 34px;
|
||
height: 22px;
|
||
flex: 0 0 34px;
|
||
margin-top: 1px;
|
||
border-radius: var(--radius-xs);
|
||
background: var(--swatch-surface, var(--surface-hi));
|
||
border: 1px solid var(--swatch-hairline, var(--line));
|
||
overflow: hidden;
|
||
}
|
||
.swatch i {
|
||
position: absolute;
|
||
inset: auto 0 0 0;
|
||
height: 7px;
|
||
background: var(--swatch-accent, var(--accent));
|
||
}
|
||
|
||
/* A caption inside a card, above a group of controls that belong together. Quieter than a
|
||
heading, because the card already has one. */
|
||
.group-label {
|
||
margin: 4px 0 2px;
|
||
color: var(--quiet);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.group + .group {
|
||
margin-top: 14px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--line-soft);
|
||
}
|
||
|
||
/* ---------- hero scheduler ---------- */
|
||
|
||
.hero-pins {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.hero-pin {
|
||
display: grid;
|
||
grid-template-columns: 30px minmax(0, 1fr) auto auto;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-height: 52px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
}
|
||
.hero-pin-order {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
background: var(--note-wash);
|
||
color: var(--note-ink);
|
||
font-size: 12px;
|
||
font-weight: 750;
|
||
}
|
||
.hero-pin > span:nth-child(2) {
|
||
min-width: 0;
|
||
}
|
||
.hero-pin b,
|
||
.hero-pin small {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.hero-pin b { font-size: 13.5px; }
|
||
.hero-pin small { margin-top: 2px; color: var(--quiet); font-size: 11.5px; }
|
||
|
||
.hero-schedule-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.hero-schedule {
|
||
position: relative;
|
||
display: grid;
|
||
grid-template-columns: 100px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 15px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--line);
|
||
border-radius: 11px;
|
||
background: linear-gradient(135deg, var(--surface-lift), rgba(13, 17, 23, .74));
|
||
}
|
||
.hero-schedule::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
width: 3px;
|
||
background: var(--quiet);
|
||
opacity: .4;
|
||
}
|
||
.hero-schedule[data-enabled="true"]::before {
|
||
background: var(--info);
|
||
opacity: 1;
|
||
}
|
||
.hero-schedule-time {
|
||
align-self: stretch;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding-right: 14px;
|
||
border-right: 1px solid var(--line-soft);
|
||
}
|
||
.hero-schedule-time b {
|
||
color: var(--info-ink);
|
||
font-size: 11px;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.hero-schedule-time span {
|
||
margin-top: 4px;
|
||
color: var(--muted);
|
||
font: 12px/1.4 var(--mono);
|
||
}
|
||
.hero-schedule-main { min-width: 0; }
|
||
.hero-schedule-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.hero-schedule-title h3 {
|
||
min-width: 0;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.hero-schedule-main > p {
|
||
margin: 4px 0 8px;
|
||
color: var(--muted);
|
||
font-size: 12.5px;
|
||
}
|
||
.hero-schedule-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.hero-schedule-dialog {
|
||
width: min(760px, 100%);
|
||
padding: 0;
|
||
overflow-x: hidden;
|
||
}
|
||
.hero-schedule-dialog-head {
|
||
padding: 24px 26px 20px;
|
||
border-bottom: 1px solid var(--line);
|
||
background:
|
||
radial-gradient(circle at 90% -40%, var(--info-wash), transparent 240px),
|
||
var(--surface-lift);
|
||
}
|
||
.hero-schedule-kicker {
|
||
color: var(--info-ink);
|
||
font-size: 10.5px;
|
||
font-weight: 750;
|
||
letter-spacing: .1em;
|
||
text-transform: uppercase;
|
||
}
|
||
.hero-schedule-dialog-head h2 {
|
||
margin-top: 4px;
|
||
font-size: 21px;
|
||
letter-spacing: -.025em;
|
||
}
|
||
.hero-schedule-dialog-head p { margin-bottom: 0; }
|
||
.hero-schedule-dialog > .fields,
|
||
.hero-schedule-dialog > .schedule-days,
|
||
.hero-schedule-dialog > .schedule-options,
|
||
.hero-schedule-dialog > .check {
|
||
margin-right: 26px;
|
||
margin-left: 26px;
|
||
}
|
||
.schedule-frequency {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin: 20px 26px;
|
||
}
|
||
.schedule-frequency button {
|
||
display: block;
|
||
height: auto;
|
||
min-height: 68px;
|
||
padding: 11px 13px;
|
||
text-align: left;
|
||
white-space: normal;
|
||
}
|
||
.schedule-frequency button b,
|
||
.schedule-frequency button span {
|
||
display: block;
|
||
}
|
||
.schedule-frequency button span {
|
||
margin-top: 4px;
|
||
color: var(--quiet);
|
||
font-size: 11.5px;
|
||
font-weight: 500;
|
||
}
|
||
.schedule-frequency button[aria-pressed="true"] {
|
||
border-color: var(--info);
|
||
background: var(--info-wash);
|
||
color: var(--info-ink);
|
||
}
|
||
.schedule-frequency button[aria-pressed="true"] span { color: var(--muted); }
|
||
.schedule-days {
|
||
margin-top: 18px;
|
||
padding: 16px;
|
||
border: 1px solid var(--line-soft);
|
||
border-radius: 10px;
|
||
background: var(--surface-lift);
|
||
}
|
||
.schedule-days-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.schedule-days-head > b { font-size: 12.5px; }
|
||
.schedule-days-head > div {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-left: auto;
|
||
}
|
||
.schedule-days-head button {
|
||
height: 28px;
|
||
padding: 0 8px;
|
||
border-color: transparent;
|
||
background: none;
|
||
color: var(--muted);
|
||
font-size: 11.5px;
|
||
}
|
||
.schedule-day-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
gap: 6px;
|
||
}
|
||
.schedule-day-grid button {
|
||
width: 100%;
|
||
height: 38px;
|
||
padding: 0;
|
||
color: var(--muted);
|
||
}
|
||
.schedule-day-grid button[aria-pressed="true"] {
|
||
border-color: var(--accent);
|
||
background: var(--accent-wash);
|
||
color: var(--accent-ink);
|
||
}
|
||
.schedule-options {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 2fr) minmax(140px, 1fr);
|
||
align-items: start;
|
||
gap: 22px;
|
||
margin-top: 20px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid var(--line-soft);
|
||
}
|
||
.schedule-option-label {
|
||
display: block;
|
||
margin-bottom: 3px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.schedule-placement-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 6px 14px;
|
||
}
|
||
.hero-schedule-dialog > .check { margin-top: 8px; }
|
||
.hero-schedule-dialog > .dialog-actions {
|
||
margin-top: 8px;
|
||
padding: 18px 26px;
|
||
border-top: 1px solid var(--line);
|
||
background: var(--surface-lift);
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.hero-pin {
|
||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||
}
|
||
.hero-pin > button:last-child { grid-column: 3; }
|
||
.hero-schedule {
|
||
grid-template-columns: 78px minmax(0, 1fr);
|
||
gap: 12px;
|
||
}
|
||
.hero-schedule-actions {
|
||
grid-column: 1 / -1;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--line-soft);
|
||
}
|
||
.hero-schedule-actions button { flex: 1 1 0; }
|
||
.hero-schedule-dialog-head { padding: 22px 20px 18px; }
|
||
.hero-schedule-dialog > .fields,
|
||
.hero-schedule-dialog > .schedule-days,
|
||
.hero-schedule-dialog > .schedule-options,
|
||
.hero-schedule-dialog > .check {
|
||
margin-right: 20px;
|
||
margin-left: 20px;
|
||
}
|
||
.schedule-frequency {
|
||
gap: 6px;
|
||
margin: 16px 20px;
|
||
}
|
||
.schedule-frequency button {
|
||
min-width: 0;
|
||
min-height: 62px;
|
||
padding: 9px;
|
||
}
|
||
.schedule-frequency button span { display: none; }
|
||
.schedule-days-head { align-items: flex-start; }
|
||
.schedule-days-head > div { flex-wrap: wrap; justify-content: flex-end; }
|
||
.schedule-day-grid { gap: 4px; }
|
||
.schedule-day-grid button { min-width: 0; padding: 0 3px; font-size: 11px; }
|
||
.schedule-options { grid-template-columns: 1fr; }
|
||
.schedule-placement-grid { grid-template-columns: 1fr; }
|
||
.hero-schedule-dialog > .dialog-actions { padding: 16px 20px max(16px, var(--safe-bottom)); }
|
||
}
|
||
|
||
/* ---------- tablet and touch refinement ---------- */
|
||
|
||
/* iPad is an operating surface, not a large phone. Cards gain enough separation to be
|
||
parsed at arm's length, but data remains dense and two-column where portrait width can
|
||
genuinely support it. */
|
||
@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px) {
|
||
.page-head {
|
||
margin-bottom: 24px;
|
||
}
|
||
.page-head h1 {
|
||
font-size: 26px;
|
||
line-height: 1.2;
|
||
}
|
||
.page-head p {
|
||
max-width: 72ch;
|
||
font-size: 14px;
|
||
}
|
||
.card {
|
||
padding: 20px 22px;
|
||
border-color: rgba(99, 110, 123, .42);
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 0 rgba(255, 255, 255, .025), 0 14px 40px rgba(0, 0, 0, .14);
|
||
}
|
||
.card + .card,
|
||
.grid + .card,
|
||
.card + .grid,
|
||
.grid + .grid {
|
||
margin-top: 20px;
|
||
}
|
||
.grid {
|
||
gap: 20px;
|
||
}
|
||
.card-head {
|
||
align-items: flex-start;
|
||
margin-bottom: 18px;
|
||
}
|
||
.card-head h2 {
|
||
font-size: 15.5px;
|
||
}
|
||
.card-head p {
|
||
margin-top: 4px;
|
||
font-size: 13px;
|
||
}
|
||
.card-foot {
|
||
margin-top: 18px;
|
||
padding-top: 16px;
|
||
}
|
||
.tiles {
|
||
gap: 14px;
|
||
}
|
||
.tile {
|
||
min-height: 112px;
|
||
padding: 16px 17px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(145deg, var(--surface-lift), var(--surface));
|
||
}
|
||
.tile b {
|
||
font-size: 27px;
|
||
}
|
||
.filters {
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
}
|
||
.filters .field {
|
||
flex: 1 1 calc(50% - 6px);
|
||
max-width: none;
|
||
}
|
||
.filters .field.grow {
|
||
flex-basis: 100%;
|
||
}
|
||
.list-row {
|
||
min-height: 64px;
|
||
padding: 14px 20px;
|
||
}
|
||
.list-item {
|
||
min-height: 60px;
|
||
padding: 13px 0;
|
||
}
|
||
/* The column collapse itself is width-driven and lives beside the table's own rules;
|
||
what a touch device needs on top of it is room to scroll and controls it can reach. */
|
||
.logview {
|
||
height: min(66dvh, 720px);
|
||
}
|
||
.logbar-actions {
|
||
margin-left: 0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.page-head-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: 14px;
|
||
}
|
||
.page-head-row .page-head-actions {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
.card {
|
||
padding: 17px 18px;
|
||
border-radius: 11px;
|
||
}
|
||
.card-head {
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
.card-head-actions {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
.card-foot > button,
|
||
.card-foot > .btn {
|
||
flex: 1 1 auto;
|
||
}
|
||
.tile {
|
||
min-width: 0;
|
||
min-height: 104px;
|
||
padding: 14px;
|
||
background: linear-gradient(145deg, var(--surface-lift), var(--surface));
|
||
}
|
||
.tile b {
|
||
font-size: 23px;
|
||
}
|
||
.tile span {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.filters .field,
|
||
.filters .field.grow {
|
||
flex: 1 1 100%;
|
||
max-width: none;
|
||
}
|
||
.filters .filter-actions {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
.list-item,
|
||
.list-row,
|
||
.list-main {
|
||
align-items: flex-start;
|
||
}
|
||
.list-item,
|
||
.list-row {
|
||
flex-wrap: wrap;
|
||
}
|
||
.list-item .list-actions,
|
||
.list-row .list-actions {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
padding-left: 46px;
|
||
}
|
||
.kv-row {
|
||
align-items: flex-start;
|
||
}
|
||
.dialog {
|
||
width: 100%;
|
||
max-height: min(88dvh, 720px);
|
||
padding: 22px;
|
||
border-radius: 16px;
|
||
}
|
||
.scrim {
|
||
align-items: end;
|
||
padding: 12px max(12px, var(--safe-right)) max(12px, var(--safe-bottom)) max(12px, var(--safe-left));
|
||
}
|
||
.dialog-actions > button {
|
||
flex: 1 1 0;
|
||
}
|
||
.bell-panel {
|
||
position: fixed;
|
||
top: calc(var(--top) + 8px);
|
||
right: max(12px, var(--safe-right));
|
||
left: max(12px, var(--safe-left));
|
||
width: auto;
|
||
max-height: calc(100dvh - var(--top) - var(--safe-bottom) - 20px);
|
||
border-radius: 14px;
|
||
}
|
||
.bell-list {
|
||
max-height: calc(100dvh - var(--top) - var(--safe-bottom) - 120px);
|
||
}
|
||
.toasts {
|
||
right: max(12px, var(--safe-right));
|
||
bottom: max(12px, var(--safe-bottom));
|
||
left: max(12px, var(--safe-left));
|
||
max-width: none;
|
||
}
|
||
}
|
||
|
||
/* Coarse pointers need a target, not merely larger text. This is deliberately scoped to
|
||
tablet widths so a desktop browser with touch support keeps the compact operations UI. */
|
||
@media (pointer: coarse) and (max-width: 1400px) {
|
||
button,
|
||
.btn {
|
||
min-width: 44px;
|
||
height: 44px;
|
||
padding-right: 15px;
|
||
padding-left: 15px;
|
||
}
|
||
button[data-size="sm"] {
|
||
min-width: 38px;
|
||
height: 38px;
|
||
padding-right: 11px;
|
||
padding-left: 11px;
|
||
}
|
||
.topbar .rail-toggle,
|
||
.topbar .bell-button,
|
||
.topbar .topbar-status,
|
||
.topbar .account-trigger {
|
||
width: 44px;
|
||
height: 44px;
|
||
padding: 0;
|
||
}
|
||
.topbar .bell,
|
||
.topbar .account-menu {
|
||
height: 44px;
|
||
}
|
||
.topbar .omni-input {
|
||
height: 44px;
|
||
}
|
||
.rail a {
|
||
min-height: 46px;
|
||
padding: 10px 12px;
|
||
border-radius: 9px;
|
||
font-size: 14px;
|
||
}
|
||
.rail-head {
|
||
min-height: 42px;
|
||
padding: 12px;
|
||
}
|
||
input[type="text"],
|
||
input[type="password"],
|
||
input[type="search"],
|
||
input[type="number"],
|
||
input[type="date"],
|
||
input[type="time"],
|
||
input[type="datetime-local"],
|
||
input[type="url"],
|
||
select {
|
||
height: 44px;
|
||
font-size: 16px;
|
||
}
|
||
select {
|
||
background-position: calc(100% - 16px) 20px, calc(100% - 11px) 20px;
|
||
}
|
||
textarea {
|
||
min-height: 104px;
|
||
font-size: 16px;
|
||
}
|
||
.check {
|
||
min-height: 52px;
|
||
padding-top: 14px;
|
||
padding-bottom: 14px;
|
||
}
|
||
.check .switch {
|
||
width: 40px;
|
||
height: 24px;
|
||
flex-basis: 40px;
|
||
}
|
||
.check .switch::after {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
.check input:checked + .switch::after {
|
||
transform: translateX(16px);
|
||
}
|
||
.segments {
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
.segments button {
|
||
height: 38px;
|
||
}
|
||
.crumb,
|
||
.crumbs a,
|
||
.table-row-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 36px;
|
||
}
|
||
}
|
||
|
||
/* Short laptop screens benefit from vertical density, while retaining the same readable
|
||
widths and full-size pointer targets. */
|
||
@media (pointer: fine) and (min-width: 821px) and (max-height: 800px) {
|
||
.page {
|
||
padding-top: 20px;
|
||
padding-bottom: 52px;
|
||
}
|
||
.page-head {
|
||
margin-bottom: 16px;
|
||
}
|
||
.card {
|
||
padding-top: 14px;
|
||
padding-bottom: 14px;
|
||
}
|
||
.card-head {
|
||
margin-bottom: 11px;
|
||
}
|
||
}
|
||
|
||
@media (hover: none) {
|
||
tbody tr:hover td,
|
||
.list-row:hover,
|
||
.bell-item:hover,
|
||
a.tile:hover {
|
||
background: inherit;
|
||
}
|
||
}
|
||
|
||
/* The notification history's detail row.
|
||
*
|
||
* The drawer is a row inside the table rather than a panel beside it, so the record stays
|
||
* under the row it belongs to while an operator reads down a filtered list. It borrows the
|
||
* log viewer's drawer vocabulary wholesale — the two answer the same shape of question and
|
||
* a second look for it would be a second thing to keep in step. */
|
||
.detail-row > td {
|
||
padding: 0 0 12px;
|
||
background: var(--surface-sunken, transparent);
|
||
}
|
||
.logdrawer-raw h4 {
|
||
margin: 0 0 6px;
|
||
color: var(--quiet);
|
||
font: 700 10px/1 var(--sans);
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
/* The message is the one thing on the page rendered as prose rather than as a field: it is
|
||
the sentence a viewer actually read, and setting it in the mono field type would make it
|
||
look like a value rather than like the notification it is. */
|
||
.notification-body {
|
||
max-width: 70ch;
|
||
margin: 0;
|
||
color: var(--text);
|
||
font: 13px/1.6 var(--sans);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* ---------- runtime ----------
|
||
|
||
The verdict block and the section headings the runtime pages are built from. The verdict
|
||
is deliberately not a `.banner`: a banner is something that has gone wrong and can be
|
||
dismissed, and this is a standing answer that is usually good news. */
|
||
|
||
.subhead {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
margin: 6px 0 0;
|
||
font-size: 11.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
}
|
||
.subhead small {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
letter-spacing: 0;
|
||
text-transform: none;
|
||
color: var(--quiet);
|
||
}
|
||
|
||
.verdict {
|
||
margin: 0 0 16px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--line);
|
||
border-left: 3px solid var(--accent);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-lift);
|
||
}
|
||
.verdict[data-tone="warn"] {
|
||
border-left-color: var(--warn);
|
||
}
|
||
.verdict[data-tone="bad"] {
|
||
border-left-color: var(--danger);
|
||
}
|
||
.verdict-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
flex-wrap: wrap;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.verdict-head svg {
|
||
width: 17px;
|
||
height: 17px;
|
||
color: var(--accent);
|
||
}
|
||
.verdict[data-tone="warn"] .verdict-head svg {
|
||
color: var(--warn);
|
||
}
|
||
.verdict[data-tone="bad"] .verdict-head svg {
|
||
color: var(--danger);
|
||
}
|
||
.verdict-head b {
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
}
|
||
.verdict-notes {
|
||
margin: 11px 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.verdict-notes li {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 9px;
|
||
font-size: 12.5px;
|
||
color: var(--muted);
|
||
}
|