/* ==========================================================================
   Modals — the single owner of .modal* chrome.

   Consolidated from theme.css (vendor) and theme-override.css (app layer);
   every value here is the one that already resolved after that cascade.

   Loaded after bootstrap.min.css and theme.css. Bootstrap is earlier and
   never !important on these selectors, so plain declarations win.

   Every surface here reads a --modal-* token, because light does not tint
   the dark dialog — it replaces it with an opaque white one.

   The dialog is one surface. Header and footer are set off by a hairline
   and nothing else: filling them banded every dialog into three slabs,
   which is what made a two-field form read as heavy as a full page.

   Sizing variants (.modal-sm / .modal-lg / .modal-xl) are stock Bootstrap
   apart from the xl ceiling below. Modals default to `lg` (modal.js).
   ========================================================================== */

/* --- Shell ------------------------------------------------------------- */

.modal-dialog {
    max-height: calc(100vh - 3.5rem);
    display: flex;
    align-items: center;
}

.modal-content {
    background: var(--modal-bg);
    color: var(--text-primary);
    border: 1px solid var(--modal-border);
    border-radius: 12px;
    /* The inset highlight rides in the same list as the drop shadow, so the
       lit top edge and the elevation are one declaration. */
    box-shadow:
        inset 0 1px 0 var(--modal-highlight),
        var(--modal-shadow);
    display: flex;
    flex-direction: column;
    max-height: calc(100vh - 3.5rem);
}

/* Fluid widths, replacing Bootstrap's stepped ones.

   Bootstrap grants .modal-lg/.modal-xl their 800px only at >=992px, so every
   dialog between 576px and 992px collapsed to .modal-dialog's 500px cap — a
   700px window showed a 500px dialog with 100px of dead margin either side,
   which read as the modal randomly shrinking. Each size now names one cap and
   otherwise takes the width available, so there is no step: the dialog grows
   with the window until it reaches the width it is allowed. Below `md` it
   stops being a dialog at all (full-screen sheet, at the end of this file),
   which is the only reason these need a media query — `min()` alone would
   otherwise cover every width on its own. */
@media (min-width: 768px) {
    .modal-dialog {
        max-width: min(500px, calc(100vw - 3rem));
    }

    .modal-lg {
        max-width: min(800px, calc(100vw - 3rem));
    }

    /* 1140px is a page width, not a dialog width — even the genuinely wide
       modals (transcripts, routing tables) stop being readable past this. */
    .modal-xl {
        max-width: min(960px, calc(100vw - 3rem));
    }
}

/* The open animation rides on .modal-content, not on .modal-dialog.

   htmx/modal.js shows the dialog from htmx:configRequest — before the
   request is even sent — so the wrapper reaches .show while the dialog is
   still empty. A transition on .modal-dialog therefore plays out against an
   empty box and is over by the time the content swaps in. .modal-content is
   the element htmx actually inserts, so an animation on it fires exactly
   when there is something to see, and only when the dialog's own content is
   replaced (a nested swap doesn't recreate it, so a live search inside an
   open modal doesn't re-pop the whole dialog).

   The stages follow swal2's `swal2-show`, which the app's confirms already
   use, at about half its amplitude: overshoot past full size, dip back under,
   settle. swal2's own 0.7 start and 1.05 peak are sized for a small confirm
   card and read as a lurch on an 800px dialog.

   The dip is the point, and it is why this is a keyframe list rather than an
   easing curve. A single overshoot bezier cannot go under 1 on the way in,
   and it front-loads besides: on a bezier the scale was within 1.5% of full
   size 60ms in, so the pop was over before it could be seen.

   The fade is a separate, much shorter animation, finishing by the time the
   overshoot peaks. swal2 doesn't fade its popup at all (its container carries
   the fade), but the modal backdrop is already fully painted by the time
   content lands, so scaling in from nothing with no fade reads as abrupt.
   Keeping it under the first stage means it doesn't mute the bounce.

   Bootstrap's own .modal.fade .modal-dialog translate is cancelled below, or
   the dialog would still drop 25px behind the popping content. */
