/* Overlays — the geometry half of the app's one floating-box primitive.
 *
 * Controller: js/components/overlay.js. Contract: docs/frontend/overlays.md.
 *
 * Every overlay is an element with the native `popover` attribute and the class
 * `.pop`, which the controller adds on first open. This file says WHERE the box is
 * and what may scroll; pop-menu.css says what it LOOKS like, on the same `.pop`.
 * The two never overlap, which is why this file declares no surface. */

.pop {
    /* The UA stylesheet centres a popover with `inset: 0; margin: auto`. All of it
       has to go, or the coordinates the controller writes have nothing to act on.

       `!important` is a deliberate floor, not a specificity patch. This is the one
       declaration in the system that is a correctness requirement rather than a
       style: the controller writes coordinates computed against the VIEWPORT
       (`strategy: 'fixed'`), so any `position` but `fixed` resolves them against the
       wrong box and the overlay slides away as the page scrolls. Bootstrap still
       ships exactly that — `.navbar-expand-lg .navbar-nav .dropdown-menu`, three
       components against this rule's one. Nothing a body's own stylesheet says about
       `position` can be allowed to reach an overlay. */
    position: fixed !important;
    inset: auto;
    margin: 0;
    top: 0;
    left: 0;
    float: none;

    /* No z-index anywhere in this file. The top layer paints above every stacking
       context in the page by definition, which is what retired the arithmetic
       against the modal's 1050 and select2's 1000000.

       No surface either — no padding, border, background or colour. Those are
       pop-menu.css's, on `.pop:not([data-pop-bare])`. This file loads last, so
       declaring any of them here would beat that rule and every component card at
       equal specificity: a bare body would lose the card it paints for itself. */
    overscroll-behavior: contain;

    /* A box wider than the window cannot be shifted back into it. Geometry, and
       the one width this file states — everything else about how wide a body is
       comes from the trigger's `data-pop-width`. */
    max-width: calc(100vw - 16px);

    /* The UA stylesheet gives every [popover] `overflow: auto`, which puts a pair of
       scrollbars on a box that has no reason to scroll — and a second, horizontal
       one on a menu that only asked for `overflow-y`. Nothing scrolls unless the
       rules below say so. */
    overflow: visible;

    /* WHERE the box is arrives as `translate`, written by the controller. The
       entrance owns `transform`. They are separate properties that multiply
       together, so the animation never has to know the coordinates and a
       re-placement never has to reconstruct the animation — and both are
       composited, so neither costs a layout.

       This is why placement does not use `left`/`top`: those are layout
       properties, and the controller rewrites them on every animation frame for
       as long as the overlay is open. Laying out a box with a scroll region
       inside it, sixty times a second, underneath a running transition, is what
       made the whole thing stutter. */
    opacity: 0;
    transform: translateY(-4px);

    /* Leaving: quick, and `linear` on purpose. An exit is short enough that an
       eased curve has nowhere to put its acceleration — at 120ms `ease` spends a
       third of the fade in one frame, which is a blink rather than a fade. Linear
       across the same 120ms moves no frame more than 14%. Short because nobody
       studies an overlay on its way out, and a slow exit is what makes a UI feel
       unresponsive to a second click.

       `overlay` keeps the box in the top layer for the length of it;
       `allow-discrete` is what makes the display change animatable. */
    transition: opacity .12s linear, transform .12s linear,
                overlay .12s allow-discrete, display .12s allow-discrete;
}

/* The open-and-positioned state selector.
 *
 * `[data-pop-placed]` is the controller saying "this box has real coordinates". It
 * has to be asked for, because being open and being positioned are two different
 * moments: the browser puts the box in the top layer synchronously, but
 * `computePosition` is async, so there is at least one frame where the box is open
 * and still sitting at the base `top: 0; left: 0` above. Without the gate that frame
 * is painted, and an overlay opened for the first time flashes in the corner of the
 * window before jumping to its trigger.
 *
 * Set on first successful placement and never cleared, so a reopen has coordinates
 * to fade in at. */
