/* =============================================================================
   Xandev V3 - the application shell

   One shell for the three signed-in apps: admin, client portal, hosting panel.
   Loaded by those three and deliberately NOT by the marketing site, which is
   why this is its own file rather than more of ui.css: the public site is the
   one that has to be fast, and it would be downloading a sidebar it never
   renders. `console assets:sync` copies it into the three docroots.

   It exists because there were two of everything. admin.css and portal.css had
   grown 53 pairs of same-named classes between them - .adm-bar and .pt-bar,
   .adm-main and .pt-main, two signature pads, two message threads, and two
   identical 21-step ladders of progress-width classes. Only 22 of those pairs
   were even byte-identical; the rest had quietly drifted apart, which is what
   two copies of one design always do.

   The vocabulary here is the hosting panel's, because that is the look being
   standardised on: a hairline brand rule over an eyebrow and a heading, solid
   cards with meters, tiles in labelled groups, a sticky rail of facts. The
   shell mechanics are the admin's, because its sidebar had already solved the
   problems the portal's top nav had not.

   Naming: `app-` for the shell itself, bare names for components that are
   generic enough to read without one. Nothing here collides with base.css or
   ui.css - in particular `.section` is the marketing page rhythm and is a
   different thing, hence `.app-section`.
   ========================================================================== */

/* -----------------------------------------------------------------------------
   Page frame

   The whole page is a column that is at least as tall as the viewport, so the
   content area takes up the slack and anything after it - the portal's footer -
   sits on the bottom edge rather than floating up under a short page. A sign-in
   screen is the extreme case: one small card, and without this the footer
   landed a third of the way down with black below it.

   100dvh after 100vh because on a phone 100vh is the tall viewport, measured
   as though the browser chrome were retracted, which leaves the last inch of
   the page under the address bar. dvh is the one that is actually visible.
   -------------------------------------------------------------------------- */

.app-body {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
    background: var(--surface-page);
}

/*
    Whichever of the two is the content area takes the slack: the shell when
    somebody is signed in, and .app-main on its own when they are not and there
    is no sidebar to put beside it.

    min-height: 0 because a flex item will not shrink below its content's
    intrinsic height without it, which is what lets a long table inside scroll
    rather than stretching the whole page.
*/
.app-body > .app-shell,
.app-body > .app-main {
    flex: 1 1 auto;
    min-height: 0;
}

/* -----------------------------------------------------------------------------
   Masthead

   The single glass moment in the whole design system. Everything else is a
   solid surface with a hairline border.
   -------------------------------------------------------------------------- */

.app-bar {
    position: sticky;
    top: 0;
    z-index: var(--z-sticky);

    display: flex;
    align-items: center;
    gap: var(--space-5);

    padding: var(--space-3) var(--space-5);
    border-block-end: 1px solid var(--line);
    background: var(--glass-bg);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
}

.app-brand {
    display: flex;
    align-items: center;
    gap: var(--space-3);

    /*
        Shrinkable, and the only thing in the bar that is.

        It used to be flex-shrink: 0 like everything else, which meant a long
        client name simply pushed the sign-out button off the right of a phone
        and the whole page scrolled sideways. The buttons are a fixed size and
        all of them have to stay reachable, so the name is what gives.

        min-width: 0 as well, because a flex item will not shrink below its
        content's intrinsic width without it and the ellipsis would never
        engage.
    */
    min-width: 0;

    color: var(--text-primary);
    font-family: var(--font-display);
    font-variation-settings: "wdth" 118;
    font-size: var(--text-lg);
    font-weight: 700;
    letter-spacing: -0.03em;
    text-decoration: none;
}
.app-brand:where(:hover) { text-decoration: none; }

/* Concrete on the mark, inherited on the word. That split is the whole reason
   the logo is inline SVG rather than an <img>: the brackets carry the brand
   colour while the wordmark flips with the theme, and an external SVG cannot
   see this page's CSS to do either. */
.app-brand svg {
    display: block;
    width: 1.625rem;
    height: 1.625rem;
    flex: none;
    color: var(--brand);
}

/* The word, which is what actually gets cut when there is no room. */
.app-brand > span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.app-brand__accent { color: var(--brand-text); }

/* Everything after this sits on the right. */
.app-bar__right {
    display: flex;
    align-items: center;
    gap: var(--space-3);

    /* Never shrinks: everything in here is a control, and a control squeezed
       to nothing is a control nobody can press. */
    flex-shrink: 0;
    margin-inline-start: auto;
}

