/* ==========================================================================
   Kestrel

   Self-contained on purpose. This fronts every vhost on the box — sites that
   have nothing to do with each other — so it wears nobody's brand: a neutral
   field, one indigo accent, and the system type stack. No shared stylesheet,
   no CDN, no build step, one file.

   Light is the default; dark is either the system preference or an explicit
   choice. The explicit choice is a `data-theme` attribute on <html>, written
   by the toggle in the header, and it always beats the media query — so
   every dark token appears twice below, once per mechanism.

   Two type decisions carry most of the look. Figures are the system sans
   with tabular numerals, so columns of counts sit still. Log material —
   paths, referrers, hosts, status codes — is monospace, because those
   strings are lifted straight out of an access log and should read as what
   they are.
   ========================================================================== */

:root {
    --bg: #f6f7f9;
    --surface: #ffffff;
    --border: #e5e7ee;
    --border-strong: #d4d8e3;
    --text: #171a23;
    --muted: #626a7d;
    --faint: #9aa1b3;
    --accent: #0d9488;
    --accent-soft: rgba(13, 148, 136, 0.09);
    --accent-line: rgba(13, 148, 136, 0.38);
    --on-accent: #0c1614;
    /* Pure green, kept deliberately apart from the teal accent — a delta
       badge and a data line must never read as the same colour. */
    --up: #16a34a;
    --up-soft: rgba(22, 163, 74, 0.1);
    --down: #d6403a;
    --down-soft: rgba(214, 64, 58, 0.09);
    --ghost: #b3b9c9;
    /* The pageviews line on a site's own chart. Indigo because it has to be
       told apart from the teal beside it and from the green and red of a
       delta badge, and those four are most of the colours on the page. */
    --pages: #6366f1;
    /* The requests line — every request in the bucket, which is the ceiling
       the other lines sit under. Pink because a site's own chart now draws it
       alongside the indigo pageviews line, and the two are nested figures
       rather than unrelated ones: same plot, one inside the other, so they
       have to be told apart at a glance. Kept away from the red of a falling
       delta badge, which is filled rather than drawn as a line. */
    --hits: #db2777;
    /* The assistants' line on the split chart, against the teal the people
       are drawn in. Amber: the fifth colour that is not any of the four
       above, and the one that reads as "something else is here" without
       reading as an error. */
    --ai: #c2610c;
    --shadow: 0 1px 2px rgba(23, 26, 35, 0.04), 0 8px 24px rgba(23, 26, 35, 0.04);
    --radius: 10px;
    --radius-sm: 7px;
    /* The row height the two list cards share — Recent activity and AI
       assistants. Set rather than left to the content: both stack two lines
       of different sizes, and a row whose height depends on whether it
       happens to carry a chip cannot be counted into a fixed box. */
    --feed-row: 3.3rem;
    /* Every list card on a site's page is this tall inside, whether it holds
       six feed rows, ten ranked ones, four AI groups or nothing at all. One
       value, so a grid of cards lines up without any of them being measured
       against the others.

       A panel's row is a tenth of it for the same reason a feed's is a sixth:
       a page has to be exactly what the box holds, or the last row of it
       peeks over the fold and the button under it looks like it has already
       been pressed. Report::PAGE is the ten; they change together. */
    --card-box: calc(6 * var(--feed-row));
    --panel-row: calc(var(--card-box) / 10);
    /* The page's side margin and a card's own, as tokens rather than as the
       same number written out twenty times: a phone sets both narrower, and
       every card, row and rail has to move by exactly the same amount or the
       left edge of the page stops being a line. */
    --gutter: 1.25rem;
    --card-pad: 1.15rem;
    --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    color-scheme: light;
}

/* Dark, as the system preference — held off when light was chosen by hand. */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --bg: #0e1015;
        --surface: #171a22;
        --border: #252a37;
        --border-strong: #323848;
        --text: #e7e9f0;
        --muted: #949bad;
        --faint: #626a7e;
        --accent: #2dd4bf;
        --accent-soft: rgba(45, 212, 191, 0.12);
        --accent-line: rgba(45, 212, 191, 0.45);
        --on-accent: #0c1614;
        --up: #4ade80;
        --up-soft: rgba(74, 222, 128, 0.13);
        --down: #ff7b72;
        --down-soft: rgba(255, 123, 114, 0.12);
        --ghost: #565d70;
        --pages: #a5b4fc;
        --hits: #f472b6;
        --ai: #fbbf24;
        --shadow: none;
        color-scheme: dark;
    }
}

/* Dark, as the explicit choice. Same values as above, deliberately. */
:root[data-theme="dark"] {
    --bg: #0e1015;
    --surface: #171a22;
    --border: #252a37;
    --border-strong: #323848;
    --text: #e7e9f0;
    --muted: #949bad;
    --faint: #626a7e;
    --accent: #2dd4bf;
    --accent-soft: rgba(45, 212, 191, 0.12);
    --accent-line: rgba(45, 212, 191, 0.45);
    --on-accent: #0c1614;
    --up: #4ade80;
    --up-soft: rgba(74, 222, 128, 0.13);
    --down: #ff7b72;
    --down-soft: rgba(255, 123, 114, 0.12);
    --ghost: #565d70;
    --pages: #a5b4fc;
    --hits: #f472b6;
    --ai: #fbbf24;
    --shadow: none;
    color-scheme: dark;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font-family: var(--sans);
    font-size: 15px;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

a { color: var(--accent); }

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 3px;
}

/* The viewport is covered to the edges of the glass — see viewport-fit in
   head.php — so a phone held sideways has a notch inside the page rather
   than beside it, and the gutter has to clear it where there is one. */
.wrap {
    max-width: 1200px;
    margin: 0 auto;
    padding-left: max(var(--gutter), env(safe-area-inset-left));
    padding-right: max(var(--gutter), env(safe-area-inset-right));
}

/* Counts and figures hold their columns. */
.num { font-variant-numeric: tabular-nums; }

/* --------------------------------------------------------------------- nav

   One bar carrying everything this dashboard can be asked, stuck to the top
   so it is still there after you have scrolled to the number you came for.
   What it replaced was four stacked bands — brand, heading, selects, chips —
   which on a phone was a fifth of the screen and all of it gone at the first
   flick.

   Two rows. Where you are: the bird home, the site as a heading that opens
   into the list of them, the span on screen, and who is on it right now. What
   you asked: the range, then the order and the comparison. On a phone the
   second row becomes one rail that scrolls sideways, with the four everyday
   ranges under a thumb and everything rarer folded into two pills at the end.

   Opaque rather than translucent on purpose. A blurred bar looks better and
   makes itself the containing block for anything fixed inside it — which is
   where the menus go on a phone, so it cannot have one. */

/* The strip above the nav on the public dashboard: what this is, and where to
   go if it sounds useful. Only that page draws it — see views/promo.php.

   Above the nav rather than in the page, because on a phone the page is a
   window that does not scroll and anything inside it would either scroll away
   or take its height out of the one screen there is. Here it costs its own
   height once, on both shapes of screen.

   Hidden from the attribute rather than by removing the element, so the
   decision can be made before first paint by the same inline script that
   picks the theme — a banner somebody closed last week must not flash past
   before a later script gets to it. */
[data-promo="off"] .promo { display: none; }

/* The band runs the full width of the window and the row inside it stays on
   the page's own column, so the rule under it is a line across the top of the
   screen rather than a box floating on one. */
.promo {
    border-bottom: 1px solid var(--border);
    background: var(--accent-soft);
}

.promo__inner {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    max-width: 1200px;
    margin: 0 auto;
    padding: 0.5rem max(var(--gutter), env(safe-area-inset-right))
             0.5rem max(var(--gutter), env(safe-area-inset-left));
}

.promo__text {
    min-width: 0;
    margin: 0;
    color: var(--muted);
    font-size: 0.8rem;
    line-height: 1.4;
}

.promo__cta {
    display: inline-flex;
    flex: none;
    align-items: center;
    margin-left: auto;
    cursor: pointer;
    padding: 0.28rem 0.75rem;
    border: 1px solid var(--accent-line);
    border-radius: 999px;
    background: var(--surface);
    color: var(--accent);
    font-size: 0.78rem;
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
}

.promo__cta:hover {
    border-color: var(--accent);
    background: var(--accent);
    color: var(--surface);
}

/* The button is a link when there is somewhere to link to and the summary of a
   <details> when there is not, so both wear this and the marker a summary
   would otherwise draw is taken off. */
.promo__cta {
    list-style: none;
}

.promo__cta::-webkit-details-marker { display: none; }

.promo__thanks {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.35rem;
    margin: 0 0 0 auto;
    color: var(--accent);
    font-size: 0.78rem;
    font-weight: 600;
    white-space: nowrap;
}

/* ------------------------------------------------------------------- sign-up

   The box behind the banner's button. A <details> like every other menu here,
   so it opens without JavaScript; what makes it a panel over the middle of the
   screen rather than a dropdown under its own button is entirely below.

   `static` rather than the `relative` a menu gets, because the panel is
   positioned against the window and a positioned ancestor would pull it back
   under the button. */
.signup {
    position: static;
    flex: none;
    margin-left: auto;
}

/* The dim. Drawn as the <details>'s own ::before so that a press on it is
   reported as a press on the <details> — which is exactly what the outside-
   click handler in views/head.php already looks for, and is why closing this
   by pressing away from it needs no code of its own. */
.signup[open]::before {
    position: fixed;
    z-index: 60;
    inset: 0;
    background: rgba(9, 11, 16, 0.45);
    content: "";
}

.signup__panel {
    position: fixed;
    z-index: 61;
    top: 50%;
    left: 50%;
    width: min(23rem, calc(100vw - 2rem));
    max-height: calc(100dvh - 2rem);
    overflow-y: auto;
    padding: 1.35rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow);
    transform: translate(-50%, -50%);
}

.signup > .promo__cta { margin-left: 0; }