@keyframes modal-content-pop {
    0% {
        transform: scale(0.88);
    }
    45% {
        transform: scale(1.02);
    }
    80% {
        transform: scale(0.99);
    }
    100% {
        transform: scale(1);
    }
}

@keyframes modal-content-fade {
    from {
        opacity: 0;
    }
}

.modal-content {
    animation:
        modal-content-pop 0.26s,
        modal-content-fade 0.12s ease-out;
}

.modal.fade .modal-dialog,
.modal.show .modal-dialog {
    transform: none;
    transition: none;
}

@media (prefers-reduced-motion: reduce) {
    .modal-content {
        animation: none;
    }
}

/* A <form> wrapping the whole dialog would otherwise become a flex item and
   break the header/body/footer column, and clip its own overflow. */
.modal-content > form {
    display: contents;
}

/* Use dynamic viewport height on mobile browsers that support it */
@supports (height: 100dvh) {
    .modal {
        max-height: 100dvh;
    }
    .modal-dialog,
    .modal-content {
        max-height: calc(100dvh - 3.5rem);
    }
}

/* --- Header ------------------------------------------------------------ */

.modal-header {
    border-bottom: 1px solid var(--modal-bar-border);
    padding: 1rem 1.25rem;
    align-items: center;
    flex-shrink: 0;
}

.modal-title {
    color: var(--text-primary);
    font-weight: var(--font-weight-heading);
    letter-spacing: var(--heading-tracking);
    font-size: 1rem;
    line-height: 1.4;
}

/* Coloured header bars carry white type regardless of the theme's text token.
   !important: templates put Bootstrap's .text-dark / .text-white utilities on
   .modal-title, and those are themselves !important — without it here a
   tinted header bar could paint its title dark. */
.modal .bg-black .modal-title,
.modal .bg-primary .modal-title,
.modal .bg-secondary .modal-title,
.modal .bg-info .modal-title,
.modal .bg-success .modal-title,
.modal .bg-warning .modal-title,
.modal .bg-danger .modal-title {
    color: #ffffff !important;
}

/* A real 28px target rather than a large loose glyph: the × is set at text
   size and the hit area comes from the box around it, which also gives the
   hover somewhere to land. Negative margin keeps it flush with the padding
   edge despite the box being taller than the title. */