.app-who {
    font-size: var(--text-sm);
    color: var(--text-secondary);
    white-space: nowrap;
}

.app-signout {
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--text-secondary);
    font-family: inherit;
    font-size: var(--text-xs);
    cursor: pointer;
    transition: border-color var(--duration) var(--ease),
                color var(--duration) var(--ease);
}
.app-signout:where(:hover) {
    border-color: var(--line-strong);
    color: var(--text-primary);
}

/* A status pill in the masthead: which host, how fresh, who you are. */
.app-pill {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);

    padding: 0.3rem 0.7rem;
    border: 1px solid var(--line);
    border-radius: var(--radius-full);
    background: var(--surface-overlay);

    font-family: var(--font-mono);
    font-size: var(--text-3xs);
    color: var(--text-secondary);
    white-space: nowrap;
}

/* -----------------------------------------------------------------------------
   Shell and sidebar

   Fifteen entries in a single row had stopped being navigation and become a
   list to read. Grouped down the side they are all visible at once, nothing is
   behind a menu, and the headings do the work of saying which part of the
   business a screen belongs to.
   -------------------------------------------------------------------------- */

.app-shell {
    display: grid;
    grid-template-columns: 13.5rem minmax(0, 1fr);
}

/*
    The column, which carries the rule down the side of the page.

    Two elements rather than one, and that is the whole point of the inner
    div. `align-items: start` used to sit on the grid so the sidebar could be
    sticky, which made it only as tall as its own links: the rule ran down
    beside the navigation and then stopped dead in mid-air with the bottom of
    the page undivided. On the portal that was the last 240 pixels, on the
    hosting panel, which has two links, it was 840 of them - a short stub of a
    line hanging under the menu and nothing below it.

    So the column stretches, which is what a grid item does by default, and it
    is the thing that draws the border. The sticking and the scrolling move
    inside it, where being shorter than the page is the correct behaviour
    rather than a visible defect.
*/
.app-side {
    border-inline-end: 1px solid var(--line);
}

.app-side__inner {
    position: sticky;
    /* Clears the bar, whose height is its padding plus a line of text. */
    top: 3.25rem;
    max-height: calc(100vh - 3.25rem);
    overflow-y: auto;

    display: flex;
    flex-direction: column;
    gap: var(--space-5);

    padding: var(--space-6) var(--space-4) var(--space-10);
    scrollbar-width: thin;
}

.app-side__group { display: flex; flex-direction: column; gap: 1px; }

.app-side__heading,
.app-drawer .app-side__heading {
    margin: 0 0 var(--space-2);
    padding-inline: var(--space-3);
    font-family: var(--font-mono);
    font-size: var(--text-3xs);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}

.app-side a,
.app-drawer a {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);

    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    font-weight: 500;
    color: var(--text-secondary);
    text-decoration: none;
    white-space: nowrap;

    transition: background-color var(--duration) var(--ease),
                color var(--duration) var(--ease);
}
.app-side a:where(:hover),
.app-drawer a:where(:hover) {
    background: var(--surface-hover);
    color: var(--text-primary);
    text-decoration: none;
}
.app-side a[aria-current="page"],
.app-drawer a[aria-current="page"] {
    background: color-mix(in oklab, var(--brand) 16%, transparent);
    color: var(--brand-text);
}

/* A count riding on a nav entry: agreements waiting, unread leads. */
.app-side__count {
    padding: 0.05rem 0.4rem;
    border-radius: var(--radius-full);
    background: color-mix(in oklab, var(--brand) 22%, transparent);
    color: var(--brand-text);
    font-family: var(--font-mono);
    font-size: var(--text-3xs);
    font-weight: 600;
}

.app-main {
    max-width: 82rem;
    margin-inline: auto;
    padding: var(--space-6) var(--space-5) var(--space-10);
}

/*
    margin-inline is reset here, and that is the whole point of this rule.

    .app-main carries `margin-inline: auto`, which is right when it is the only
    thing on the page. Inside the shell grid it is a grid item, and a grid item
    with auto inline margins shrink-wraps to its content and centres itself
    instead of stretching. Every screen then came out as wide as whatever
    happened to be on it: a table-heavy page ran wide, a page of short answers
    sat squeezed in the middle, and the left edge moved between pages. Zero lets
    the item stretch as grid items normally do, with max-width doing the capping.
*/
.app-shell .app-main {
    max-width: 74rem;
    margin-inline: 0;
}