.signup__title {
    margin: 0 0 0.4rem;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.signup__blurb {
    margin: 0 0 0.9rem;
    color: var(--muted);
    font-size: 0.82rem;
    line-height: 1.5;
}

.signup__bad {
    margin: 0 0 0.7rem;
    color: var(--down);
    font-size: 0.8rem;
}

.signup__label {
    display: block;
    margin-bottom: 0.3rem;
    color: var(--muted);
    font-size: 0.76rem;
    font-weight: 600;
}

.signup__form input[type="email"] {
    width: 100%;
    margin-bottom: 0.7rem;
    padding: 0.5rem 0.65rem;
}

.signup__form button.primary {
    width: 100%;
    padding: 0.5rem;
}

/* Off the page rather than `display: none`. A form-filler skips what is not
   displayed and writes into what is, so a trap has to be in the layout to be
   walked into — and out of everybody else's way to be a trap. */
.signup__trap {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.signup__fine {
    margin: 0.8rem 0 0;
    color: var(--faint);
    font-size: 0.72rem;
    line-height: 1.45;
}

.promo__x {
    display: inline-flex;
    flex: none;
    padding: 0.25rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--faint);
    cursor: pointer;
}

.promo__x:hover { color: var(--text); }

.nav {
    position: sticky;
    top: 0;
    z-index: 30;
    border-bottom: 1px solid var(--border);
    background: var(--bg);
}

.nav__inner { padding-block: 0.55rem 0.5rem; }

.nav__row {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    min-width: 0;
}

.nav__row--what {
    gap: 0.35rem;
    margin-top: 0.5rem;
}

.nav__spacer { flex: 1 1 auto; }

/* Grows on a wide screen to push the settings to the far end; on a phone,
   where the rail overflows and nothing can grow, it is the gap that separates
   the ranges from them. */
.nav__gap { flex: 1 1 auto; min-width: 0.55rem; }

.brand {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.45rem;
    color: var(--text);
    font-size: 0.9rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    text-decoration: none;
}

.brand .mark { display: block; color: var(--accent); }
.brand:hover .brand__name { color: var(--accent); }

/* The heading and the range it covers, as one block against the brand. */
.nav__id {
    display: flex;
    min-width: 0;
    flex-direction: column;
    padding-left: 0.65rem;
    border-left: 1px solid var(--border);
}

.navtitle {
    /* Blockified, because this is not always the same element. On the public
       overview it is a <span> — the page's real heading is the product's name
       and this is a control that says which site is on screen — and an inline
       box ignores the three properties below it, which is a title that runs
       under the bird instead of ending in an ellipsis. See views/sitepick.php. */
    display: block;
    margin: 0;
    overflow: hidden;
    color: var(--text);
    font-size: 1.02rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Read, not seen.
   The public page's <h1> and nothing else. What the page is called — the
   product, not the slice of it on screen — is a thing a search engine, an
   assistant and a screen reader all ask for and a person looking at the page
   does not: they can see it is a dashboard, and the nav already says whose
   traffic. So it is said once, in the element those three read it from, and
   taken out of the layout rather than out of the document.

   Off the page rather than `display: none`, for the same reason .signup__trap
   is: what is not displayed is not read out either, and a heading a screen
   reader skips is no heading at all. */
.offscreen {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    border: 0;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* One line of running text rather than a row of boxes: the pieces are a
   sentence about what is on screen, and a flex gap between "3" and "sites"
   would read as two facts. */
.nav__meta {
    margin: 0;
    overflow: hidden;
    color: var(--faint);
    font-size: 0.72rem;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav__meta .live {
    margin-left: 0.3rem;
    font-size: inherit;
    vertical-align: baseline;
}

/* The heading is the switcher. Same question, so it is one control. */
.sitepick > summary {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    max-width: 100%;
    margin-left: -0.4rem;
    padding: 0.05rem 0.4rem;
    border-radius: var(--radius-sm);
    color: var(--faint);
    cursor: pointer;
    list-style: none;
}

.sitepick > summary::-webkit-details-marker { display: none; }
.sitepick > summary:hover { background: var(--accent-soft); }
.sitepick > summary:hover .navtitle,
.sitepick[open] > summary { color: var(--accent); }
.sitepick > summary .icon { flex: none; transition: transform 0.15s ease; }
.sitepick[open] > summary .icon { transform: rotate(180deg); }

/* The three theme states as one segmented control. A two-way toggle can only
   ever offer the other one, which leaves no way back to following the system
   once you have chosen. Which segment is lit comes from an attribute the
   pre-paint script in head.php sets, so it is never briefly wrong; with no
   script at all nothing is stored, the system decides, and the last rule
   below lights that one. */
.themeset {
    display: inline-flex;
    flex: none;
    padding: 2px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
}

.themeset__btn {
    display: inline-flex;
    width: 26px;
    height: 23px;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: 999px;
    background: none;
    color: var(--faint);
    cursor: pointer;
}

.themeset__btn:hover { color: var(--text); }

:root[data-choice="light"] [data-theme-set="light"],
:root[data-choice="dark"] [data-theme-set="dark"],
:root[data-choice="system"] [data-theme-set="system"],
:root:not([data-choice]) [data-theme-set="system"] {
    background: var(--accent-soft);
    color: var(--accent);
}

.nav__out { display: flex; }

/* Sign out: quiet, icon-sized, borderless until hover. */
.iconbtn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.35rem;
    border: 1px solid transparent;
    border-radius: 999px;
    background: none;
    color: var(--faint);
    cursor: pointer;
    font: inherit;
}

.iconbtn:hover {
    border-color: var(--border);
    background: var(--surface);
    color: var(--text);
}

.iconbtn svg { display: block; }

/* ---------------------------------------------------------- form controls

   The plain versions, for the gate, the custom dates and the load-more
   button. The nav dresses its own buttons as chips below, which override
   these by carrying a class. Nothing here is a <select> any more: every list
   this dashboard offers is a menu of links in its own hand, down to the two
   settings that were the last native pickers in the nav. */

input[type="date"],
input[type="password"],
button {
    padding: 0.4rem 0.6rem;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--text);
    font: inherit;
    font-size: 0.85rem;
}

input[type="date"]:hover { border-color: var(--faint); }

button { cursor: pointer; font-weight: 600; }

/* The label flips with the theme: white on light's deep teal, near-black on
   dark's bright one — a filled control has to carry its own contrast. */
button.primary {
    border-color: var(--accent);
    background: var(--accent);
    color: var(--on-accent);
    font-weight: 650;
}

/* --------------------------------------------------------------------- chips

   One shape for one job: a link, or a menu you press. It used to share the
   rail with a pill — a native <select> wearing a border — and the settings
   that pill held are menus of links now, so there is one thing here rather
   than two that had to be kept looking alike. */

/* Never shrinks. Inside a rail that scrolls, a chip row allowed to narrow
   spills its chips out of its own box and under whatever comes next. */
.chips {
    display: flex;
    flex: none;
    align-items: center;
    gap: 0.3rem;
}

.chip {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.3rem;
    padding: 0.3rem 0.7rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: var(--muted);
    cursor: pointer;
    font-size: 0.78rem;
    line-height: 1.35;
    list-style: none;
    text-decoration: none;
    white-space: nowrap;
    transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}

.chip::-webkit-details-marker { display: none; }
.chip:hover { border-color: var(--accent-line); color: var(--accent); }

.chip.is-on {
    border-color: var(--accent-line);
    background: var(--accent-soft);
    color: var(--accent);
    font-weight: 600;
}

.chip .icon { flex: none; }
.chip--menu { padding-inline: 0.55rem 0.5rem; }
.chip--menu .icon:last-child { transition: transform 0.15s ease; }
details[open] > .chip--menu .icon:last-child { transform: rotate(180deg); }

/* The two settings, at the end of the rail. A chip that wears what it is set
   to and opens a list of the others, which is the range's own control in a
   different hat — and the reason neither of them is a <select> any more. The
   value is clamped rather than allowed to push the rail: "Same period last
   year" is a sentence, and the chip only has to say enough of it to be
   recognised, with the whole of it in the panel under. */
/* Opened from the end of the rail, so it hangs from the chip's right edge
   rather than running off the side of the window. Ignored inside the sheet,
   where the panel is static. */
.setting > .menu__panel {
    right: 0;
    left: auto;
}

.setting > summary .chip__text {
    display: inline-block;
    overflow: hidden;
    max-width: 10rem;
    text-overflow: ellipsis;
    vertical-align: bottom;
}

/* The dates are a form only because two of them change together; it takes no
   room of its own so the row and the sheet lay out as they did before it. */
.opts__dates { display: contents; }

.dates {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.3rem;
}

.dates__to { color: var(--muted); font-size: 0.78rem; }

/* Rounded to match everything beside them. A date input draws its own guts,
   so this is only the frame. */
.dates input[type="date"] {
    padding: 0.24rem 0.5rem;
    border-radius: 999px;
    font-size: 0.78rem;
}

.opts .primary {
    flex: none;
    padding: 0.32rem 0.9rem;
    border-radius: 999px;
    font-size: 0.78rem;
}

/* Reset takes no room of its own either, so the rail lays out as it did
   before there was anything to reset. */
.opts__reset { display: contents; }

/* Quieter than the settings it undoes: this is the control you want to be
   able to find and never to press by accident, so it wears the same shape as
   its neighbours in the muted colour and only picks up the warning red under
   a finger. Weight is set back because a <button> is 600 here and every chip
   beside this one is a link at 400. */
.chip--reset {
    color: var(--faint);
    font-weight: 400;
}

.chip--reset:hover {
    border-color: var(--down);
    background: var(--down-soft);
    color: var(--down);
}

/* ------------------------------------------------------------------- menus

   <details> does the opening, so the sites and the rarer ranges are reachable
   with JavaScript switched off; head.php only adds closing on Escape and on a
   click elsewhere. On a phone the panel comes up from the bottom of the
   screen, which is both where a thumb already is and the one position that
   cannot be clipped by the rail it was opened from. */

.menu { position: relative; flex: none; }

.menu__panel {
    position: absolute;
    z-index: 40;
    top: calc(100% + 0.4rem);
    left: 0;
    min-width: 12rem;
    max-height: 70vh;
    padding: 0.35rem;
    overflow-y: auto;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: 0 14px 34px rgba(12, 15, 22, 0.16);
}

.menu__panel--wide { min-width: 14rem; }

.menu__label {
    margin: 0.35rem 0.55rem 0.15rem;
    color: var(--faint);
    font-size: 0.66rem;
    font-weight: 650;
    letter-spacing: 0.07em;
    text-transform: uppercase;
}

.menu__item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1.5rem;
    padding: 0.42rem 0.55rem;
    border-radius: var(--radius-sm);
    color: var(--text);
    font-size: 0.85rem;
    text-decoration: none;
}

.menu__item:hover { background: var(--accent-soft); color: var(--accent); }
.menu__item.is-on { color: var(--accent); font-weight: 600; }
.menu__tick { color: var(--accent); }

/* The settings are a menu only on a phone. On a wide screen the panel is the
   row itself and the pill that would open it is not drawn — one set of
   markup, one form, laid out twice. */
.opts { position: static; }
.opts > summary { display: none; }

/* A <details> that is never closed on a wide screen. Modern engines wrap the
   contents in ::details-content and hide them with content-visibility rather
   than display, which no rule on the panel itself can override — so the
   override goes where the engine put the switch. */
.opts::details-content {
    content-visibility: visible;
    block-size: auto;
}

.opts > .menu__panel {
    position: static;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
    max-height: none;
    padding: 0;
    overflow: visible;
    border: 0;
    background: none;
    box-shadow: none;
}

/* --------------------------------------------------------------- live marks */

/* A mark for something that is still moving — the nav's freshness badge, and
   the header of each feed. The dot does the breathing, which is the part that
   says "still open"; the word beside it says what is. */
.live {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    color: var(--muted);
    font-size: 0.78rem;
    white-space: nowrap;
}

.live::before {
    content: "";
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--up);
    animation: live 2.4s ease-in-out infinite;
}

@keyframes live {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.25; }
}

/* Tabular, so the badge does not twitch sideways as it ticks over. */
.live__ago { font-variant-numeric: tabular-nums; }

/* Who is on the site right now — the last five minutes of the log, breathing
   while anyone is, still and grey while nobody is. */
.livecount {
    display: inline-flex;
    align-items: center;
    gap: 0.38rem;
    color: var(--up);
    font-size: 0.78rem;
    font-weight: 600;
    white-space: nowrap;
}

.livecount i {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--up);
    animation: live 2.4s ease-in-out infinite;
}

.livecount b { font-weight: 650; }

.livecount.is-zero {
    color: var(--faint);
    font-weight: 500;
}

.livecount.is-zero i {
    background: var(--faint);
    animation: none;
}

/* In the nav it is a button: thirty seconds is a long time to stand in a
   doorway wondering whether anybody is on your site, and pressing it asks
   now. It breathes faster while the answer is on its way, so a press that
   changes no digits still looks like it did something. */
.livecount--tap {
    flex: none;
    padding: 0.28rem 0.62rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    cursor: pointer;
    font-family: inherit;
    font-size: 0.78rem;
}

.livecount--tap:hover { border-color: var(--accent-line); }
.livecount--tap.is-busy i { animation-duration: 0.55s; }

/* ------------------------------------------------------------------- page */

.page { padding-top: 1.35rem; }

/* ------------------------------------------------------------------- cards */

.card {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow);
}

.card__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.35rem 0.75rem;
    padding: 0.95rem var(--card-pad) 0;
}

.card__title {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 650;
    letter-spacing: -0.01em;
}

.legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.9rem;
    margin-left: auto;
    color: var(--muted);
    font-size: 0.76rem;
}

.legend i {
    display: inline-block;
    width: 16px;
    height: 0;
    margin-right: 0.4rem;
    border-top: 3px solid var(--accent);
    border-radius: 2px;
    vertical-align: middle;
}

/* Where the figure this stands for is drawn as columns rather than as a line,
   the swatch stops being a length of line. Same trade as .chartkey__mark--bar
   under the cards, for the same reason: a legend is only worth reading if the
   mark beside the word is the mark on the plot. */
.legend i.is-bar {
    width: 8px;
    height: 8px;
    border-top: 0;
    border-radius: 2px;
    background: var(--accent);
    opacity: 0.82;
}

.legend i.is-pages { border-top-color: var(--pages); }
.legend i.is-hits { border-top-color: var(--hits); }
.legend i.is-prev { border-top: 2px dashed var(--ghost); }
.legend i.is-ai { border-top-color: var(--ai); }

/* Split, each entry carries the period's figure as well as the name, so the
   caption under the chart is also the reading the toggle was pressed for. */
.legend b {
    color: var(--text);
    font-weight: 650;
    font-variant-numeric: tabular-nums;
}

/* The entry that is a figure and not a line: crawlers, which are what the
   People figure had taken off it. No swatch, and quieter than the two that
   are drawn, so the legend still reads as naming two lines. */
.legend .legend__off { color: var(--faint); }
.legend .legend__off b { color: var(--muted); font-weight: 600; }

/* ----------------------------------------------------------------- metrics */

.metrics {
    display: flex;
    flex-wrap: wrap;
    padding: 0.9rem var(--card-pad) 0.2rem;
}

.metric {
    flex: 1 1 9rem;
    min-width: 8.5rem;
    padding: 0.2rem var(--card-pad) 0.55rem 0;
}

.metric + .metric {
    padding-left: var(--card-pad);
    border-left: 1px solid var(--border);
}

.metric__label {
    display: block;
    margin-bottom: 0.15rem;
    color: var(--muted);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.metric__value {
    font-size: 1.55rem;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.02em;
    line-height: 1.2;
    white-space: nowrap;
}

.badge {
    display: inline-block;
    margin-left: 0.45rem;
    padding: 0.1rem 0.42rem;
    border-radius: 999px;
    font-size: 0.74rem;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
    vertical-align: 0.22em;
    white-space: nowrap;
}

.badge.is-up { background: var(--up-soft); color: var(--up); }
.badge.is-down { background: var(--down-soft); color: var(--down); }
.badge.is-flat { background: var(--accent-soft); color: var(--muted); }

/* ------------------------------------------------------------------- chart */

.chart {
    display: block;
    width: 100%;
    height: auto;
    padding: 0.5rem 0.5rem 0.2rem;
}

.chart__grid { stroke: var(--border); stroke-width: 1; }
.chart__area { fill: var(--accent); opacity: 0.09; }
.chart__line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; }
.chart__prev { fill: none; stroke: var(--ghost); stroke-width: 1.5; stroke-dasharray: 4 3; }

/* The same figure as .chart__line, drawn as one column per bucket instead —
   the reader's choice, and the only thing on this plot that is. It is here
   because a line needs somewhere to go before it can be read, and on a plot
   scaled to the request line above it the visitor line has nowhere: it runs
   along the floor whether the day doubled or halved. Asked for, the columns
   take the left-hand axis and the requests are given one of their own up the
   right — see views/chart.php for how the colouring keeps two scales on one
   plot from being read as one.

   A shade off solid, so the dashed comparison, the assistants' line and the
   request line crossing down out of its own scale all stay legible where they
   cross a tall one. Under all three for the same reason — see the order they
   are drawn in — and deaf to the pointer, so the invisible columns carrying
   the tooltips keep every pixel of the plot they had when this was a line. */