.pop:popover-open[data-pop-placed] {
    opacity: 1;
    transform: none;
    /* Arriving: `ease-out`, and the curve matters more than the duration. An
       expo-out (`cubic-bezier(.16, 1, .3, 1)`) is the fashionable choice and is
       wrong for opacity — measured frame by frame it puts 47% of the fade in the
       FIRST frame and then crawls through the remaining 53% over eight more, which
       reads as the box snapping to half-visible and then hanging. `ease-out` spreads
       the same 180ms evenly: no single frame moves it more than 15%. */
    transition: opacity .18s ease-out, transform .18s ease-out,
                overlay .18s allow-discrete, display .18s allow-discrete;
}

/* Held for the whole time it is open, not just while a transition runs: the
   controller re-places on every frame, and a promoted layer is the difference
   between that being a composite and being a repaint. Dropped on close with the
   class, so nothing keeps a layer alive for a box nobody is looking at. */
.pop:popover-open {
    will-change: transform, translate, opacity;
}

/* A closed overlay is not laid out. Full stop.
 *
 * This restates the UA's `[popover]:not(:popover-open) { display: none }`, which
 * exists but loses to ANY author `display` on the body — a `.d-flex` utility, a
 * component's own flex column — because author CSS outranks the UA sheet. The result
 * is a box that is invisible (opacity 0, above) yet still occupies space and still
 * swallows every click that lands on it, which reads as "it closed but the page is
 * broken now" and is miserable to track down. The destination panel did exactly this.
 *
 * `!important` because that is the only thing that beats an unscoped author rule, and
 * there is no legitimate case for rendering a closed overlay. `allow-discrete` in the
 * transition above still holds the box visible for the length of its exit.
 *
 * On `[popover]`, NOT on `.pop`. The class is the controller's and arrives on first
 * open, so scoping this to it left every body that had never been opened with nothing
 * restating the UA rule — and `.pop-menu` declares `display: flex` on exactly such a
 * body, which is the box itself whenever the rows are its direct children. Unopened
 * menus were laid out in the page wearing the UA's own popover chrome.
 *
 * With this in place, a body may declare its own `display` unscoped and be safe. */
[popover]:not(:popover-open) {
    display: none !important;
}

/* For the case where placement lands before the first paint — `computePosition`
   resolves in a microtask, so it can — and the two style changes coalesce into one.
   Without a starting state there is nothing to transition FROM and the box appears
   instantly; with it, the entrance runs either way. */
@starting-style {
    .pop:popover-open[data-pop-placed] {
        opacity: 0;
        transform: translateY(-4px);
    }
}

/* Flipped above its trigger, so it rises from below instead of dropping in. */
.pop[data-pop-side="top"] {
    transform: translateY(4px);
}

@starting-style {
    .pop[data-pop-side="top"]:popover-open[data-pop-placed] {
        transform: translateY(4px);
    }
}

/* Light dismiss needs a backdrop to exist, but an anchored overlay must not dim
   anything. The sheet below is the one that does. */
.pop::backdrop {
    background: transparent;
}

.pop:focus,
.pop:focus-visible {
    outline: none;
}

/* --- what scrolls --------------------------------------------------------- */

/* Two caps, and the lower wins.
 *
 * `--pop-avail-height` is the room the `size` middleware found wherever the box
 * actually landed — that is what makes an overlay opened from a row near the bottom
 * of the page shrink instead of running off the screen. The viewport cap below is the
 * backstop for the frames before the first placement lands, and clears `--topbar-h`
 * (layout.css) because the topbar paints above an overlay, so a box reaching the top
 * of the window would disappear under it. */
:root {
    --pop-viewport-height: calc(100dvh - var(--topbar-h, 0px) - 16px);
}