.app-flash {
    display: grid;
    gap: var(--space-3);
    margin-block-end: var(--space-5);
}

/*
    Narrow: the name goes and the bar tightens. Both apps did this at 52rem
    before the shell was shared, and a phone masthead carrying a brand, a
    theme switch, a full name and a sign-out button has one thing too many
    on it.
*/
@media (max-width: 52rem) {
    /*
        The name goes, the pill goes, and the bar tightens. A phone masthead
        carrying a menu button, a brand, a host pill, a theme switch, a full
        name and a sign-out button has three things too many on it, and the
        two that are only telling you something are the two that go.
    */
    .app-who { display: none; }
    .app-pill { display: none; }
    .app-bar {
        gap: var(--space-3);
        padding-inline: var(--space-4);
    }
}

/* -----------------------------------------------------------------------------
   The drawer

   A <details>, like the marketing header and the admin folds. It opens, closes
   and answers the keyboard on its own, so navigation never depends on script
   running and there is no open state for an aria attribute to disagree with.
   -------------------------------------------------------------------------- */

/* Hidden until the sidebar goes. */
.app-menu { display: none; }

.app-menu__button {
    display: grid;
    place-items: center;
    width: 2.25rem;
    height: 2.25rem;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    color: var(--text-secondary);
    cursor: pointer;
    list-style: none;
    transition: border-color var(--duration) var(--ease),
                color var(--duration) var(--ease);
}
.app-menu__button::-webkit-details-marker { display: none; }
.app-menu__button:where(:hover) {
    border-color: var(--line-strong);
    color: var(--text-primary);
}
.app-menu__button:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
}

.app-menu__icon { width: 1.125rem; height: 1.125rem; }
.app-menu__icon--close { display: none; }
.app-menu[open] .app-menu__icon--open { display: none; }
.app-menu[open] .app-menu__icon--close { display: block; }

/*
    The panel hangs off the bar. `position: sticky` on .app-bar establishes a
    containing block, so this anchors to the bar rather than to the page and
    travels with it when the page scrolls.

    Capped at the visible viewport minus the bar, and scrollable, because the
    admin has nineteen entries in six groups and on a short phone in landscape
    that is taller than the screen.
*/
.app-menu__panel {
    position: absolute;
    inset-inline: 0;
    top: 100%;

    max-height: calc(100dvh - 3.25rem);
    overflow-y: auto;
    padding: var(--space-4);

    background: var(--surface-overlay);
    border-block-end: 1px solid var(--line);
    box-shadow: var(--shadow-3);
}

.app-drawer {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    max-width: var(--container);
    margin-inline: auto;
}

/*
    Narrow: the sidebar is replaced by the drawer in the masthead.

    It used to collapse into a horizontal scrolling strip. That is fine for
    five entries and wrong for fifteen: the admin's ran off the side of the
    screen with the last third of it reachable only by a sideways flick, and
    the portal's cut "Invoices" in half. A strip that hides links is not
    navigation, it is a hiding place.
*/
@media (max-width: 64rem) {
    .app-shell { display: block; }
    .app-side { display: none; }
    .app-side__inner { position: static; max-height: none; }
    .app-menu { display: block; }
    .app-shell .app-main { max-width: none; }
}

/* -----------------------------------------------------------------------------
   Section rhythm

   A hairline of brand gradient, then an eyebrow and a heading. This is one of
   exactly two places the gradient is allowed to appear; the other is the
   active tab underline. Do not add a third.
   -------------------------------------------------------------------------- */

.app-section { margin-block-start: var(--space-8); }
.app-section:first-of-type { margin-block-start: 0; }

/*
    Stacked, not strung along one baseline.

    This was flex with align-items: baseline, which on a wide screen fitted the
    eyebrow, the heading and the description onto a single line: the panel's
    account screen read as "ACCOUNT madhatchet.example A tenant with mail
    hosted elsewhere, which is the ordinary case here", with the name of the
    thing you are looking at buried mid-sentence. A page title is not a clause.

    Two columns so actions can sit out to the right across the full height of
    the block, which is where a New quote button belongs.
*/
.app-head {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    gap: var(--space-1) var(--space-4);
    padding-block: var(--space-4) var(--space-5);
}
.app-head > .eyebrow,
.app-head h1,
.app-head h2,
.app-head p {
    grid-column: 1;
}
.app-head h1,
.app-head h2 {
    margin: 0;
    font-size: var(--text-xl);
    font-weight: 700;
}
.app-head p {
    margin: 0;
    max-width: 68ch;
    color: var(--text-secondary);
    font-size: var(--text-xs);
}