.chart__bars { pointer-events: none; }
.chart__bar { fill: var(--accent); opacity: 0.82; }

/* Pageviews: a line, no fill. Two filled areas over each other go muddy, and
   visitors is the one the card is named after — it keeps the fill. */
.chart__pages { fill: none; stroke: var(--pages); stroke-width: 1.75; stroke-linejoin: round; }

/* The split chart's second line, and the only other thing on that plot. Same
   weight as the pageviews line and for the same reason: the filled one is the
   answer, this is what was taken off it to get there. Crawlers are counted
   into neither and drawn as nothing — the gap between these two is what the
   chart is for, and a third line is a third thing to rule out first. */
.chart__ai { fill: none; stroke: var(--ai); stroke-width: 1.75; stroke-linejoin: round; }

/* The request line — every request on the site, or the ones the people made
   where the chart is split. On a card it is the only thing over the visitor
   fill; on a site's own page it is the third line, above the pageviews it
   contains. Its own colour for exactly that reason: requests and pageviews
   are the same traffic counted with the static files in and out, and two
   nested figures a centimetre apart cannot share a hue. Thinnest of the
   three, because it is the ceiling rather than the answer. */
.chart__hits { fill: none; stroke: var(--hits); stroke-width: 1.5; stroke-linejoin: round; }
.chart__hit { fill: transparent; }
.chart__hit:hover { fill: var(--accent); opacity: 0.07; }
.chart__label { fill: var(--faint); font-size: 11px; font-family: var(--sans); }

/* Except where there are two axes on the plot, which is the one arrangement
   here that a reader can get wrong: the columns are read against the left,
   the request line against the right, and nothing about a pair of numbers a
   plot apart says which belongs to which. So each side takes the colour of
   the shape it measures — teal for the columns, pink for the line — and the
   swatch a reader needs is the axis itself. Weighted up a little because a
   coloured 11px number is a fainter thing than a grey one at the same size.

   Only in that arrangement. On a plot with one scale the gridlines belong to
   every line crossing them, and a coloured axis would be claiming otherwise. */
.chart__label--lead { fill: var(--accent); font-weight: 600; }
.chart__label--hits { fill: var(--hits); font-weight: 600; }

.chart--compact { padding: 0.35rem 0.35rem 0; }
.chart--compact .chart__label { font-size: 13px; }

/* The site page draws its chart twice, wide and nearly square, and shows
   whichever fits the screen. This is the default half of that: the phone's
   copy is not drawn until the media query at the foot of this file asks for
   it, and the wide one is hidden there in exchange. The strip that carries
   the same line into the other sections is drawn there and nowhere else — a
   wide screen has the chart and the panels on one page already. */
.chartbox--phone,
.chart--card,
.spark { display: none; }
.chart--phone { padding: 0.35rem 0.4rem 0.1rem; }
.chart--phone .chart__label { font-size: 13px; }

/* ---------------------------------------------------------- chart: the key

   Under a card's chart, and only there. A site's own chart names its lines in
   the readout that follows the pointer; a card has no readout, and the phone's
   copy of a card has no <title> tooltips either — so the two shapes that are
   read at arm's length across a page of them say it in words instead.

   Quiet enough to stay under the chart rather than beside the figures above
   it: this is a caption on a picture, not a fourth row of numbers. */
.chartkey {
    display: flex;
    flex-wrap: wrap;
    gap: 0.15rem 0.85rem;
    padding: 0.1rem var(--card-pad) 0.4rem;
    color: var(--muted);
    font-size: 0.66rem;
    font-weight: 600;
    letter-spacing: 0.03em;
}

/* After the rule above and not in the group of hidden shapes further up: both
   selectors weigh the same, so whichever is written last wins, and a `display`
   set on the base class after the hide put the phone's copy of the key back on
   a desk — two keys under one chart, the second belonging to a chart that is
   not there. */
.chartkey--card { display: none; }

.chartkey__item {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
}

/* A length of the line and not a dot, because dashed against solid is half of
   what these swatches have to say. */
.chartkey__mark {
    width: 0.85rem;
    height: 0;
    flex: none;
    border-top: 2px solid var(--accent);
}

/* Except where the line it stands for is not a line. A length of nothing is
   a swatch for a shape that is not on the plot, and the key is the only thing
   naming it on the two shapes that have no readout to. */
.chartkey__mark--bar {
    width: 0.5rem;
    height: 0.5rem;
    border-top: 0;
    background: var(--accent);
    opacity: 0.82;
}

.chartkey__item--ai .chartkey__mark { border-color: var(--ai); }
.chartkey__item--hits .chartkey__mark { border-color: var(--hits); }

/* The comparison period, which is the only dashed line a card draws. */
.chartkey__item--prev .chartkey__mark {
    border-color: var(--ghost);
    border-top-style: dashed;
}

/* ------------------------------------------------- chart: the hover readout

   Only the full-width chart on a site's own page has this. A compact card is
   a link to that page and keeps the browser's own <title> tooltips.

   Everything here is inert until the script in head.php adds .is-hovered:
   with JavaScript off the marker never appears and the panel never fills, so
   neither is ever seen empty. The panel is absolutely positioned against
   .chartbox and placed in pixels by the script, which measures the marker
   rather than doing the viewBox arithmetic a second time. */

.chartbox { position: relative; }

.chart__cursor {
    opacity: 0;
    transition: opacity 0.12s ease;
}

.chartbox.is-hovered .chart__cursor { opacity: 1; }

.chart__rule { stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 3 3; }
.chart__dot { fill: var(--surface); stroke: var(--accent); stroke-width: 2.5; }
.chart__dot--pages { stroke: var(--pages); }
.chart__dot--hits { stroke: var(--hits); }
.chart__dot--ai { stroke: var(--ai); }

.charttip {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 3;
    min-width: 10.5rem;
    padding: 0.5rem 0.6rem 0.55rem;
    background: var(--surface);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow);
    font-size: 0.8rem;
    line-height: 1.5;
    /* Never between the pointer and the column it is describing. */
    pointer-events: none;
    opacity: 0;
    transform: translate(-50%, calc(-100% - 14px));
    transition: opacity 0.12s ease;
}

/* Flipped under the marker when the point sits too high for the panel to fit
   above it — which on a chart this tall is most of a busy day. */
.charttip.is-below { transform: translate(-50%, 14px); }

.chartbox.is-hovered .charttip { opacity: 1; }

.charttip__when {
    display: block;
    margin-bottom: 0.3rem;
    color: var(--text);
    font-weight: 650;
    white-space: nowrap;
}

.charttip__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1.25rem;
    color: var(--muted);
}

/* A swatch per row, so the number and the line it came off are the same
   colour and neither has to be looked up in the legend. */
.charttip__key::before {
    content: "";
    display: inline-block;
    width: 8px;
    height: 8px;
    margin-right: 0.42rem;
    border-radius: 2px;
    background: var(--accent);
}

.charttip__key.is-pages::before { background: var(--pages); }
.charttip__key.is-hits::before { background: var(--hits); }
.charttip__key.is-ai::before { background: var(--ai); }
.charttip__key.is-none::before { background: none; }

.charttip__figure { white-space: nowrap; }
.charttip__figure b { color: var(--text); font-weight: 650; }
.charttip__figure .badge { margin-left: 0.3rem; }

.charttip__prev {
    display: block;
    margin-top: 0.35rem;
    padding-top: 0.35rem;
    border-top: 1px solid var(--border);
    color: var(--faint);
    font-size: 0.74rem;
    white-space: nowrap;
}

/* Drag across the plot to zoom into it. The band is placed in pixels by the
   script, which measures the columns it is drawn between rather than doing
   the viewBox arithmetic a second time — the same way the readout is placed.
   Only a chart of days gets the cursor that advertises it. */
.chartbox.is-zoomable svg { cursor: crosshair; }
.chartbox.is-selecting { user-select: none; }

.chart__band {
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: 2;
    display: none;
    border-left: 1px solid var(--accent-line);
    border-right: 1px solid var(--accent-line);
    background: var(--accent-soft);
    pointer-events: none;
}

.chartbox.is-selecting .chart__band { display: block; }

/* Printing has no pointer. */
@media print {
    .chart__cursor,
    .charttip,
    .chart__band { display: none; }
}

/* ------------------------------------------------- chart: the split toggle

   Two links dressed as one switch, sat at the end of the Traffic card's head.
   Links rather than buttons because the state is a URL — the same as the
   range, the order and the comparison — so it survives a reload, a bookmark
   and a printer, and needs no script to work at all.

   Drawn as the theme switcher in the nav is, because it is the same kind of
   thing: a short closed set where the one you are on is lit rather than
   ticked. Words instead of glyphs, though. "All traffic" and "People vs AI"
   are not a pair anyone would recognise as two icons. */

.split {
    display: inline-flex;
    flex: none;
    padding: 2px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--bg);
}

.split__opt {
    padding: 0.2rem 0.6rem;
    border-radius: 999px;
    color: var(--muted);
    font-size: 0.74rem;
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
    transition: background 0.12s ease, color 0.12s ease;
}

.split__opt:hover { color: var(--text); }

.split__opt.is-on {
    background: var(--surface);
    box-shadow: var(--shadow);
    color: var(--accent);
}

/* The overview's copy of it, which sits in the nav's control rail instead of
   in a card head: there is a chart per site on that page and one switch for
   all of them, so it belongs where the range and the order already are.

   Dressed as the rail rather than as the card. The bar's own background is
   --bg, so the track takes --surface to be seen at all, and the lit half
   takes the accent wash a chip takes when it is on — a segmented control that
   kept the card's white pill and drop shadow read as something that had
   fallen in from another page. */

.split--nav { background: var(--surface); }

.split--nav .split__opt {
    padding: 0.24rem 0.62rem;
    font-size: 0.76rem;
}

.split--nav .split__opt.is-on {
    background: var(--accent-soft);
    box-shadow: none;
}

/* A switch is a thing to press, and a page on paper has already been pressed
   — it prints whichever chart it was showing, with the legend saying which. */
@media print {
    .split { display: none; }
}

/* --------------------------------------------------- overview: site cards */

.sites {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
    gap: 1rem;
    margin-bottom: 2.5rem;
}

.sitecard {
    display: block;
    padding-bottom: 0.35rem;
    color: inherit;
    text-decoration: none;
    transition: border-color 0.12s ease, transform 0.12s ease;
}

a.sitecard:hover {
    border-color: var(--accent-line);
    transform: translateY(-1px);
}

/* The head is the name and the two facts about the site rather than about a
   number: who is on it this minute, and which way the period moved. Centred
   rather than sat on a baseline, because a pill and a badge have no text
   baseline worth aligning a heading to. */
.sitecard__head {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.9rem var(--card-pad) 0;
}

/* Grows, and is the only thing here allowed to: a long hostname ellipsises
   rather than pushing the live count and the badge off the card. */
.sitecard__name {
    flex: 1 1 auto;
    min-width: 0;
    margin: 0;
    overflow: hidden;
    font-size: 0.98rem;
    font-weight: 650;
    letter-spacing: -0.01em;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.sitecard__head .livecount,
.sitecard__head .badge { flex: none; }

/* The card's big-number rule must not reach the pill's count. */
.sitecard__head .livecount b { font-size: inherit; }

.sitecard__go { display: none; }

/* The figures, a column each.

   They were a sentence — "408 visitors · 427 unique · 160 pageviews" — which
   read nicely at two numbers and wrapped at three, dragging the live pill onto
   a second line and making every card a different height. Columns hold one
   line at any width, and they line the cards up with one another: the fifth
   site's visitor figure sits directly under the first site's, which is the
   difference between a page you can scan and a page you have to read.

   Auto columns rather than a fixed three, so the row is still even where the
   unique figure is missing and there are only two of them. */
.sitecard__stats {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: 0.5rem;
    margin: 0.55rem 0 0;
    padding: 0 var(--card-pad);
}

.sitestat {
    display: flex;
    min-width: 0;
    flex-direction: column;
    gap: 0.1rem;
}

/* Sized to be read rather than announced. A card is one of six on a page and
   the figure on it is a fact, not a headline — at the size it started, twelve
   numbers in a grid shouted over the site names above them and over the charts
   they belong to. */
.sitestat__value {
    color: var(--text);
    font-size: 1.05rem;
    font-weight: 650;
    letter-spacing: -0.01em;
    line-height: 1.15;
}

/* Small, spaced and upper-case, the way the site page labels its metrics —
   quiet enough that the row reads as three numbers with names attached rather
   than as three words with numbers over them. */
.sitestat__label {
    overflow: hidden;
    color: var(--muted);
    font-size: 0.63rem;
    font-weight: 600;
    letter-spacing: 0.07em;
    text-overflow: ellipsis;
    text-transform: uppercase;
    white-space: nowrap;
}

/* Split, the assistants wear the colour of the line they are the total of —
   a figure this size has no room for a swatch, so the colour is the swatch —
   and the crawlers, which are not a line at all, are quiet enough to read as
   the third thing they are. All three are there because they add up to the
   visitor figure the card shows unsplit, and two of them do not. */
.sitestat--ai .sitestat__value { color: var(--ai); }
.sitestat--quiet .sitestat__value { color: var(--faint); }

/* -------------------------------------------------------------------- feed */

/* Recent activity is a panel like the others, and at a third of the page it
   has no room for the table it used to be. A row is two lines instead: the
   path with how long ago on the right, then the status as a pill, who the
   client was and where from. Everything that can overflow is one line with an
   ellipsis and the whole string in a tooltip — a long path must not be
   allowed to widen the grid column it sits in. */

.panel--feed .live { margin-left: auto; }

/* One page of rows and then a scroll, so the card holds the height its
   neighbours in the grid are aligned to however deep the feed is opened —
   "load more" pours the next page into this box rather than under it.

   Which needs a row of known height, so the box can be exactly six of them:
   the height is set rather than left to the content, the tracks are centred
   inside it, and every row carries the same 1px top border, transparent on
   the first. Guessing at the sum of two line boxes and a pill's padding is
   how a seventh row ends up peeking. The six is Live::PAGE; they change
   together. */

/* The box itself, worn by whatever a card puts its rows in: the feed's list,
   the AI card's, and the div a panel's table sits in. Fixed rather than
   capped, so a card with three rows in it is the height of the card beside it
   with sixty — which is the whole point, and is why "Load more" pours into
   this box rather than under it. */
.rows {
    height: var(--card-box);
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    scrollbar-color: var(--border-strong) transparent;
}

.feed {
    margin: 0;
    padding: 0;
    list-style: none;
}

.feed__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-content: center;
    align-items: baseline;
    gap: 0.1rem 0.6rem;
    height: var(--feed-row);
    padding: 0 var(--card-pad);
    border-top: 1px solid transparent;
}