/* The box scrolls itself — a fetched form, a client-built confirm, a filter panel:
   every overlay the app dresses that is not itself a container of rows.
 *
 * `.pop-menu` is out of it because a rows container states its own cap and its own
 * scroll region (pop-menu.css), and two rules capping one element is how a menu ends
 * up with the wrong one of the two answers. The two selectors are mutually exclusive,
 * so exactly one cap applies to any element: `.pop-menu` on the box means the menu's
 * cap, `.pop-menu` on a child means this one for the box and the menu's for the child
 * — which the box never reaches, because its content is a bar plus a bounded list.
 *
 * `[data-pop-bare]` is out of it too, and that is the whole point of the opt-out: a
 * body that paints its own card also states its own height and runs its own scroll
 * region (`.upoly-panel` is a flex column with a scrolling results list). Capping and
 * clipping it from here would take both away.
 *
 * Every geometry rule from here down says whether it means an ANCHORED overlay or a
 * sheet, rather than leaving it to source order — a sheet is sized by the screen it
 * lies on. Left implicit, `.pop:popover-open:has(…)` (three components) quietly
 * outranked `.pop.is-sheet` (two) and handed the sheet the anchored cap, so it grew
 * to its content and ran off the bottom of the screen. */
.pop:not(.is-sheet):not([data-pop-bare]):not(.pop-menu) {
    max-height: min(var(--pop-viewport-height), var(--pop-avail-height, 100dvh));
    /* Vertical only: a menu is as wide as it is, and an unasked-for horizontal
       scrollbar steals a row's worth of height at the bottom of every menu. */
    overflow: hidden auto;
}

/* A box that IS the rows container scrolls itself, which pop-menu.css already says.
   Restated here because this file loads after that one and `.pop`'s `overflow:
   visible` above ties with `.pop-menu`'s on specificity, so source order handed a
   box-level menu `visible`: the cap still applied, nothing clipped, and the rows past
   it painted straight through the bottom edge onto the page. Two components, so it
   wins the tie the other way round. The cap itself is pop-menu.css's, on a selector
   this file never states. */
.pop.pop-menu {
    overflow: hidden auto;
}

/* A box that HOLDS a rows container hands the scrolling to it and does none of its
   own. Both caps read `--pop-avail-height`, so a list free to fill the room by itself
   plus the pinned bars above it stands taller than the box's cap by exactly the height
   of those bars — and the box answered that with a second scrollbar down its own edge,
   beside the list's. As a flex column the bars take their room first and the list
   shrinks into what is left, which is the one scrollport the menu asked for.
   (`.pop-menu` scrolls, so its automatic minimum size is already 0 and it shrinks
   without being told to; the bars have visible overflow and hold their content.) */