.modal-header .close {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    margin: -4px -4px -4px 0;
    padding: 0;
    border-radius: 6px;
    color: var(--text-tertiary);
    opacity: 1;
    text-shadow: none;
    font-size: 1.25rem;
    font-weight: var(--font-weight-body);
    line-height: 1;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.modal-header .close:hover,
.modal-header .close:focus {
    background: var(--surface-3);
    color: var(--text-primary);
    opacity: 1;
}

/* --- Body -------------------------------------------------------------- */

.modal-body {
    padding: 1.25rem;
    color: var(--text-secondary);
    background: transparent;
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
    /* Stop scroll chaining: without this, a wheel that reaches the end of the
       body continues into the page behind. The JS scroll lock deliberately
       exempts .modal so the body can scroll at all, so it cannot catch this
       one — the containment has to come from here. */
    overscroll-behavior: contain;
}

/* --- Body layout: read-only detail -------------------------------------- */

/* A record's facts as columns: each fact stacks a small caps label over its
   value, three to a row so the facts read as a block rather than a ladder.
   Narrow sheets drop to two columns, then one. */
.modal-detail-facts {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 1.125rem 2rem;
    margin: 0;
}

.modal-detail-facts > div {
    min-width: 0;
}

/* The caps label itself is `.field-label`, shared with the read-only grids
   outside modals; only the gap to its value is set here. */
.modal-detail-facts dt {
    margin-bottom: 0.3125rem;
}

@media (max-width: 575px) {
    .modal-detail-facts {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }
}

@media (max-width: 380px) {
    .modal-detail-facts {
        grid-template-columns: 1fr;
    }
}

.modal-detail-facts dd {
    margin: 0;
    color: var(--text-primary);
}

/* A titled block of prose or an embedded widget below the facts grid. The
   rule above it does the separating, so the block itself carries no fill. */
.modal-detail-section {
    margin-top: 1.25rem;
    padding-top: 1.25rem;
    border-top: 1px solid var(--modal-bar-border);
}

/* --- Footer ------------------------------------------------------------ */

.modal-footer {
    border-top: 1px solid var(--modal-bar-border);
    padding: 0.75rem 1.25rem;
    gap: 0.5rem;
    flex-shrink: 0;
    /* Narrow sheets: rows of buttons wrap instead of clipping past the
       right edge. The gap covers both axes, so wrapped rows stay spaced. */
    flex-wrap: wrap;
}

/* Bootstrap spaces footer children with a .25rem margin on every side,
   which fights the gap above and pads the row out top and bottom. */
.modal-footer > * {
    margin: 0;
}

/* --- Backdrop + page state --------------------------------------------- */

/* The blur is what lets the scrim lighten: the page behind reads as out of
   focus rather than merely dimmed, so the dialog no longer has to sit on a
   near-black wall to separate from it. */
.modal-backdrop {
    background-color: var(--modal-backdrop-bg);
    backdrop-filter: blur(2px);
}

.modal-backdrop.show {
    opacity: 1;
}

/* Background scroll while a modal is open is frozen in JS (main.js), which
   clamps the body and keeps the page scrollbar painted — hiding it
   (Bootstrap's .modal-open overflow:hidden) narrows the viewport and resizes
   every fixed element in the chrome. This is the unlocked default the script
   overrides with an inline style. Undo Bootstrap's hide, and the inline
   padding-right it adds to compensate for the scrollbar it thinks it removed
   (!important beats the inline style). */
body.modal-open {
    overflow-y: auto;
    padding-right: 0 !important;
}

/* --- Small screens: the dialog becomes a full-screen sheet -------------- */

/* Below `md` the dialog stops behaving like a dialog and takes the whole
   viewport. On a narrow screen an inset card spends the only space there is
   on margins and corners, and its body scrolls inside a box that is itself
   inset — so it goes edge to edge, square, full height, with .modal-body as
   the single scrolling region. 768px rather than Bootstrap's 576px: at 600px
   a centred card is already mostly margin, and this is where the fluid caps
   above hand over.

   This block sits last on purpose. It overrides rules from both ends of the
   file — .modal-dialog/.modal-content up in the shell (and the @supports
   100dvh caps, which add no specificity) and the two page-state rules just
   above — all at equal specificity, so source order is what carries it.
   Anywhere earlier and the sheet gets re-capped or the gutters come back. */
@media (max-width: 767.98px) {
    .modal-dialog {
        margin: 0;
        max-width: none;
        min-height: 100%;
        max-height: 100%;
        /* Beats the centring on .modal-dialog so the sheet fills instead. */
        align-items: stretch;
    }

    .modal-content {
        flex: 1;
        max-height: 100%;
        border: 0;
        border-radius: 0;
        /* No pop on a full-screen sheet. The overshoot only reads as a pop
           because the dialog has edges to grow past; on a sheet pinned to all
           four sides it just pulls the whole screen away from them and back,
           which looks like a rendering glitch. The fade stays, so the sheet
           still arrives rather than cutting in. */
        animation: modal-content-fade 0.12s ease-out;
    }

    /* Both scrollbars go, and neither is load-bearing here. The outer one
       is redundant — .modal-body already scrolls — and it was reserving a
       column that left the sheet short of the right edge. The page bar is
       kept visible at desktop widths only to avoid a reflow; a sheet that
       covers the viewport hides the page entirely, so there is no reflow to
       see. The scroll freeze itself is JS and does not depend on either. */
    .modal-open .modal {
        overflow: hidden;
    }

    body.modal-open {
        overflow: hidden;
    }

    /* Bootstrap also reserves that column on .modal itself, as an inline
       padding-right measured from the page scrollbar. Hidden above, so there
       is nothing left to reserve and the padding just held the sheet short of
       the right edge. !important is required to beat the inline style — the
       same reason body.modal-open needs it above. */
    .modal {
        padding-right: 0 !important;
        padding-left: 0 !important;
    }
}