.app-head__actions {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: center;

    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}

/* -----------------------------------------------------------------------------
   Stats
   -------------------------------------------------------------------------- */

.stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: var(--space-4);
}

/* Flex rather than grid so the note can be pushed to the bottom: not every
   stat has a meter, and without this the cards in a row end their notes at
   different heights. */
.stat {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
}
.stat__note { margin-block-start: auto; }

.stat__label {
    font-family: var(--font-mono);
    font-size: var(--text-3xs);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}

.stat__value {
    font-family: var(--font-display);
    font-variation-settings: "wdth" 108;
    font-size: var(--text-2xl);
    font-weight: 700;
    line-height: 1;
    letter-spacing: -0.03em;
    font-variant-numeric: tabular-nums;
}
.stat__value small {
    font-size: var(--text-sm);
    font-weight: 500;
    color: var(--text-muted);
    letter-spacing: 0;
}

.stat__note {
    font-size: var(--text-2xs);
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

/* -----------------------------------------------------------------------------
   Meter

   A bar whose length is a number, drawn as an <svg> whose fill width is a
   presentation ATTRIBUTE rather than an inline style.

   This is not a stylistic preference. The CSP carries no 'unsafe-inline' for
   styles and a nonce does not cover style attributes, so `style="width:72%"`
   is dropped silently: the bar renders empty and nothing appears in the
   console to say why. An attribute is not CSS, so the policy leaves it alone.

   It replaces two identical 21-step ladders of `__fill--0` through
   `__fill--100` classes, one in admin.css and one in portal.css, which were
   the older answer to the same wall: 42 rules to round a percentage to the
   nearest five, twice. This is exact and is four.

   The track is the element's own background and the radius clips the fill, so
   the fill needs no rounding of its own - which matters, because
   preserveAspectRatio="none" would stretch any it had into a lozenge.
   -------------------------------------------------------------------------- */

.meter {
    display: block;
    width: 100%;
    height: 5px;
    border-radius: var(--radius-full);
    background: var(--surface-sunken);
    overflow: hidden;
}
/* `ok` is the default and is spelled out anyway, so all three tones read here
   and nobody has to wonder whether meter--ok is a class that got deleted. */
.meter__fill { fill: var(--success); }
.meter--ok   .meter__fill { fill: var(--success); }
.meter--warn .meter__fill { fill: var(--warning); }
.meter--bad  .meter__fill { fill: var(--danger); }

/* Progress toward a goal rather than consumption of a limit, so it carries the
   brand rather than a severity: a project at 90% is good news. */
.meter--brand .meter__fill { fill: var(--brand); }

.meter--tall { height: 8px; }

/*
    For a table whose cells are single values rather than prose: a Unix
    username, a PHP version, a feature list. Wrapping those splits one value
    across two lines - "madhatchet," above "1010" - which reads as damage. The
    table is already inside .table-wrap, so the honest answer when it does not
    fit is to scroll it.
*/
.table--nowrap th,
.table--nowrap td { white-space: nowrap; }

/* -----------------------------------------------------------------------------
   Chips
   -------------------------------------------------------------------------- */

.chips {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
}

.chip {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);

    padding: 0.4rem 0.7rem;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--surface-overlay);

    font-family: var(--font-mono);
    font-size: var(--text-2xs);
    color: var(--text-secondary);
}

/* -----------------------------------------------------------------------------
   Tiles

   A grid of ways in. Renders as a link when there is somewhere to go and as an
   inert span when there is not, which is deliberate: a control that looks live
   and does nothing is worse than one that is visibly not ready.
   -------------------------------------------------------------------------- */

.tiles {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
    gap: var(--space-3);
}

.tile {
    display: flex;
    align-items: center;
    gap: var(--space-3);

    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--line);
    border-radius: var(--radius-md);
    background: var(--surface-raised);
    box-shadow: var(--edge-highlight);

    color: var(--text-primary);
    font-size: var(--text-xs);
    font-weight: 600;
    text-align: start;
    text-decoration: none;

    transition: border-color var(--duration) var(--ease),
                background-color var(--duration) var(--ease),
                transform var(--duration) var(--ease);
}
.tile:where(:hover) {
    border-color: var(--line-strong);
    background: var(--surface-hover);
    transform: translateY(-2px);
    text-decoration: none;
}
.tile svg {
    width: 17px;
    height: 17px;
    flex: none;
    color: var(--brand-text);
}
.tile__sub {
    display: block;
    margin-block-start: 1px;
    font-weight: 400;
    font-size: var(--text-3xs);
    color: var(--text-muted);
}