.pop:not(.is-sheet):not([data-pop-bare]):not(.pop-menu):has(> .pop-menu) {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* --- the fetched-body singleton ------------------------------------------- */

/* One #pop, reused, so an htmx swap replaces its contents and leaves the open
   overlay in place. It carries no class and no id styling of its own: it is a
   `.pop` like every other overlay and wears the one surface.
   Nothing may be styled from `#pop`. An ID outranks every sheet rule in this file,
   so an id-selector surface silently survives into the sheet — a card floating in
   the corner, or a sheet that has lost its whole surface to the UA's white
   `canvas`. The id stays on the element for JS to find it, and does no styling. */

.pop-skeleton {
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding: 16px;
}

.pop-skeleton .sk-bar {
    height: 12px;
    border-radius: 4px;
    background: var(--border-normal);
    opacity: .5;
}

.pop-skeleton .sk-label {
    width: 40%;
}

.pop-skeleton .sk-input {
    height: 32px;
}

.pop-skeleton .sk-btn {
    height: 28px;
    width: 72px;
    align-self: flex-end;
}

/* --- bodies the app builds itself ------------------------------------------ */

/* The hx-confirm popover (js/htmx/confirm.js): icon tile + question + a row of two
   equal buttons. `--pc` is the accent, set by the modifier and read by the tile. */
.pop-confirm {
    --pc: var(--bs-danger-rgb);
    padding: 15px;
}

.pop-confirm--warning {
    --pc: var(--bs-warning-rgb);
}

.pop-confirm--primary {
    --pc: var(--bs-primary-rgb);
}

.pop-confirm--secondary {
    --pc: var(--bs-secondary-rgb);
}

.pop-confirm-head {
    display: flex;
    align-items: center;
    gap: 12px;
}

.pop-confirm-ic {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    border-radius: 9px;
    font-size: 15px;
    color: rgb(var(--pc));
    background: rgba(var(--pc), .14);
}

.pop-confirm-q {
    color: var(--text-primary);
    font-size: 13.5px;
    font-weight: 500;
    line-height: 1.45;
    text-wrap: pretty;
}

.pop-confirm-row {
    display: flex;
    gap: 9px;
    margin-top: 14px;
}

.pop-confirm-row .btn {
    flex: 1;
}

/* --- tooltips -------------------------------------------------------------- */

/* One #tip, created by js/components/tooltip.js on first hover. It is `popover=manual`
   rather than `auto`, so showing it never light-dismisses the menu whose button is
   being hovered.

   The tooltip is the one surface that inverts: --tooltip-* hold a dark slate panel in
   BOTH themes, because a white tip vanished into the white cards and navbar. */
#tip {
    position: fixed;
    inset: auto;
    margin: 0;
    top: 0;
    left: 0;
    padding: 0;
    border: 0;
    background: none;
    /* See the note on `.pop`: the UA gives every popover `overflow: auto`. */
    overflow: visible;
    /* The tip must never eat the hover it was triggered from. */
    pointer-events: none;
    font-size: 0.8rem;
    line-height: 1.5;
    opacity: 0;
    transition: opacity .1s ease, overlay .1s allow-discrete, display .1s allow-discrete;
}

#tip:popover-open {
    display: block;
    opacity: 1;
}

@starting-style {
    #tip:popover-open {
        opacity: 0;
    }
}

#tip .tip-body {
    max-width: 260px;
    padding: 8px 10px;
    border-radius: 8px;
    color: var(--tooltip-fg);
    background: var(--tooltip-bg);
    border: 1px solid var(--tooltip-edge);
    box-shadow: var(--tooltip-shadow);
}

/* --- the overlay's arrow --------------------------------------------------- */

/* Every anchored overlay has one unless its trigger says `data-pop-arrow="false"`.
 *
 * A popover of its own, placed by the controller, NOT a child of the box it belongs
 * to — a plain menu is its own scroll region (see "what scrolls" above) and would
 * clip a child that stuck out past its edge. See the note in overlay.js.
 *
 * Its surface is copied off the box at open time into these two properties, so a
 * menu, the fetched panel and anything added later all get an arrow that matches
 * without this file knowing which is which. */
.pop-arrow {
    position: fixed;
    /* The UA centres a popover with `inset: 0; margin: auto`. WHERE is `translate`,
       written by the controller, so the box itself has to start at the origin. */
    inset: 0 auto auto 0;
    margin: 0;
    padding: 0;
    width: 9px;
    height: 9px;
    background: var(--pop-arrow-bg, var(--menu-bg));
    border: 0 solid var(--pop-arrow-edge, var(--menu-border));
    /* `rotate`, not `transform`: the entrance below owns `transform` and the
       controller owns `translate`. Three independent properties, one owner each, and
       none of them has to reconstruct the others. */
    rotate: 45deg;
    /* It sits over the box's own edge, and the box is a menu — a click that lands on
       the arrow is a click meant for the row underneath it. */
    pointer-events: none;
    /* The box's entrance, exactly: they are two elements and must read as one. */
    opacity: 0;
    transition: opacity .12s linear, overlay .12s allow-discrete, display .12s allow-discrete;
}

.pop-arrow:popover-open[data-pop-placed] {
    opacity: 1;
    transition: opacity .18s ease-out, overlay .18s allow-discrete, display .18s allow-discrete;
}

@starting-style {
    .pop-arrow:popover-open[data-pop-placed] {
        opacity: 0;
    }
}

/* See the note on `.pop:not(:popover-open)`. */
.pop-arrow:not(:popover-open) {
    display: none !important;
}