/* Two-line rows run together without one; a hairline is enough, and the
   panels' own rows keep none because theirs are single lines. */
.feed__row + .feed__row { border-color: var(--border); }
.feed__row:hover { background: var(--accent-soft); }

.feed__path {
    overflow: hidden;
    font-family: var(--mono);
    font-size: 0.78rem;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.feed__ago {
    color: var(--faint);
    font-size: 0.72rem;
}

/* Fixed, because the chip below is a shade taller than the text beside it
   and a row's height may not depend on whether a crawler made the request. */
.feed__meta {
    display: flex;
    grid-column: 1 / -1;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
    height: 1.2rem;
    color: var(--muted);
    font-size: 0.72rem;
}

/* The status, as a pill. A 200 is the expected answer and is kept quiet —
   same box, no fill — so that a wall of them reads as calm and the one 404
   or 502 in the run is the thing the eye lands on. */
.code {
    flex: none;
    padding: 0.02rem 0.34rem;
    border-radius: 999px;
    background: transparent;
    color: var(--faint);
    font-family: var(--mono);
    font-size: 0.68rem;
    font-variant-numeric: tabular-nums;
}

.code.is-mid { background: var(--accent-soft); color: var(--muted); }
.code.is-bad { background: var(--down-soft); color: var(--down); font-weight: 650; }

.feed__client {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A crawler wears its name in a chip, so a run of them is one glance rather
   than five readings. They stay in every number on this dashboard — this
   marks them, it does not filter them. */
.feed__client.is-bot {
    flex: none;
    padding: 0.02rem 0.34rem;
    border-radius: 4px;
    background: var(--border);
    color: var(--muted);
}

/* An assistant's chip is coloured by which of the three kinds it is, in both
   feeds — so a row worth reading twice is findable in Recent activity too,
   not only in the AI card. Loudest is rarest on purpose: a fetch made because
   a person asked is the one piece of news in a week of crawling, and a
   training crawl is the wallpaper it has to stand out against. */
.feed__client.is-request {
    background: var(--accent-soft);
    color: var(--accent);
    font-weight: 600;
}

.feed__client.is-retrieval {
    background: var(--border);
    color: var(--muted);
}

.feed__client.is-training {
    background: transparent;
    box-shadow: inset 0 0 0 1px var(--border);
    color: var(--faint);
}

.feed__who {
    overflow: hidden;
    margin-left: auto;
    color: var(--faint);
    font-family: var(--mono);
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Reveals the next page inside the box above. Rendered hidden and unhidden
   by the script that gives it its job, so a page with JavaScript off ends at
   the sixth row rather than at a button that does nothing. */
/* The foot of a list card. It is there on every one of them, with or without
   a button in it, because a footer that appeared only on the cards with more
   to show would make exactly those cards taller than the rest. */
.card__foot {
    display: flex;
    height: 2.1rem;
    align-items: center;
    padding: 0 var(--card-pad) 0.15rem;
}

.more {
    display: block;
    width: 100%;
    padding: 0.3rem;
    border-color: var(--border);
    color: var(--muted);
    font-size: 0.78rem;
}

.more:hover {
    border-color: var(--faint);
    color: var(--text);
}

@media (max-width: 46rem) {
    /* Room enough for who and what; the address is the first thing a phone
       can live without, and Top hosts below has it anyway. */
    .feed__who { display: none; }
}

/* ---------------------------------------------------------------------- ai */

/* Who is reading this that is not a person. Built on the feed's row so the
   two sit as a pair: the same height, the same hairline, a count where the
   time was and the agents where the client was. No share bars — a training
   crawl outweighs the handful of on-request fetches by an order of magnitude
   most weeks, and a bar scaled to it would draw the row that matters most as
   nothing at all. */

.ai {
    margin: 0;
    padding: 0;
    list-style: none;
}

.ai__share {
    margin-left: auto;
    color: var(--muted);
    font-size: 0.76rem;
    font-variant-numeric: tabular-nums;
}

.ai__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-content: center;
    align-items: baseline;
    gap: 0.1rem 0.6rem;
    height: var(--feed-row);
    padding: 0 var(--card-pad);
    border-top: 1px solid transparent;
}

.ai__row + .ai__row { border-color: var(--border); }

.ai__label {
    overflow: hidden;
    font-size: 0.82rem;
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ai__count {
    font-size: 1rem;
    font-weight: 650;
    letter-spacing: -0.01em;
}

/* One line, clipped. The whole list is in the row's tooltip, so a busy week
   spills into a hover rather than into the card's height. */
.ai__agents {
    display: flex;
    grid-column: 1 / -1;
    align-items: center;
    gap: 0.3rem;
    overflow: hidden;
    height: 1.2rem;
    color: var(--muted);
    font-size: 0.72rem;
    white-space: nowrap;
}

.ai__agent {
    flex: none;
    padding: 0.02rem 0.34rem;
    border-radius: 4px;
    background: var(--border);
    font-family: var(--mono);
}

.ai__agent b {
    font-weight: 650;
    font-variant-numeric: tabular-nums;
}

/* Nothing yet is an answer, not an absence — but it should not shout. */
.ai__row.is-none .ai__label,
.ai__row.is-none .ai__count { color: var(--faint); font-weight: 500; }
.ai__row.is-none .ai__agents { color: var(--faint); }

/* ------------------------------------------------------- panes and panels

   The twelve cards of a site's page are dealt into five panes — Traffic,
   Live, AI, Pages, Visitors — which is the whole of the phone's navigation
   and none of the wide screen's. Here the panes are transparent: they are
   `display: contents`, the element around them is the grid the panels always
   sat in, and the cards flow into it in document order exactly as before.
   The phone shell at the foot of this file is where they become five
   screens.

   Three things in the Traffic pane are not cards and must not be dealt a
   column: the chart card spans the row, and so do the "no traffic" note and
   the line about how far back the logs go — which is written into that pane
   because that is where it belongs on a phone, and ordered last here because
   that is where it belongs on a desk. */

.panes {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
    gap: 1rem;
    margin: 0 0 2rem;
}

.pane { display: contents; }

.hero,
.pane > .note { grid-column: 1 / -1; }

.pane > .foot {
    grid-column: 1 / -1;
    order: 1;
}

/* The foot below the box is the card's bottom edge now. */
.panel { padding-bottom: 0; }

.panel .card__head { padding-bottom: 0.55rem; }

.panel table {
    width: 100%;
    border-collapse: collapse;
}

/* Set rather than left to the content, so ten of them are the box exactly.
   A row whose label wraps to a second line is taller than this and is meant
   to be — the height is a floor for the ordinary row, not a clamp on the long
   one, and a card holding one of those simply scrolls a little sooner. */
.panel tr { height: var(--panel-row); }

.panel td {
    position: relative;
    padding: 0.25rem var(--card-pad);
    font-size: 0.82rem;
}

.panel tr:hover td { background: var(--accent-soft); }

/* The share of the biggest row, drawn behind the log line itself. */
.panel .bar {
    position: absolute;
    inset: 3px auto 3px 6px;
    max-width: calc(100% - 12px);
    border-radius: 3px;
    background: var(--accent-soft);
    z-index: 0;
}

.panel .label,
.panel .n {
    position: relative;
    z-index: 1;
}

.panel .label {
    font-family: var(--mono);
    font-size: 0.78rem;
    word-break: break-word;
}

/* What kind of place a referrer is, in front of its name — see lib/Source.php
   for which is which. Quiet by default: the mark is there to group the column
   at a glance, not to compete with the figure at the end of the row. The one
   in accent is an arrival from an assistant's own window, which is the same
   thing the AI card calls the one piece of news in a week of crawling. */
.src {
    position: relative;
    z-index: 1;
    display: inline-flex;
    margin-right: 0.35rem;
    color: var(--faint);
    vertical-align: -0.15em;
}

.src.is-ai { color: var(--accent); }
.panel tr:hover .src { color: var(--muted); }
.panel tr:hover .src.is-ai { color: var(--accent); }

.panel .n {
    color: var(--muted);
    font-variant-numeric: tabular-nums;
    text-align: right;
    white-space: nowrap;
}

/* How the row moved against the same period before. Text rather than the
   filled badge the headline figures wear: twelve pills down the side of a
   card would be the loudest thing on the page, and the sign is already in
   the words, so the colour is confirming what it says rather than carrying
   it alone. A row that held still prints nothing at all. */
.chg {
    margin-left: 0.4rem;
    font-size: 0.7rem;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
}

.chg.is-up { color: var(--up); }
.chg.is-down { color: var(--down); }
.chg.is-flat { color: var(--faint); }

/* ------------------------------------------------------------------- hours

   The day as twenty-four slots in the order a day happens in. See
   views/hours.php for why this is not the table it used to be. */

/* The one card whose content is not a list, and so the one that had a strip
   of bars sitting in the top third of a box built for ten rows. It grows into
   the box instead: the bars take whatever height is left once the scale and
   the busiest-hour line have had theirs, which on a desk is two and a half
   times what they had and reads as a day rather than as a decoration.

   No breakpoint needed for the phone, where the box is a ceiling rather than
   a height: with nothing definite to grow into, the bars fall back to the
   4.5rem they always were. */
.rows--strip {
    display: flex;
    flex-direction: column;
}

.hours {
    display: flex;
    min-height: 4.5rem;
    flex: 1 1 auto;
    align-items: flex-end;
    gap: 2px;
    padding: 0.35rem var(--card-pad) 0;
}

.hours__bar {
    min-width: 0;
    flex: 1 1 0;
    border-radius: 2px 2px 0 0;
    background: var(--accent);
    opacity: 0.34;
    transition: opacity 0.12s ease;
}

/* The hour the day turns on, told apart by weight rather than by a second
   colour — one accent is the whole palette. */
.hours__bar.is-peak { opacity: 0.9; }
.hours__bar:hover { opacity: 0.65; }
.hours__bar.is-peak:hover { opacity: 1; }

.hours__scale {
    display: flex;
    justify-content: space-between;
    margin: 0.3rem 0 0;
    padding: 0 var(--card-pad);
    color: var(--faint);
    font-size: 0.68rem;
    font-variant-numeric: tabular-nums;
}

.hours__peak {
    margin: 0.15rem 0 0;
    padding: 0 var(--card-pad) 0.35rem;
    color: var(--muted);
    font-size: 0.78rem;
}

.hours__peak b { color: var(--text); font-weight: 650; }

.panel .empty {
    padding: 1.4rem var(--card-pad);
    color: var(--faint);
    font-size: 0.85rem;
    text-align: center;
}

/* ------------------------------------------------------------------- notes */

.note {
    padding: 0.85rem 1.05rem;
    margin-bottom: 1.25rem;
    border: 1px solid var(--border);
    border-left: 3px solid var(--accent);
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.86rem;
}

.note b { color: var(--text); }

.note code,
.setup code {
    padding: 0.1rem 0.3rem;
    border-radius: 4px;
    background: var(--accent-soft);
    color: var(--text);
    font-family: var(--mono);
    font-size: 0.82em;
}

.note--warn { border-left-color: var(--down); }

.pane > .note { margin-bottom: 0; }

.foot {
    padding: 1.25rem 0 2.5rem;
    color: var(--faint);
    font-size: 0.76rem;
}

/* -------------------------------------------------------------------- gate */

.gate {
    max-width: 21rem;
    margin: 18vh auto 0;
    padding: 1.6rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow);
}

.gate__brand {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0 0 1.1rem;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.gate__brand svg { color: var(--accent); }

/* The brand as a link back to the front door: it carries the heading's own
   layout so it looks no different sitting there, and only says so on hover. */
.gate__brand a {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    color: inherit;
    text-decoration: none;
}

.gate__brand a:hover { color: var(--accent); }
.gate input { width: 100%; margin-bottom: 0.7rem; padding: 0.5rem 0.65rem; }
.gate button { width: 100%; padding: 0.5rem; }
.gate .bad { color: var(--down); font-size: 0.8rem; margin-bottom: 0.7rem; }

.setup { max-width: 44rem; margin: 8vh auto; }
.setup h1 { font-size: 1.3rem; margin: 0 0 0.6rem; }
.setup pre {
    padding: 0.9rem 1.1rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    font-family: var(--mono);
    font-size: 0.8rem;
    overflow-x: auto;
}

/* ---------------------------------------------------------------- tab bar

   The five sections, along the bottom of a phone. Drawn nowhere else: on a
   wide screen the same panes are one grid and there is nothing to switch
   between, which is why this is `display: none` here and turned on by the
   shell below rather than the other way round.

   Links to fragments, so a press is the browser scrolling the pane into view
   and everything works with no script at all. Which tab is lit is the one
   part that needs saying twice: with a script it is a class, set on whatever
   pane you swiped to; without one it is :target, which is right for a press
   and cannot know about a swipe. The [data-js] mark head.php sets before
   first paint picks between them, so no tab is ever lit twice. */

.tabbar { display: none; }

.tabbar__tab {
    position: relative;
    display: flex;
    flex: 1 1 0;
    min-width: 0;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.12rem;
    padding: 0.5rem 0.15rem calc(0.45rem + env(safe-area-inset-bottom));
    color: var(--faint);
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.01em;
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
}

.tabbar__label {
    overflow: hidden;
    max-width: 100%;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The lit tab, however it came to be lit. A colour and a short bar over it:
   at this size a filled pill would be the loudest thing on the screen, and
   the loudest thing on the screen should be the numbers. */
.tabbar__tab.is-on,
:root:not([data-js]) .panes:not(:has(.pane:target)) ~ .tabbar .tabbar__tab:first-child,
:root:not([data-js]) .panes:has(#pane-traffic:target) ~ .tabbar [href="#pane-traffic"],
:root:not([data-js]) .panes:has(#pane-live:target) ~ .tabbar [href="#pane-live"],
:root:not([data-js]) .panes:has(#pane-ai:target) ~ .tabbar [href="#pane-ai"],
:root:not([data-js]) .panes:has(#pane-pages:target) ~ .tabbar [href="#pane-pages"],
:root:not([data-js]) .panes:has(#pane-visitors:target) ~ .tabbar [href="#pane-visitors"] {
    color: var(--accent);
}

.tabbar__tab.is-on::before,
:root:not([data-js]) .panes:not(:has(.pane:target)) ~ .tabbar .tabbar__tab:first-child::before,
:root:not([data-js]) .panes:has(#pane-traffic:target) ~ .tabbar [href="#pane-traffic"]::before,
:root:not([data-js]) .panes:has(#pane-live:target) ~ .tabbar [href="#pane-live"]::before,
:root:not([data-js]) .panes:has(#pane-ai:target) ~ .tabbar [href="#pane-ai"]::before,
:root:not([data-js]) .panes:has(#pane-pages:target) ~ .tabbar [href="#pane-pages"]::before,
:root:not([data-js]) .panes:has(#pane-visitors:target) ~ .tabbar [href="#pane-visitors"]::before {
    content: "";
    position: absolute;
    top: 0;
    width: 1.6rem;
    height: 2px;
    border-radius: 0 0 2px 2px;
    background: var(--accent);
}

/* A phone has no hover to answer a press with, so the press answers itself. */
.tabbar__tab:active { color: var(--text); }

/* -------------------------------------------------------------- responsive */

/* The wordmark and the long settings values are the first things to go: the
   bird still says whose dashboard this is, and a chip still says what it is
   set to once you open it. */
@media (max-width: 52rem) {
    .brand__name { display: none; }
    /* Opened from the end of the rail, so it hangs from the chip's right edge
   rather than running off the side of the window. Ignored inside the sheet,
   where the panel is static. */
.setting > .menu__panel {
    right: 0;
    left: auto;
}

.setting > summary .chip__text { max-width: 7.5rem; }
}

@media (max-width: 40rem) {
    /* Every side margin on the page draws in together — the page's, and the
       one inside every card — so a narrow screen spends its width on the
       numbers rather than on the space around them. */
    :root {
        --gutter: 0.85rem;
        --card-pad: 0.9rem;
    }

    /* The Traffic card, re-dealt for a screen that is tall rather than wide.

       A desk reads it left to right: five figures in a row, the chart under
       them, the legend tucked up beside the title. A phone has no width to
       put five figures across — three fit only by shrinking them, and the
       chart ended up in the bottom third with a hand's breadth of nothing
       under it. So the order is turned on its side: the chart first, at the
       top where it is looked at, the legend under it as its caption, and the
       figures below as a list, one to a line, each with its change beside
       it. Which reads better and, being a list, is the one part of the card
       that can stretch to whatever height is left.

       `display: contents` on the head is what lets the title and the legend
       be ordered separately without a second copy of the markup: the box
       around them goes away here and they become two items of the card
       itself. */
    .hero {
        display: flex;
        flex-direction: column;
    }

    .hero .card__head { display: contents; }

    .hero .card__title {
        order: 1;
        padding: 0.7rem var(--card-pad) 0.25rem;
    }

    .hero .chartbox--phone { order: 2; }

    .hero .legend {
        order: 3;
        margin: 0;
        padding: 0.1rem var(--card-pad) 0.5rem;
        border-bottom: 1px solid var(--border);
    }

    /* `nowrap` matters: the row version of this block wraps, and a wrapping
       column flex box does not run on past the bottom of a fixed-height card
       — it starts a second column, which is five figures in two ragged
       piles. */
    .hero .metrics {
        order: 4;
        display: flex;
        flex-direction: column;
        flex-wrap: nowrap;
        padding: 0;
    }

    /* Label, figure, change — three columns, so the numbers hold one edge
       and the badges hold another rather than each row setting its own. The
       figure is the text inside .metric__value, which is why that element
       stands aside here: with no box of its own its text is a column of the
       row's grid, and the badge after it is the next one. */
    .metric {
        display: grid;
        min-width: 0;
        grid-template-columns: minmax(0, 1fr) auto auto;
        align-items: baseline;
        column-gap: 0.5rem;
        align-content: center;
        min-height: 2.4rem;
        padding: 0.3rem var(--card-pad);
        border-left: 0;
        text-align: right;
    }

    .metric + .metric {
        padding-left: var(--card-pad);
        border-top: 1px solid var(--border);
        border-left: 0;
    }

    .metric__label {
        margin: 0;
        font-size: 0.68rem;
        letter-spacing: 0.05em;
        text-align: left;
    }

    .metric__value {
        display: contents;
        font-size: 1.15rem;
    }

    .metric__value .badge {
        margin-left: 0;
        padding-inline: 0.38rem;
        font-size: 0.7rem;
        vertical-align: baseline;
    }

    /* The head is `display: contents` up there, so the switch is an item of
       the card like the title and the legend are — and it goes last, under
       the figures, where a thumb already is. Full width, because at this size
       a control floating at one end of a row reads as something left over
       rather than as something to press. */
    .hero .split {
        order: 5;
        margin: 0 var(--card-pad) 0.7rem;
        padding: 3px;
    }

    .hero .split__opt {
        flex: 1;
        padding-block: 0.35rem;
        text-align: center;
    }

    /* The wide chart and the phone's are the same numbers at two shapes;
       exactly one of them is ever on screen. */
    .chartbox--wide { display: none; }
    .chartbox--phone { display: block; }

    /* Which period the dashed line is drawn from is worth the room; the word
       in front of it is not, with a swatch already saying it. */
    .legend { gap: 0.55rem; font-size: 0.7rem; }
    .legend__long { display: none; }
    .legend i { width: 12px; margin-right: 0.3rem; }

    .card__title { font-size: 0.9rem; }

    .navtitle { font-size: 0.95rem; }
    .nav__meta { font-size: 0.68rem; }

    /* Enough of the pill to read the number; "active" is what the green dot
       already says. */
    .livecount--tap .livecount__word { display: none; }
    .livecount--tap { padding-inline: 0.5rem; }
    /* "Updated" is the droppable half; the age is what anyone reads it for. */
    .nav__meta .live__word { display: none; }

    .themeset__btn { width: 24px; }

    /* A thumb, not a cursor. The everyday ranges are the most-pressed thing
       on the page and they were built for a mouse. */
    .chip {
        padding: 0.42rem 0.8rem;
        font-size: 0.82rem;
    }

    .chip:active { background: var(--accent-soft); }
    .sitepick > summary { padding: 0.15rem 0.4rem; }

    /* Two halves of one pill in a rail of pills: the same height as the
       ranges beside them, or the row reads as two rows. */
    .split--nav .split__opt {
        padding: 0.36rem 0.7rem;
        font-size: 0.8rem;
    }

    /* Row two becomes one rail. The four everyday ranges sit under a thumb
       and the rest is two pills at the far end of it. */
    .nav__row--what {
        margin-inline: calc(var(--gutter) * -1);
        padding: 0.1rem var(--gutter);
        overflow-x: auto;
        scrollbar-width: none;
    }

    .nav__row--what::-webkit-scrollbar { display: none; }

    /* Now the settings fold behind their pill, and the engine's own hiding
       goes back to doing the work. */
    .opts > summary { display: inline-flex; }
    .opts:not([open]) > .menu__panel { display: none; }

    .opts:not([open])::details-content {
        content-visibility: hidden;
        block-size: 0;
    }

    /* Every panel comes up from the bottom of the screen instead of hanging
       under the control that opened it — a thumb is already down there, the
       rows can be twice the height, and a fixed sheet is the one thing the
       sideways-scrolling rail above cannot clip. */
    /* Tappable, and said to be: a phone that decides this is scenery rather
       than a control can withhold the click that dismisses the sheet. */
    .menu[open]::before {
        content: "";
        position: fixed;
        z-index: 45;
        inset: 0;
        background: rgba(8, 10, 16, 0.5);
        cursor: pointer;
    }

    .menu__panel,
    .opts[open] > .menu__panel {
        position: fixed;
        z-index: 50;
        inset: auto 0 0 0;
        display: flex;
        max-height: 74vh;
        flex-direction: column;
        align-items: stretch;
        gap: 0.15rem;
        padding: 0.55rem 0.85rem calc(1rem + env(safe-area-inset-bottom));
        overflow-y: auto;
        border: 0;
        border-top: 1px solid var(--border-strong);
        border-radius: 18px 18px 0 0;
        background: var(--surface);
        box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3);
    }

    .opts[open] > .menu__panel { gap: 0.5rem; }

    .menu__item { padding: 0.68rem 0.6rem; font-size: 0.95rem; }
    .menu__label { margin-top: 0.6rem; }

    /* In the sheet the settings are not two menus to open — they are the
       lists inside them, one under each heading, exactly as the ranges sheet
       reads. A sheet that opened another sheet would be a place to get lost
       in, and the chip that opens each one has nothing left to say once its
       list is on screen. The engine's own hiding is switched off where it
       put the switch, the same way the wide screen does it to this whole
       menu further up. */
    .opts .setting > summary { display: none; }

    .opts .setting::details-content {
        content-visibility: visible;
        block-size: auto;
    }

    .opts .setting > .menu__panel {
        position: static;
        display: block;
        max-height: none;
        padding: 0;
        overflow: visible;
        border: 0;
        border-radius: 0;
        background: none;
        box-shadow: none;
    }

    .opts .menu__label:first-child { margin-top: 0; }
    .opts .dates { justify-content: space-between; }
    .opts .dates input[type="date"] { flex: 1 1 auto; min-width: 0; padding-block: 0.42rem; }
    .opts .primary { width: 100%; padding-block: 0.5rem; }

    /* At the foot of the sheet, the width of it, with a hairline over: in a
       list of settings the one that undoes them all should read as the way
       out rather than as one more of them. */
    .opts .chip--reset {
        width: 100%;
        margin-top: 0.35rem;
        justify-content: center;
        padding-block: 0.55rem;
        border-color: transparent;
        border-top: 1px solid var(--border);
        border-radius: 0;
        background: none;
        font-size: 0.9rem;
    }

    /* A card is a link, and on a phone the chevron is the only thing saying
       so — there is no cursor and no hover to say it. At the end of the name's
       line, after the live count and the badge, which is where a phone's own
       lists put it. */
    .sitecard__go {
        display: inline-flex;
        flex: none;
        margin-right: -0.15rem;
        color: var(--faint);
        transform: rotate(-90deg);
    }

    .sitecard__name { font-size: 1rem; }

    /* Four or five cards on screen instead of three. Most of that is the
       chart — the long, low shape above replaces the compact one — and the
       rest is every margin around it coming in a notch, since a card whose
       chrome is half its height is a card you can only see three of. */
    .sitecard .chart--compact,
    .sitecard .chartkey--compact { display: none; }

    .chart--card {
        display: block;
        padding: 0.1rem 0.3rem 0;
    }

    .chart--card .chart__label { font-size: 15px; }

    /* The key comes with the chart it belongs to, and comes in a notch like
       everything else on a card here — it is one line either way, and one line
       is the price of the phone's chart being the only one with no tooltips
       to fall back on. */
    .chartkey--card {
        display: flex;
        gap: 0.15rem 0.7rem;
        padding: 0 var(--card-pad) 0.35rem;
        font-size: 0.62rem;
    }

    .sitecard__head { padding-top: 0.65rem; gap: 0.4rem; }
    .sitecard__name { font-size: 0.95rem; }
    .sitecard__stats { gap: 0.4rem; margin-top: 0.4rem; }
    .sitestat__value { font-size: 1rem; }
    .sitestat__label { font-size: 0.6rem; letter-spacing: 0.05em; }
    .sitecard { padding-bottom: 0.1rem; }

    /* Two lines and then the ellipsis. A referrer or a path long enough to
       fill the card pushes everything under it off the screen, and the rest
       of the string was never the useful half. Inline, so that a row wearing
       a mark keeps the mark and the name on one line — a block box here would
       put the name under its own icon. */
    .panel .label {
        display: -webkit-inline-box;
        overflow: hidden;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        word-break: break-word;
    }

    .panel td { padding-block: 0.3rem; }

    /* Under 16px a phone zooms the page to meet the field, and then leaves it
       zoomed — with the sign-in button somewhere off to the right. */
    .gate {
        max-width: none;
        margin: 12vh 0 0;
    }

    .gate input,
    .gate button { font-size: 1rem; }

    .setup { margin: 5vh auto; }
    .setup pre { font-size: 0.75rem; }
}

/* ------------------------------------------------------------- phone shell

   A narrow screen and enough of it to divide: the dashboard stops being a
   page you scroll and becomes a window with furniture. The nav is pinned at
   the top, the sections at the bottom, and between them exactly one pane —
   one screen of one subject, which is as much as a phone can show clearly
   anyway. Nothing here is a second copy of anything: the same markup that is
   a grid on a desk is a scroller that snaps here.

   The height test is what keeps this off a phone held sideways, where 400
   pixels split three ways is worse than a page that simply scrolls; the
   width test alone would also catch it, since a landscape phone is wider
   than this. Fall out of either and every rule below stops applying at once,
   the panes go back to being `display: contents`, and what is left is an
   ordinary scrolling page — which is why the tab bar is drawn here and
   nowhere else. */

@media (max-width: 40rem) and (min-height: 30rem) {
    html { height: 100%; }

    /* Only the two dashboard pages carry `app`. The gate is one field and the
       setup page is a wall of instructions; both want to scroll like any
       other page, and neither has panes to put in a window. */
    body.app {
        display: flex;
        height: 100dvh;
        flex-direction: column;
        overflow: hidden;
    }

    /* Nothing scrolls past it any more, so it does not need to stick. */
    body.app .nav {
        position: static;
        flex: none;
    }
    /* The public page's strip is pinned chrome too — the first row of the
       column, above the nav, and never squeezed to give the panes their
       height back. */
    body.app .promo { flex: none; }

    .promo__inner { padding-block: 0.4rem; }
    .promo__text { font-size: 0.76rem; }

    /* The width is spelled out because `.wrap` centres itself with automatic
       side margins, and an automatic margin across a flex container's cross
       axis eats the free space instead of stretching into it — which leaves
       the page as wide as its widest card rather than as wide as the screen,
       and pushes the right-hand end of everything off the glass. */
    body.app .page {
        display: flex;
        width: 100%;
        min-height: 0;
        flex: 1 1 auto;
        flex-direction: column;
        margin-inline: 0;
        padding-top: 0.7rem;
    }

    /* One screen per pane, snapped. Swiping and pressing a tab are the same
       movement: both are the browser scrolling this element.

       Bled out to the glass, with the page's margins moved onto the panes
       themselves. A pane is then exactly the width of the window — which is
       what makes every way of arriving at one land in the same place, the
       browser's own "scroll this into view" included, and what lets the pane
       you are leaving run off the edge of the screen rather than being cut
       at the margin. */
    .panes {
        display: flex;
        min-height: 0;
        flex: 1 1 auto;
        margin: 0 calc(var(--gutter) * -1);
        overflow-x: auto;
        overflow-y: hidden;
        scroll-behavior: smooth;
        scroll-snap-type: x mandatory;
        overscroll-behavior-x: contain;
        scrollbar-width: none;
    }

    .panes::-webkit-scrollbar { display: none; }

    /* The window's width exactly, carrying the page's side margins as its
       own padding — so the cards inside land on the same line the nav above
       them does. */
    .pane {
        display: flex;
        min-height: 0;
        flex: 0 0 100%;
        flex-direction: column;
        gap: 0.7rem;
        padding: 0 var(--gutter) 0.55rem;
        overflow-y: auto;
        /* `none` rather than `contain`: a pane that bounces under a pull is
           two things moving where the gesture means one. */
        overscroll-behavior: none;
        scroll-snap-align: start;
        scroll-snap-stop: always;
        scrollbar-width: none;
    }

    .pane::-webkit-scrollbar { display: none; }
    .pane > .card { flex: none; }

    /* The page is the window and the pane is what moves, so a pull at the top
       of one is where the browser's own refresh gesture went. It rides out
       from behind the nav — no z-index of its own, so the bar it comes from
       covers it — and turns accent when it has come far enough to mean it. */
    body.app .page { position: relative; }

    .pull {
        position: absolute;
        top: 0;
        left: 50%;
        display: flex;
        width: 2rem;
        height: 2rem;
        align-items: center;
        justify-content: center;
        margin-top: -2.6rem;
        border: 1px solid var(--border);
        border-radius: 999px;
        background: var(--surface);
        box-shadow: var(--shadow);
        opacity: 0;
        transform: translate(-50%, 0);
    }

    .pull::after {
        content: "";
        width: 0.9rem;
        height: 0.9rem;
        border: 2px solid var(--faint);
        border-top-color: transparent;
        border-radius: 999px;
    }

    .pull.is-pulling { opacity: 1; }
    .pull.is-easing { transition: transform 0.2s ease, opacity 0.2s ease; }
    .pull.is-armed { border-color: var(--accent-line); }
    .pull.is-armed::after { border-color: var(--accent); border-top-color: transparent; }
    .pull.is-busy::after { animation: pull 0.7s linear infinite; }

    @keyframes pull {
        to { transform: rotate(360deg); }
    }

    /* A pane whose cards overrun it is a scrolling box, and a scrolling box
       is focusable so it can be scrolled from a keyboard — which means
       following a link to one rings the whole screen in accent. The ring is
       right for a control and wrong for a region: there is nothing here to
       operate, and everything inside it that can be operated keeps a ring of
       its own. */
    .pane:focus-visible { outline: none; }

    /* The Traffic card takes the whole section, and the list of figures at
       the bottom of it takes whatever the chart above them did not: the rows
       stand at a comfortable minimum on the shortest phone still in service
       and open out on a tall one, so the section is full either way and the
       gap that used to sit under the chart has nowhere to be. */
    .pane > .hero { flex: 1 1 auto; }
    .pane > .hero .metrics { flex: 1 1 auto; }

    /* The ceiling is for a narrow window on a desk rather than for a phone,
       where the five of them divide what is left into rows a thumb's width
       apart and never reach it. */
    .pane > .hero .metric {
        flex: 1 1 auto;
        max-height: 5.5rem;
    }

    /* A feed given a section to itself takes the whole of it rather than the
       six rows it is allotted in a grid of cards — the box is whatever is
       left, and the script in head.php keeps asking for pages until the rows
       reach the bottom of it. Twice the log on screen for no extra furniture,
       which is the point of the shell. */
    .pane > .panel--feed {
        display: flex;
        min-height: 9rem;
        flex: 1 1 auto;
        flex-direction: column;
    }

    /* On a phone the cards are stacked one under another and have nobody to
       line up with, so the box stops being a height at all: a panel with
       three rows in it is three rows tall, one with its whole page in it is
       as tall as that page came out, and the pane behind them is what
       scrolls. Not even a ceiling, which is what this was — the ceiling was
       a row and a half short the moment a hostname wrapped to two lines, and
       Top hosts quietly kept its tenth row behind a scrollbar inside a card
       nobody thinks to scroll. A box within a box is one scroll too many on
       a screen this narrow, and the rows it hides are the ones nobody finds.
       The button is unchanged: it pours the next page in and the card grows
       by it. */
    .rows {
        height: auto;
        max-height: none;
    }

    /* Every card but one. The strip is drawn as bars that are a percentage of
       the box they stand in, and a percentage of a height that is itself
       content-shaped is not a height at all — the browser gives up and every
       bar collapses to nothing, so By hour came out as an empty gap with a
       scale under it. The ceiling above is what took the height away; this
       gives it back, as half the box a list card gets rather than the whole
       of it, since three lines of a chart do not need six rows of room.
       Derived from the same row so the short-phone rule below still brings
       it down with everything else. */
    .rows--strip { height: calc(var(--card-box) / 2); }

    /* And a foot with no button in it is a foot worth nothing here. */
    .card__foot:empty { display: none; }

    /* Except the feed given a section to itself, which takes the whole of it
       — no ceiling, and it grows into whatever the shell leaves. */
    .pane > .panel--feed .rows {
        min-height: 0;
        height: auto;
        max-height: none;
        flex: 1 1 auto;
    }

    /* Unless it does not have the section to itself. AI requests sits under
       the assistants card, and a feed still insisting on the whole of a
       section it shares can only get it by shrinking — which is what it did,
       down to the 9rem floor above: six rows rendered, two and a half of them
       shown, and the rest behind a scroll inside a pane that had nothing left
       to scroll with. Sharing, it is a card like any other — its page of rows
       at full height, and the pane moves. head.php stops topping it up to
       match, since a box the height of its own rows is never short of any. */
    .pane:has(.card + .card) > .panel--feed,
    .pane:has(.card + .card) > .panel--feed .rows {
        flex: none;
    }

    /* The "logs cover" line is last in its pane either way, so the ordering
       the grid needed is dropped here — and it sits against the bottom of
       the screen rather than adrift under the chart, which is where a
       footnote belongs when the page has an edge again. */
    .pane > .foot {
        order: 0;
        margin-top: auto;
        padding: 0.35rem 0 0.15rem;
    }

    .foot { text-align: center; }

    /* The trend, at the head of every section that is not it. Pinned rather
       than scrolled: a table of top pages is taller than the screen, and a
       figure that leaves as soon as you read past it is one you have to
       swipe back for. Bled to the edges and backed in the page's own colour
       so the cards pass under it cleanly. */
    .spark {
        position: sticky;
        top: 0;
        z-index: 3;
        display: flex;
        flex: none;
        align-items: center;
        gap: 0.45rem;
        margin: 0 calc(var(--gutter) * -1);
        padding: 0.3rem var(--gutter) 0.4rem;
        border-bottom: 1px solid var(--border);
        background: var(--bg);
        color: var(--muted);
        font-size: 0.75rem;
        text-decoration: none;
        -webkit-tap-highlight-color: transparent;
    }

    .spark__figure b {
        margin-right: 0.1rem;
        color: var(--text);
        font-size: 1rem;
        font-weight: 650;
        letter-spacing: -0.01em;
    }

    .spark .badge {
        margin-left: 0;
        padding-inline: 0.36rem;
        font-size: 0.66rem;
    }

    /* Sized here rather than by the viewBox: the line is drawn with no
       aspect ratio to keep — there is no type in it to stretch — so the box
       can be whatever is left at the end of the row. */
    .spark__line {
        display: block;
        width: 5.5rem;
        height: 1.7rem;
        margin-left: auto;
    }

    .spark .chart--spark {
        display: block;
        width: 100%;
        height: 100%;
        padding: 0;
    }

    .spark .chart--spark .chart__line { stroke-width: 1.75; }
    .spark .chart--spark .chart__area { opacity: 0.13; }

    /* No hover on a phone, so the press answers for itself. */
    .spark:active { background: var(--accent-soft); }

    .tabbar {
        display: flex;
        flex: none;
        margin-inline: calc(var(--gutter) * -1);
        border-top: 1px solid var(--border);
        background: var(--surface);
    }

    /* The overview is a list rather than sections, so its cards are the thing
       that scrolls. They snap as the panes do — a list at rest showing two
       half cards is a list you have to scroll to read either of them. */
    .sites {
        min-height: 0;
        flex: 1 1 auto;
        grid-template-columns: minmax(0, 1fr);
        /* Cards keep their own height. A grid told to fill a box taller than
           its rows hands the difference to the rows, which on a short list
           is three cards stretched into a screen and a chart adrift in the
           middle of each. */
        align-content: start;
        gap: 0.6rem;
        margin: 0;
        /* The inset is the home indicator's, and it is only ever anything
           when this is running installed: in a browser the toolbar already
           holds that strip and 100dvh stops above it. The site pages get the
           same allowance from the tab bar, which is the last thing on them;
           this list is the last thing on the overview. */
        padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
        overflow-y: auto;
        overscroll-behavior: none;
        scroll-snap-type: y proximity;
        scrollbar-width: none;
    }

    .sites::-webkit-scrollbar { display: none; }
    .sitecard { scroll-snap-align: start; }
}

/* The shortest screens the shell still applies to — a 640-pixel phone, which
   is four rows of figures and most of a fifth. Everything gives up a hair so
   that the fifth one lands above the tab bar rather than a finger's width
   below it. */
@media (max-width: 40rem) and (min-height: 30rem) and (max-height: 41rem) {
    /* The row the two list cards share comes down with everything else: on a
       screen this short the strip above them has to come from somewhere, and
       a fifth of a row each is cheaper than a card that is mostly button. */
    :root { --feed-row: 3rem; }

    .spark { padding-block: 0.25rem 0.3rem; }
    .spark__line { height: 1.5rem; }
    .metric { min-height: 2.15rem; }
    .hero .card__title { padding-top: 0.55rem; }
    .hero .legend { padding-bottom: 0.4rem; }
    .pane > .foot { padding-block: 0.25rem 0.1rem; }
}

@media (prefers-reduced-motion: reduce) {
    .live::before { animation: none; }
    .livecount i { animation: none; }
    .pull.is-busy::after { animation: none; }
    a.sitecard:hover { transform: none; }
    .sitepick > summary .icon,
    .chip--menu .icon:last-child { transition: none; }

    /* A tab still moves the pane it points at; it just gets there at once. */
    .panes { scroll-behavior: auto; }
}

/* ---------------------------------------------------------------------------
   Somebody else's website: views/site.php and views/join.php.

   Kept apart from everything above it rather than folded in, because these two
   pages are not the dashboard. They share the tokens, the card and the type
   scale — so they look like the same product — and they share nothing else.
   A rule here cannot reach the dashboard, which is the point: the dashboard is
   the thing that works, and this is the thing that is new.
   --------------------------------------------------------------------------- */

.site {
    max-width: 34rem;
    margin: 0 auto;
    padding: 2.2rem 1rem 4rem;
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
}

.site__head { margin-bottom: 0.2rem; }

/* The brand out to Kestrel, and — for the owner — the way up to their other
   sites, on one line at the very top of the page. A way back belongs where
   people look for one, which is the top left, not the foot of a long page. */
.site__topline {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem 1rem;
    margin-bottom: 1.1rem;
}

.site__brand {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    color: var(--muted);
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.01em;
    text-decoration: none;
}

/* The arrow is drawn by the rule rather than typed into the markup, so the
   text is what gets selected and read out, and the glyph cannot be left
   behind by a copy. */
.site__back {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.25rem 0.7rem 0.25rem 0.55rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    color: var(--muted);
    font-size: 0.76rem;
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
    transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}

.site__back::before { content: "←"; font-size: 0.85em; }

.site__back:hover,
.site__back:focus-visible {
    border-color: var(--accent-line);
    background: var(--accent-soft);
    color: var(--accent);
}

.site__brand:hover { color: var(--accent); }
.site__brand .mark { color: var(--accent); }

/* The four words, which are the address and the title both. Monospace-adjacent
   letter-spacing rather than a display face: this is a string people read out
   and type, not a name somebody chose. */
/* The site's own icon and its own name, side by side. The icon is a fixed
   32 square so the row does not jump while it loads, and it keeps its box even
   when the picture is missing. */
.site__title {
    display: flex;
    align-items: center;
    gap: 0.55rem;
}

.site__icon {
    flex: none;
    width: 32px;
    height: 32px;
    border-radius: var(--radius-sm);
    object-fit: contain;
    background: var(--bg);
}

.site__name {
    margin: 0;
    font-size: 1.25rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1.2;
    overflow-wrap: anywhere;
}

/* A slug is four hyphenated words and reads as an address, so it keeps the
   monospace treatment it has always had. A real name does not — set in the
   mono face it would look like a filename rather than like a website. */
.site__name--slug {
    font-family: var(--mono);
    font-size: 1.15rem;
}

/* The site's own address, and this page's, on one line under the name.

   The link is the loud one: somebody sent this page to a reader saying "look
   what my site gets", and the obvious next move is to go and look at the site
   — until now there was nothing to click. The slug is the quiet one, still
   what everything keys on and what somebody types to get back here, but no
   longer the only identifier on the page. */
.site__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.25rem 0.6rem;
    margin: 0.35rem 0 0;
    font-family: var(--mono);
    font-size: 0.76rem;
    line-height: 1.5;
}

.site__link {
    color: var(--accent);
    font-weight: 600;
    text-decoration: none;
    overflow-wrap: anywhere;
}

.site__link:hover,
.site__link:focus-visible { text-decoration: underline; }

/* A middot between the two, drawn rather than typed so that dragging across
   the address to copy it does not pick the separator up. Only ever where
   there is something on both sides of it. */
.site__link + .site__slug::before {
    content: "·";
    margin-right: 0.6rem;
    color: var(--border-strong);
}

.site__slug {
    color: var(--faint);
    overflow-wrap: anywhere;
}

/* What the site says about itself, in its own words. */
.site__about {
    margin: 0.5rem 0 0;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--muted);
    max-width: 34rem;
}

.site__setup h2,
.site__badge h2,
.site__own h2 {
    margin: 0 0 0.5rem;
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.site__setup,
.site__badge,
.site__own { padding: var(--card-pad); }

.site__setup p,
.site__badge p,
.site__own p {
    margin: 0 0 0.7rem;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--muted);
}

.site__note { font-size: 0.78rem !important; color: var(--faint) !important; }

.site__figures { padding-bottom: 0.6rem; }

.site__range {
    margin: 0.2rem var(--card-pad) 0.1rem;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--faint);
}

/* The one thing on either page that a reader is meant to copy rather than
   read. Selectable, scrollable, and never wrapped — a snippet broken across
   lines is a snippet somebody pastes half of. */
.snippet {
    margin: 0 0 0.7rem;
    padding: 0.7rem 0.8rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--bg);
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}

.snippet code {
    font-family: var(--mono);
    font-size: 0.72rem;
    line-height: 1.6;
    color: var(--text);
    white-space: pre;
    /* An inline <code> in prose on these pages wears a tinted pill — see
       `.note code`. A block of them would be a stripe behind every line, which
       is what happened the moment snippets grew a <code> of their own. */
    padding: 0;
    border-radius: 0;
    background: none;
}

/* Except where what is in the block is a sentence rather than code. The rule
   above is about code, where a line the browser broke is a line somebody
   pastes wrong; a prompt is prose, and prose that scrolls sideways is prose
   nobody reads before pressing the button. Nothing is lost by wrapping it:
   the copy takes the text, not the layout, so it is pasted as it was written
   either way. */
.snippet--prose code {
    white-space: pre-wrap;
    /* The address inside it is longer than a phone's card is wide, and a word
       that will not break is a word that widens the block instead. */
    overflow-wrap: anywhere;
}

/* The badge, shown as it will appear rather than described. On the page's own
   ground, not the card's, so that what is being previewed is the badge and not
   the badge plus whatever is behind it here. */
.site__badgepreview {
    display: flex;
    justify-content: center;
    padding: 1rem;
    margin-bottom: 0.8rem;
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-sm);
    background: var(--bg);
}

.site__snippet,
.site__markdown { margin-bottom: 0.9rem; }

/* Folded away rather than laid out beside the HTML snippet. Most people want
   one snippet and the one above it is the one they want; this is for the
   person putting a counter in a README, who knows they are and will go
   looking. A second code block always open would make the first look like a
   choice to be made. */
.site__markdown summary {
    cursor: pointer;
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--muted);
    margin-bottom: 0.5rem;
}

.site__markdown summary:hover { color: var(--accent); }

.site__snippet summary {
    cursor: pointer;
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--muted);
    margin-bottom: 0.5rem;
}

.site__snippet summary:hover { color: var(--accent); }

.site__out button {
    padding: 0.35rem 0.8rem;
    font-size: 0.78rem;
    color: var(--muted);
}

/* What this box knows about the site, and the button that asks it to look
   again. Separated from the sign-out below it by a rule, because the two are
   the only controls in the card and one of them is not undoable. */
.site__known {
    margin-bottom: 0.9rem;
    padding-bottom: 0.9rem;
    border-bottom: 1px solid var(--border);
}

.site__known p { margin-bottom: 0.55rem; }

.site__relook button {
    padding: 0.35rem 0.8rem;
    font-size: 0.78rem;
    color: var(--muted);
}

/* The one-field domain form, shared by the hub's "add a site" box. */

.site__domainform { margin: 0 0 0.7rem; }

.site__domainform input {
    width: 100%;
    margin-bottom: 0.45rem;
    padding: 0.45rem 0.6rem;
    font-family: var(--mono);
    font-size: 0.82rem;
}

.site__domainform button { margin-top: 0.2rem; }

/* The line that says something is arriving from somewhere undeclared, which
   is the one thing on this page somebody has to act on. Loud enough to stop
   the eye, quiet enough not to read as an outage — nothing is broken, and the
   fix is one box above it. */
.site__setup .bad {
    margin-bottom: 0.7rem !important;
    padding: 0.55rem 0.7rem;
    border-radius: var(--radius-sm);
    background: var(--down-soft);
    color: var(--down) !important;
    font-size: 0.82rem !important;
}

.site__setup .bad code { color: inherit; }

.site__cta {
    margin: 0.3rem 0 0;
    text-align: center;
    font-size: 0.85rem;
}

/* The two lines around the one field pair on /join. */
.gate__lede {
    margin: 0 0 1rem;
    font-size: 0.84rem;
    line-height: 1.55;
    color: var(--muted);
}

.gate__note {
    margin: 0.7rem 0 0;
    font-size: 0.75rem;
    line-height: 1.5;
    color: var(--faint);
}

/* The one link on either of these two pages, and it is the way between them:
   /join offers /subscribe when the box is full, and /subscribe offers /join
   back. Underlined rather than left to colour alone — this is a paragraph of
   grey small print with one thing in it that can be pressed. */
.gate__note a {
    color: var(--muted);
    text-decoration: underline;
    text-underline-offset: 2px;
}

.gate__note a:hover { color: var(--accent); }

/* Answering a form that worked, where .bad answers one that did not. */
.gate__good {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin: 0 0 0.7rem;
    color: var(--accent);
    font-size: 0.8rem;
    font-weight: 600;
}

.gate input + input { margin-top: 0; }


/* ---------------------------------------------------------------- the hub

   Everything one account runs, side by side, at /a/<four-words>. See
   views/account.php.

   Wider than a site page because it is a grid rather than a column — 34rem is
   right for one site read top to bottom and wrong for three read across. Two
   per row where there is room, one where there is not, and never three: a card
   narrow enough for three of them has no room for a chart worth looking at. */

.hub { max-width: 56rem; }

.hub__total {
    margin: 0.5rem 0 0;
    font-size: 0.8rem;
    color: var(--muted);
}

.hub__cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
    gap: 0.9rem;
}

/* The whole card is the link, not the name inside it. On a phone the target is
   whatever the thumb lands on, and a card where only one word is pressable is a
   card that mostly does nothing. */
.hub__card {
    display: block;
    padding: var(--card-pad);
    color: inherit;
    text-decoration: none;
    transition: border-color 0.12s ease, transform 0.12s ease;
}

.hub__card:hover,
.hub__card:focus-visible {
    border-color: var(--accent-line);
    transform: translateY(-1px);
}

.hub__cardhead {
    display: flex;
    align-items: center;
    gap: 0.45rem;
    margin-bottom: 0.15rem;
}

.hub__icon { width: 24px; height: 24px; }

.hub__name {
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    overflow-wrap: anywhere;
}

.hub__name--slug { font-family: var(--mono); font-size: 0.9rem; }

.hub__domain {
    margin: 0 0 0.3rem;
    font-family: var(--mono);
    font-size: 0.72rem;
    color: var(--accent);
    overflow-wrap: anywhere;
}

.hub__quiet {
    margin: 0.4rem 0 0;
    font-size: 0.8rem;
    color: var(--faint);
}

/* Three across, tighter than a site page's four — a card is read at a glance
   and a glance takes in three numbers. */
.hub__figures {
    display: flex;
    gap: 0.2rem;
    margin: 0.5rem 0 0.2rem;
}

.hub__figures .metric {
    flex: 1 1 0;
    min-width: 0;
    padding: 0 0.4rem 0 0;
}

.hub__figures .num { font-size: 1.05rem; }
.hub__figures span { font-size: 0.66rem; }

/* Then the chart, at every width — the one place the hub departs from the
   pages that draw two of them.

   A site page and the overview draw their chart twice, compact and card, and
   let the media query at the foot of this file pick one. The compact copy is
   the taller of the two because it has a whole page column to be tall in. A
   hub card never has that: it is one of two in a grid at 56rem and one of one
   below that, which is the narrow card the long, low shape was drawn for in
   the first place. So views/account.php draws that shape alone, and this is
   what puts it on the screen at the widths the phone rule does not reach —
   without it the card is figures and nothing else on a desk, since the hide
   further up is where .chart--card starts.

   Padding and label size repeated from the phone rule rather than left to it:
   both selectors here outweigh it, so a hub card would otherwise carry the
   desk's spacing down onto a phone and be the one card on a narrow screen
   that did not come in a notch with the rest. */
.hub__card .chart--card {
    display: block;
    padding: 0.1rem 0.3rem 0;
}

.hub__card .chart--card .chart__label { font-size: 15px; }

/* And its key, which is not decoration here — a card's chart has no readout
   following the pointer and no tooltips under it, so these two words are the
   only thing on the card saying which line is which. */
.hub__card .chartkey--card {
    display: flex;
    gap: 0.15rem 0.7rem;
    padding: 0 var(--card-pad) 0.35rem;
    font-size: 0.62rem;
}

.hub__add { margin-top: 0.3rem; }

/* One row per site: whose it is, the field, and the button. On a phone the
   three stack and the field takes the width; on anything wider they sit on one
   line with the field taking whatever is left, so a column of them lines up. */
.hub__domains h2 { margin-bottom: 0.5rem; }

.hub__domainrow {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.5rem;
}

.hub__domainfor {
    flex: 0 1 8rem;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--muted);
}

.hub__domainrow input {
    flex: 1 1 11rem;
    min-width: 0;
    padding: 0.4rem 0.6rem;
    font-family: var(--mono);
    font-size: 0.8rem;
}

.hub__domainrow button {
    flex: none;
    padding: 0.4rem 0.8rem;
    font-size: 0.78rem;
}

.hub__extras { margin: -0.2rem 0 0.6rem !important; }

/* The undeclared-host warning, which is the one thing on this page somebody
   has to act on. Loud enough to stop the eye, quiet enough not to read as an
   outage — nothing is broken and the fix is the box under it. */
.hub__domains .bad {
    margin: 0 0 0.5rem !important;
    padding: 0.55rem 0.7rem;
    border-radius: var(--radius-sm);
    background: var(--down-soft);
    color: var(--down) !important;
    font-size: 0.82rem !important;
}

.hub__domains .bad code,
.hub__domains .bad strong { color: inherit; }

.hub__address { margin: 0.2rem 0 0; }
.hub__address code { overflow-wrap: anywhere; }

/* ------------------------------------------------------------- agent keys

   The list of what has been let in to an account, on the hub, and the one
   sentence the approval page shows before its button. See views/account.php
   and views/pair.php.

   Deliberately plainer than the site cards above it. This is an audit list —
   read once in a while, acted on rarely — and anything that made it compete
   with the cards for attention would be wrong about which of the two somebody
   opened this page for. */

.hub__keys { margin-top: 0.3rem; }

.hub__keylist {
    margin: 0.7rem 0 0.6rem;
    padding: 0;
    list-style: none;
    border-top: 1px solid var(--border);
}

/* A row that folds rather than scrolls. The revoke button is pushed to the
   far end on a wide screen and drops under the label on a narrow one, which
   is the right way round: on a phone the name is what identifies the row and
   it should never be the thing that gets truncated to make room. */
.hub__key {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.3rem 0.6rem;
    padding: 0.6rem 0;
    border-bottom: 1px solid var(--border);
}

.hub__keywho {
    flex: 1 1 12rem;
    min-width: 0;
}

.hub__keywho b { font-size: 0.9rem; }

/* The only part of a key that was ever stored, so it is the only part that can
   be shown. Enough to tell two rows apart and useless to anybody reading over
   a shoulder. */
.hub__keyid {
    margin-left: 0.4rem;
    font-family: var(--mono);
    font-size: 0.74rem;
    color: var(--faint);
    overflow-wrap: anywhere;
}

.hub__keywhen {
    flex: 1 1 11rem;
    font-size: 0.78rem;
    color: var(--faint);
}

.hub__keyout { flex: none; margin: 0; }

.hub__keyout button {
    padding: 0.32rem 0.7rem;
    font-size: 0.76rem;
}

/* Confirmation, in the same shape as .hub__domains .bad above and the opposite
   colour. Something was taken away and the page needs to say so — a list that
   is simply one shorter than it was is not an answer to a button press. */
.good {
    margin: 0 0 0.6rem;
    padding: 0.55rem 0.7rem;
    border-radius: var(--radius-sm);
    background: var(--up-soft);
    color: var(--up);
    font-size: 0.82rem;
}

/* What is being approved, on /pair. Bigger than the note under it because it
   is the sentence the decision is made from. */
.pair__what {
    margin: 0 0 0.9rem;
    font-size: 0.95rem;
    line-height: 1.5;
}

.pair__what code {
    font-family: var(--mono);
    font-size: 0.82rem;
    overflow-wrap: anywhere;
}

/* The code field, which is typed rather than pasted and is four characters and
   a word. Monospace and spaced out, so a digit misread is a digit noticed. */
.gate input[name="code"] {
    font-family: var(--mono);
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

/* ------------------------------------------------------- spans and tables

   The four-button range switch on a site page, and the two tables under its
   chart. See views/site.php. */

.site__spans {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    padding: var(--card-pad) var(--card-pad) 0;
}

/* Two side by side where there is room, stacked where there is not. Each is a
   card of its own so that one being empty does not leave a hole in the other. */
.site__tables {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
    gap: 0.9rem;
}

.site__table { padding: var(--card-pad); }

.site__table h2 {
    margin: 0 0 0.6rem;
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.site__rows {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

/* A bar behind each row rather than beside it, scaled against the biggest in
   the table — which is what turns a column of numbers into a shape the eye
   reads before it reads any of the numbers. Drawn as a background gradient so
   it costs no element and cannot be selected with the text. */
.site__rows li {
    position: relative;
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.6rem;
    padding: 0.3rem 0.45rem;
    border-radius: var(--radius-sm);
    background: linear-gradient(to right, var(--accent-soft) var(--fill, 0%), transparent var(--fill, 0%));
    font-size: 0.8rem;
}

.site__rowname {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--mono);
    font-size: 0.75rem;
    color: var(--text);
}

.site__rowcount {
    flex: none;
    font-weight: 600;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------------------
   The badge picker, on a site's own page.

   A form of radio buttons dressed as chips, which is the whole of it — the
   preview beside it is an <img> pointing at the real badge address, so nothing
   here styles a badge and nothing here can make one look better than it will
   look on somebody's page. See views/site.php.
   --------------------------------------------------------------------------- */

.picker {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    margin: 0 0 1rem;
    padding: 0.85rem 0.9rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--bg);
}

.picker__row {
    display: flex;
    align-items: baseline;
    gap: 0.7rem;
}

/* Fixed, so the chips start on one line however long the words above are. */
.picker__label {
    flex: 0 0 3.6rem;
    color: var(--faint);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
}

.picker__opts {
    display: flex;
    flex: 1 1 auto;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.3rem;
}

/* The input is the control and the span is the button. Moved off-screen rather
   than display:none, which would take it out of the tab order and off every
   screen reader — a picker nobody can reach by keyboard is a picker half the
   people here cannot use. */
.chip {
    position: relative;
    display: inline-flex;
    cursor: pointer;
}

.chip input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    padding: 0;
    border: 0;
    opacity: 0;
}

.chip span {
    padding: 0.28rem 0.62rem;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.78rem;
    font-weight: 600;
    line-height: 1.35;
    transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.chip:hover span { border-color: var(--accent-line); color: var(--text); }

.chip input:checked + span {
    border-color: transparent;
    background: var(--accent);
    color: #ffffff;
}

/* Focus has to live on the span, because the thing that actually has focus is
   a pixel nobody can see. */
.chip input:focus-visible + span {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* A colour is shown rather than named — seven words for seven swatches is a
   row of text where a row of colour reads instantly. The name is in the title
   attribute for anybody hovering, and in the input's own value for anybody
   listening. */
.chip--color span {
    width: 1.55rem;
    height: 1.55rem;
    padding: 0;
    border-radius: 999px;
    background: var(--swatch);
    box-shadow: inset 0 0 0 1px rgba(23, 26, 35, 0.12);
}

.chip--color:hover span { box-shadow: inset 0 0 0 1px rgba(23, 26, 35, 0.3); }

.chip--color input:checked + span {
    background: var(--swatch);
    box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--swatch);
}

.picker select {
    max-width: 100%;
    padding: 0.28rem 0.5rem;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--text);
    font: inherit;
    font-size: 0.78rem;
    font-weight: 600;
}

/* The button a browser with JavaScript never needs. head.php marks the root
   the moment a script runs, and the panel over the code block below takes over
   — which is the better control, because it only appears once there is
   something to save. */
/* The eighth swatch: any colour at all.

   A native <input type="color"> rather than anything hand-built. It is the one
   control on this page that opens the operating system's own picker, which is
   the picker people already know how to use and the only one that works on a
   phone. Dressed down to the size of the seven beside it — the default is a
   button with a border and a lot of padding — and given the same round shape,
   so the row reads as eight of one thing rather than seven and a widget. */
.picker__hex {
    flex: none;
    width: 26px;
    height: 26px;
    padding: 0;
    border: 1px solid var(--border-strong);
    border-radius: 50%;
    background: none;
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
}

.picker__hex::-webkit-color-swatch-wrapper { padding: 2px; }
.picker__hex::-webkit-color-swatch { border: none; border-radius: 50%; }
.picker__hex::-moz-color-swatch { border: none; border-radius: 50%; }

/* The one line saying a chosen colour may come back adjusted, shown only while
   a chosen colour is what is selected. */
.picker__aside {
    margin: 0;
    font-size: 0.75rem;
    line-height: 1.5;
    color: var(--faint);
    max-width: 30rem;
}

.picker__go {
    align-self: flex-start;
    padding: 0.35rem 0.8rem;
    font-size: 0.8rem;
}

:root[data-js] .picker__go { display: none; }

/* --------------------------------------------------------------- copy buttons

   One per code block, in the corner, put there by views/snippet.php.

   Opaque and sitting over the code rather than beside it: the first line of a
   snippet is usually its longest, so there is no clear space to sit in. The
   button is a sibling of the <pre> and not a child, so the code scrolls
   sideways underneath it while it stays where it is.

   Only ever drawn once a script has run. head.php marks the root the moment
   one does; before that this is a button that could not copy anything, and one
   of those is worse than none — it is pressed, nothing happens, and there is
   no way to tell whether it worked. */
.snippet__copy {
    position: absolute;
    /* Inside the block's own border rather than floating a few pixels off it.
       A button inset from the corner leaves a sliver of code showing down its
       right-hand side, which does not read as a button over code — it reads as
       something drawn in the wrong place. Filling the corner, with a rule down
       one side and along the bottom, makes it part of the block's chrome. */
    top: 1px;
    right: 1px;
    display: none;
    padding: 0.28rem 0.6rem;
    border: 0;
    border-left: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
    border-radius: 0 6px 0 6px;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.7rem;
    font-weight: 600;
    line-height: 1.3;
}

/* No width is set, deliberately. The word changes to "Copied" and, where the
   clipboard was refused, to "Selected" — and anchored to the right corner a
   wider word grows leftward, so the button never moves out from under the
   pointer that is still resting on it. */
:root[data-js] .snippet__copy { display: block; }

.snippet__copy:hover { color: var(--text); }

.snippet__copy:active { background: var(--bg); }

/* The setup page's blocks sit on the card colour rather than the page's, so a
   button painted the same would have nothing but its two rules to be seen by. */
.setup .snippet__copy { background: var(--bg); }

/* ------------------------------------------------------- the stale code panel

   The snippet describes the badge that is saved. The preview above it follows
   the picker live. So the moment somebody changes a control the two have
   parted, and this covers the code to say so.

   Covering it rather than annotating it is the decision worth stating: what is
   underneath is now the wrong thing to copy, and a warning beside code is a
   warning people copy past. The panel goes when the two agree again, which is
   either saving or putting the controls back. */
.snippet__box {
    position: relative;
    margin: 0 0 0.9rem;
}

/* The box carries the spacing now that every snippet is inside one, and the
   block makes room above itself for the button in its corner.
 
   Room rather than overlap, which is the second thing tried here and the one
   that survives. A button sitting on top of the code covers most of the first
   line and leaves the bottom two pixels of it showing underneath — letter
   bottoms with no letters above them, which reads as a rendering fault rather
   than as a control. Sizing the button to cover the line exactly would work
   until the day a font size changed. This cannot be wrong. */
.snippet__box .snippet {
    margin-bottom: 0;
    padding-top: 2.05rem;
}

/* The setup page styles its own code blocks — roomier, on the card's ground
   rather than the page's, because that page has no cards. `.setup pre` beats
   `.snippet` on specificity and always did; this is the one thing it cannot
   reach, because the rule it is up against is on the <code> inside. */
.setup .snippet code { font-size: 0.8rem; }

.snippet__stale {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.55rem;
    padding: 0.7rem 0.9rem;
    border-radius: var(--radius-sm);
    /* Opaque first for anything that cannot mix, then a shade of translucency
       so it reads as code covered over rather than as code gone. */
    background: var(--surface);
    background: color-mix(in srgb, var(--surface) 93%, transparent);
    backdrop-filter: blur(2px);
    box-shadow: inset 0 0 0 1px var(--border-strong);
    text-align: center;
}

/* The UA's `display: none` for [hidden] loses to the rule above, which is how
   a panel meant to be hidden ends up permanently on screen. */
.snippet__stale[hidden] { display: none; }

.snippet__stale p {
    margin: 0 !important;
    font-size: 0.78rem !important;
    line-height: 1.45;
    color: var(--muted) !important;
}

.snippet__stale button {
    padding: 0.34rem 0.85rem;
    font-size: 0.8rem;
}

.snippet__stale button[disabled] { opacity: 0.55; cursor: default; }

/* A phone's code block is short enough that a centred column does not fit.
   The message goes and the button stays, which is the half that does anything.

   Except when the message is the half that does something: a save that failed
   says why here, and a sentence nobody can read is the same as no sentence.
   The script marks the panel while it is carrying one — see say() at the foot
   of views/site.php — and a live region set to display:none is not announced
   either, so this is what makes the failure reach a screen reader as well. */
@media (max-width: 30rem) {
    .snippet__stale p { display: none; }
    .snippet__stale[data-state] p { display: block; }
}

/* Two by two rather than a row. This card is a fixed column whatever the
   screen is, so four readings never fit a line — and three with a fourth
   hanging underneath reads as a mistake where a square reads as a decision. */
.site__figures .metric { flex: 1 1 40%; }

/* The badge, shown as it will appear rather than described. On the page's own
   ground, not the card's, so that what is being previewed is the badge and not
   the badge plus whatever is behind it here. */
.site__badgepreview img {
    display: block;
    max-width: 100%;
    height: auto;
}