.tile[aria-disabled="true"] {
    opacity: 0.62;
    cursor: default;
}
.tile[aria-disabled="true"]:where(:hover) {
    transform: none;
    background: var(--surface-raised);
    border-color: var(--line);
}

/* A labelled run of tiles, with a rule running out to the edge. */
.group { margin-block-start: var(--space-5); }
.group:first-child { margin-block-start: 0; }

.group__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin-block-end: var(--space-3);
}
.group__head::after {
    content: "";
    flex: 1;
    height: 1px;
    background: var(--line-faint);
}

/* -----------------------------------------------------------------------------
   Two columns with a sticky rail of facts
   -------------------------------------------------------------------------- */

.columns {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 300px;
    gap: var(--space-6);
    align-items: start;
}
@media (max-width: 60rem) {
    .columns { grid-template-columns: minmax(0, 1fr); }
}

.rail {
    display: grid;
    gap: var(--space-4);
    position: sticky;
    top: 4.5rem;
}
@media (max-width: 60rem) {
    .rail { position: static; }
}
.rail h3 {
    margin: 0 0 var(--space-4);
    font-size: var(--text-base);
    font-weight: 700;
}
.rail__blurb {
    margin: 0 0 var(--space-4);
    font-size: var(--text-xs);
    color: var(--text-secondary);
}

/* A term and its value on one line, term left, value right. */
.kv { display: grid; gap: var(--space-3); margin: 0; }
.kv > div {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-4);
}
.kv dt {
    font-family: var(--font-mono);
    font-size: var(--text-3xs);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.kv dd {
    margin: 0;
    font-size: var(--text-xs);
    text-align: end;
    font-variant-numeric: tabular-nums;
    word-break: break-all;
}

/* A labelled figure over a meter. */
.usage { display: grid; gap: var(--space-2); }
.usage + .usage { margin-block-start: var(--space-4); }
.usage__top {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-3);
    font-size: var(--text-xs);
}
.usage__top b {
    font-family: var(--font-mono);
    font-weight: 500;
    font-size: var(--text-2xs);
    color: var(--text-secondary);
}

/* -----------------------------------------------------------------------------
   Note

   An aside about how something works, or does not yet.
   -------------------------------------------------------------------------- */

.note {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);

    margin-block-start: var(--space-8);
    padding: var(--space-4);
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-md);
    background: var(--surface-sunken);

    font-size: var(--text-2xs);
    color: var(--text-secondary);
}
.note svg {
    width: 15px;
    height: 15px;
    flex: none;
    margin-block-start: 1px;
    color: var(--accent-text);
}
.note b { color: var(--text-primary); }

/* -----------------------------------------------------------------------------
   Status badges

   ui.css owns `.badge` and the four tones. These are the named statuses the
   apps actually store, mapped onto those tones once.

   The admin wrote them as `.badge--won` and the portal as `.pill--paid`, two
   components with two vocabularies that overlapped on five names and disagreed
   about the rest. One set here, so a status added to the database gets a colour
   decided in one place rather than in whichever app renders it first.

   Grouped by what the colour MEANS, not by which table the word came from:
   green is settled and good, amber is waiting on somebody, red is a problem or
   a refusal, grey is not started.
   -------------------------------------------------------------------------- */

/* Settled, and the outcome was the good one. */
.badge--won,
.badge--paid,
.badge--complete,
.badge--done,
.badge--accepted,
.badge--replied {
    background: var(--success-surface);
    color: var(--success);
}

/* In flight, or waiting on somebody. */
.badge--in_progress,
.badge--review,
.badge--sent,
.badge--viewed,
.badge--partial,
.badge--on_hold {
    background: var(--warning-surface);
    color: var(--warning);
}

/* A problem, or a no. */
.badge--lost,
.badge--overdue,
.badge--declined,
.badge--expired,
.badge--blocked,
.badge--cancelled,
.badge--spam {
    background: var(--danger-surface);
    color: var(--danger);
}

/* Not started yet, or merely seen. `new` is deliberately the accent rather
   than a tone: an unread lead is not a warning, it is just unopened. */
.badge--new {
    background: var(--info-surface);
    color: var(--info);
}
.badge--planning,
.badge--read {
    background: var(--surface-overlay);
    color: var(--text-secondary);
}