/* Only the two OUTWARD faces carry a border; the half still buried in the box paints
   over the box's own edge, which is what makes the outline read as one continuous
   shape. `data-pop-side` is the side the box landed on, so the faces flip with it. */
.pop-arrow[data-pop-side="top"] {
    border-width: 0 1px 1px 0;
}

.pop-arrow[data-pop-side="bottom"] {
    border-width: 1px 0 0 1px;
}

.pop-arrow[data-pop-side="left"] {
    border-width: 1px 1px 0 0;
}

.pop-arrow[data-pop-side="right"] {
    border-width: 0 0 1px 1px;
}

/* --- the tooltip's arrow --------------------------------------------------- */

/* A square rotated 45° and pushed out past the body's edge by slightly more than
   half its width, so one corner points at the trigger.

   Only the two OUTWARD faces carry a border, and the half still inside the body sits
   on top of the body's own edge and paints it out. That is what makes the outline
   read as one continuous shape: a fully bordered square would draw a line straight
   across the mouth of the arrow, and clipping the square to a triangle instead
   leaves the diagonal unbordered. Floating UI writes the offset along the body's
   edge; which edge that is comes from the side the tip actually landed on. */
#tip .tip-arrow {
    position: absolute;
    width: 9px;
    height: 9px;
    background: var(--tooltip-bg);
    border: 0 solid var(--tooltip-edge);
    transform: rotate(45deg);
}

#tip[data-side="top"] .tip-arrow {
    bottom: -5px;
    border-width: 0 1px 1px 0;
}

#tip[data-side="bottom"] .tip-arrow {
    top: -5px;
    border-width: 1px 0 0 1px;
}

#tip[data-side="left"] .tip-arrow {
    right: -5px;
    border-width: 1px 1px 0 0;
}

#tip[data-side="right"] .tip-arrow {
    left: -5px;
    border-width: 0 0 1px 1px;
}

/* --- sheets on a phone ----------------------------------------------------- */

/* Below the same width where a modal becomes a full-screen sheet (modals.css), an
   overlay that cannot fit stops being anchored and lies along the bottom edge. The
   controller decides which ones and adds `.is-sheet`.

   The keyboard is the reason this exists: it takes half the screen and can cover
   the button the overlay is anchored to, and no amount of placing, flipping or
   clamping fixes a button that is not visible. A sheet is anchored to nothing, so
   the question does not arise. */
@media (max-width: 767.98px) {
    .pop.is-sheet {
        /* The controller stops autoUpdate and clears the coordinates it wrote, so
           the insets are overriding nothing and need no !important.

           Width does. A sheet spans the screen, and both a template's inline
           `style="width: 350px"` on a menu and the `data-pop-width` the controller
           writes are inline styles that no stylesheet rule can outrank. A sheet's
           geometry is not negotiable, so these two say so. */
        inset: auto 0 var(--pop-keyboard, 0px) 0;
        width: auto !important;
        max-width: none !important;
        /* Of what is left after the keyboard, not of the screen: dvh is the whole
           viewport and would run the sheet up behind it. */
        max-height: min(70dvh, calc(100dvh - var(--pop-keyboard, 0px) - 2rem));
        border-radius: 14px 14px 0 0;
        padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
        transform: translateY(12px);
    }

    .pop.is-sheet:popover-open {
        transform: none;
    }

    @starting-style {
        .pop.is-sheet:popover-open {
            transform: translateY(12px);
        }
    }

    /* Clear of the keyboard rather than tucked against it: four corners on screen,
       so it reads as a card instead of something cut off. */
    .pop.is-sheet.is-lifted {
        inset: auto 8px calc(var(--pop-keyboard, 0px) + 8px) 8px;
        border-radius: 14px;
    }

    /* A grab handle, so it reads as a sheet and not as a menu that has come loose
       and stuck itself to the bottom of the screen. */
    .pop.is-sheet::before {
        content: "";
        display: block;
        flex: none;
        width: 36px;
        height: 4px;
        margin: 8px auto 4px;
        border-radius: 2px;
        background: var(--border-normal);
    }

    /* A caret drawn by the menu's own chrome points at a button somewhere above a
       full-width sheet — at nothing. */
    .pop.is-sheet::after {
        display: none;
    }

    /* The sheet is the scroller, always — same as the anchored box, at the sheet's
       height instead of the middleware's. A sticky search field or head/foot pins
       itself inside it, so there is nothing here to detect and nothing to get wrong.

       A sheet that is itself a `.pop-menu` is sized by the rule above: pop-menu.css
       states its cap as `:not(.is-sheet)` precisely so a sheet full of rows fills the
       screen rather than stopping at 30rem. */
    .pop.is-sheet {
        overflow: hidden auto;
    }

    /* Unless it holds a rows container, and then the list is the scroller and the
       sheet is the column that gives it its room — the same division as the anchored
       box above, for the same reason: two scrolling boxes, one inside the other, put
       two scrollbars down the same edge. */
    .pop.is-sheet:has(> .pop-menu) {
        display: flex;
        flex-direction: column;
        overflow: hidden;
    }

    /* A rows container INSIDE a sheet is bounded by the sheet, not by a length of its
       own. It must lose the pairing with `--pop-avail-height` in any case: placement
       stops when a box becomes a sheet, so that property is whatever the last anchored
       measurement left behind — on a phone, often the sliver of room under the trigger,
       a measurement of a layout the sheet is no longer in. The flex column above gives
       the pinned bars their height first and hands the list the rest, which is a truer
       cap than 30rem — a sheet is sized to the screen, so its rows should fill it.
       Three components, so it beats pop-menu.css's two. */
    .pop.is-sheet .pop-menu {
        max-height: none;
    }

    /* A sheet is modal, so it dims. */
    .pop.is-sheet::backdrop {
        background: rgba(0, 0, 0, .45);
    }

    /* And the page behind it must not move. `overscroll-behavior` on the scroll
       region stops a swipe that reaches the end of the list from chaining to the
       page; this stops one that never touched the list.

       `scrollbar-gutter`, in the rule after this one, is what keeps the lock from
       costing a reflow. This block is keyed on a WIDTH, not on a device: a desktop
       window dragged under the
       breakpoint still has a classic scrollbar occupying real width, so taking the
       scrollport away reclaims that width and shifts the entire page under the
       sheet.

       Both classes come from overlay.js, which measures the page before setting
       either. This one lands only on a page that ACTUALLY scrolls: a page with nothing
       to scroll has nothing to take away.

       The lock is on `html` ALONE, and `body` is explicitly handed back its
       scrollport-free state. Which element owns the viewport's overflow moves when
       this class lands: unlocked, `html` is visible so `body`'s `overflow-y: auto`
       (layout.css) propagates to the viewport and `body` itself computes to
       `visible`. Lock `html` and it wins the propagation instead — at which point
       `body`'s own value stops propagating and starts applying to `body`, making it
       a scrollport that cannot scroll. Every `position: sticky` element in the page
       sticks to its NEAREST scrollport, so the sticky toolbar and table head would
       silently re-parent onto that dead one and drop back to their static
       positions. */
    html.pop-sheet-open {
        overflow: hidden;
        touch-action: none;
    }

    /* `stable` holds the groove open while the scrollbar is inert, so the content
       behind the sheet does not move a pixel — reserved only when a classic scrollbar
       is actually taking width at the moment the sheet opens, which overlay.js
       measures as `innerWidth - clientWidth`. That one number answers every case at
       once: a phone's overlay scrollbar takes none, so the property would do nothing
       and the class stays off; and a Bootstrap modal has already removed the bar and
       padded the body by its width, so there is nothing left to reserve for and a
       second gutter on top of that compensation is what put an empty track down the
       side of a full-screen modal. */
    html.pop-sheet-gutter {
        scrollbar-gutter: stable;
    }

    html.pop-sheet-open body {
        overflow: visible;
        touch-action: none;
    }
}
