/*
 * modal.css — the client popup component.
 *
 * Consumes tokens.css; load after components.css.
 *
 * THIS FILE CHANGES NOTHING ON ITS OWN. Every selector is a `.ds-modal*` class
 * that exists nowhere else in the codebase, so loading it renders every popup
 * exactly as it renders today. Popups opt in one at a time by having their
 * markup rewritten — the same contract tokens.css and components.css use, and
 * the reason this can land while production pushes are on hold.
 *
 * Grew out of `.ds-sheet` in components.css (added 2026-08-06, never used).
 * That component is gone from there; this is its only home.
 *
 *
 * HOW OPENING WORKS — read this before changing `display`.
 *
 * Every popup on the dashboard is opened by an inline handler:
 *
 *     onclick="document.getElementById('id06').style.display='flex'"
 *
 * An inline style beats a stylesheet declaration, so `.ds-modal { display: none }`
 * below plus that existing inline `flex` behaves exactly as `.w3-modal` does
 * today. **The conversion needs no JavaScript changes at all.** Do not "improve"
 * this into an `.is-open` class without changing all the call sites in the same
 * commit — including js/modal.js, which reads the same inline `display` to find
 * which popup is open.
 *
 * Closing is defined once per popup, by its own `.ds-modal-close` button.
 * js/modal.js adds backdrop / Escape / swipe dismissal by clicking that button
 * rather than by deciding for itself what closing means, so a popup that closes
 * unusually — Daily Water reloads the page — behaves the same however it is
 * dismissed. A popup with no close button is non-dismissible, which is how
 * Calculate Macros stays that way.
 *
 * One known caller sets `'block'` rather than `'flex'`: public/js/custom/macro.js
 * lines 18 and 20. `.ds-modal` therefore also survives `display: block` — the
 * dialog uses `margin: auto` so it still centres horizontally — but the macro
 * popup should have those two lines changed to `'flex'` in the slice that
 * converts it, so vertical centring works too.
 *
 *
 * WHY THE MARKUP DROPS `w3-modal` AND `chooseprogram-modal`.
 *
 * These popups sit *outside* `.test-program` (that wrapper opens at
 * dashboard.blade.php:31 and closes at :707), so the `.test-program .ds-*`
 * scoping that pages/dashboard.css uses to beat style.css is not available here.
 * Rather than escalate specificity against style.css's 45 `.w3-modal` and 18
 * `.chooseprogram-modal` rules, converted markup simply stops carrying those
 * classes and the rules stop matching.
 *
 * Nothing in public/js/ selects them (verified 2026-09-03) — the only references
 * are in Blade. The rules stay in style.css untouched, because food-log,
 * food-calculator, recipes, module-detail and register still use them.
 *
 *
 * MARKUP
 *
 *   <div id="id06" class="ds-modal ds-modal-blue">
 *     <div class="ds-modal-dialog" role="dialog" aria-modal="true"
 *          aria-labelledby="id06-title">
 *       <div class="ds-modal-handle" aria-hidden="true"></div>
 *       <div class="ds-modal-head">
 *         <span class="ds-modal-icon"><i class="bi bi-cup-straw"></i></span>
 *         <div class="ds-modal-heading">
 *           <h3 class="ds-modal-title" id="id06-title">Daily Water</h3>
 *           <p class="ds-modal-sub">Stay hydrated.</p>
 *         </div>
 *         <button type="button" class="ds-modal-close" aria-label="Close">
 *           <i class="bi bi-x"></i></button>
 *       </div>
 *       <div class="ds-modal-body"> … </div>
 *       <div class="ds-modal-foot"> … </div>
 *     </div>
 *   </div>
 *
 * Added 2026-09-03 (PR B1).
 */


/* ===========================================================================
 * Backdrop
 *
 * `display: none` is the closed state and the inline `flex` is the open state —
 * see the note above.
 *
 * Bottom-anchored on phones so the dialog rises as a sheet; centred from 768px.
 *
 * `overscroll-behavior: contain` stops a scroll gesture that reaches the end of
 * the modal from CHAINING onto the page behind it — the complaint raised at the
 * 08-21 sync (00:14:26).
 *
 * It is not the whole fix, and an earlier version of this comment claimed it
 * was ("needs no JS and no body lock"). That was wrong. It does not stop a
 * gesture that starts on the backdrop, and on iOS Safari it does not hold the
 * page behind a fixed overlay at all. The body scroll lock in js/modal.js (#400)
 * is what actually pins the page; the two work together and neither is
 * redundant.
 * ======================================================================== */

.ds-modal {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    /*
     * `height: 100lvh`, NOT `bottom: 0` / `inset: 0`. On iPhone WebKit a fixed
     * element's `bottom: 0` resolves against the layout viewport, which is sized
     * with the browser toolbar visible. Opening a popup locks the body, the
     * toolbar slides away, the visual viewport grows by the toolbar's height,
     * and the overlay did not follow: a page-coloured, undimmed band sat under
     * every sheet (Marcus's iPhone, 2026-09-24). The LARGE viewport (`lvh`; on
     * iOS plain `vh` is the same thing) is the viewport with the toolbar hidden,
     * so this always covers. Engines without `lvh` keep `100vh`, which is what
     * they had. Desktop is unchanged: lvh = dvh = vh there.
     */
    height: 100vh;
    height: 100lvh;
    /*
     * ...except on Chrome for iOS, where `lvh` did not reach the bottom either
     * once its toolbar had hidden (Marcus, 09-24, after the rule above shipped).
     * So modal.js also MEASURES the viewport — `--ds-vh` is the larger of
     * `innerHeight` and `visualViewport.height`, refreshed on every resize while
     * a popup is open — and the overlay takes whichever of the two is taller.
     * Safari keeps `lvh` (it covers the toolbar's slide before any resize
     * event fires); Chrome gets the measurement. Unset → `0px` → `100lvh`.
     */
    height: max(100lvh, var(--ds-vh, 0px));
    z-index: 10000;
    /*
     * `flex-start`, never `flex-end` or `center`. The dialog is pinned to the
     * bottom by an auto margin instead — see `.ds-modal-dialog`.
     *
     * This is the flexbox overflow trap and it is why a tall popup could report
     * as "cut off with no way to reach it". When a flex item is taller than its
     * container, `align-items: flex-end`/`center` pushes the excess off the
     * START edge, and overflow at the start edge is unreachable — no scrollbar
     * appears and scrolling cannot get to it. An auto margin degrades safely
     * instead: it takes the free space when there is some, and collapses to
     * zero when there is not, leaving the overflow at the end edge where
     * `overflow-y` below can reach it.
     */
    align-items: flex-start;
    justify-content: center;
    /* The safety net for the same problem: if the dialog ever exceeds the
       viewport despite its max-height, the backdrop scrolls rather than clips. */
    overflow-y: auto;
    padding: 0;
    background: rgba(14, 35, 64, 0.45);
    overscroll-behavior: contain;
    font-family: var(--ds-font);
    color: var(--ds-text);
    font-size: var(--ds-size-body);
    line-height: var(--ds-leading-normal);
    /*
     * w3.css sets `-webkit-text-size-adjust` and a Segoe UI stack on its own
     * containers. Nothing here inherits from those any more, but state the face
     * explicitly so a stray Bootstrap rule cannot reintroduce the mismatch that
     * F3 in the phase-3 plan describes.
     */
}

/*
 * The dialog is a column: fixed head, scrolling body, fixed foot. `min-height: 0`
 * on the body is what actually lets it scroll — without it a flex item refuses
 * to shrink below its content and the whole dialog grows instead.
 *
 * `margin: auto` rather than flex centring alone, so the component still centres
 * horizontally if a caller sets `display: block` (macro.js, above).
 */
.ds-modal-dialog {
    position: relative;
    display: flex;
    flex-direction: column;
    width: 100%;
    max-width: 100%;
    /*
     * `vh` first, `dvh` second. An engine that understands `dvh` takes the
     * second and gets the dynamic viewport; one that does not drops it and
     * still has a cap. Without the fallback the whole declaration is invalid on
     * older WebViews — the dialog then has NO max-height, grows past the
     * viewport, and its top is clipped away unreachably. The app ships inside a
     * native WebView shell, so that is not a hypothetical engine.
     */
    max-height: 92vh;
    max-height: 92dvh;
    /*
     * `margin-top: auto` pins the sheet to the bottom while it fits, and
     * collapses to zero when it does not — see the note on `.ds-modal`. Doing
     * this with `align-items: flex-end` instead makes tall content unreachable.
     *
     * `margin-inline: auto` also centres the dialog if a caller opens the
     * backdrop with `display: block` (macro.js, see the file header).
     */
    margin-top: auto;
    /*
     * The overlay now reaches the large viewport's bottom (above), but the
     * sheet must sit where the user sees the edge: the DYNAMIC viewport's
     * bottom, which is above the toolbar while it is shown and equal to the
     * large one once it hides. lvh − dvh is exactly the toolbar's height, or 0.
     * Unsupported → 0, today's behaviour.
     */
    margin-bottom: calc(100lvh - 100dvh);
    /*
     * Same measurement as the overlay (see `.ds-modal`): the visible bottom is
     * `--ds-vh`, so the sheet's gap is lvh minus that, floored at 0 for the
     * engine where lvh comes out too SMALL. Unset → lvh − dvh, the line above.
     */
    margin-bottom: max(0px, calc(100lvh - var(--ds-vh, 100dvh)));
    margin-inline: auto;
    border-radius: var(--ds-radius-sheet) var(--ds-radius-sheet) 0 0;
    background: var(--ds-surface);
    box-shadow: var(--ds-shadow-lg);
    overflow: hidden;
    animation: ds-modal-rise 0.22s ease-out;
}

/* The grab handle. Phones only — hidden at 768px with the sheet shape. */
.ds-modal-handle {
    flex: 0 0 auto;
    width: 44px;
    height: 4px;
    margin: var(--ds-space-3) auto 0;
    border-radius: var(--ds-radius-pill);
    background: var(--ds-border);
}


/* ===========================================================================
 * Head — icon chip, title, subtitle, close
 *
 * This replaces the `w3-teal` header bar. The chip carries the same icon and
 * the same tint as the task row that opened the popup (pages/dashboard.css
 * `.ds-task-icon`), so the popup reads as a continuation of the row rather than
 * a separate object.
 * ======================================================================== */

.ds-modal-head {
    flex: 0 0 auto;
    display: flex;
    align-items: flex-start;
    gap: var(--ds-space-3);
    /*
     * Bottom padding is 12px, not 16px, and the body carries the missing 4px as
     * `padding-top` — see the note there. The 16px gap between head and body is
     * unchanged; it is just split across the two so the body has room to clip
     * into.
     */
    padding: var(--ds-space-5) var(--ds-space-5) var(--ds-space-3);
}

.ds-modal-icon {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    /* Defaults are overridden by the accent modifiers at the bottom of the file. */
    background: var(--ds-modal-tint, var(--ds-teal-tint));
    color: var(--ds-modal-accent, var(--ds-teal));
    font-size: 1.375rem;
    line-height: 1;
}

.ds-modal-icon img {
    width: 26px;
    height: 26px;
    object-fit: contain;
}

/*
 * Centre the glyph in its holder.
 *
 * bootstrap-icons 1.3.0 sets `vertical-align: text-bottom` on every `.bi::before`.
 * That is right for an icon sitting inline beside a run of text — it drops the
 * glyph onto the text's bottom edge — and wrong for every icon here, all of
 * which are alone inside a box that already centres them. The offset pushed the
 * droplet visibly below the middle of its circle.
 *
 * That misalignment is worth understanding, because it caused a second one. The
 * chip *was* correctly centred on the heading block; the glyph inside it was
 * not, so the whole chip read as too low, and an earlier pass "fixed" that by
 * raising the chip 6px — which left the glyph looking right and the circle
 * sitting high. Correcting the glyph removes the need for the nudge entirely,
 * so the chip is back to plain block centring.
 *
 * Verified by rendering the alternatives with a rule drawn through the circle's
 * geometric centre: `vertical-align: 0` sits the droplet on the line, while
 * forcing the glyph to `display: block; line-height: 1` overshoots and sits it
 * slightly high.
 */
.ds-modal-icon .bi::before,
.ds-modal-close .bi::before,
.ds-modal-option .bi::before {
    vertical-align: 0;
}

/* Takes the leftover width so a long title truncates rather than shoving the
   close button off the edge. `min-width: 0` is what permits that inside flex. */
.ds-modal-heading {
    flex: 1 1 auto;
    min-width: 0;
    padding-top: var(--ds-space-1);
}

/*
 * Ellipsis, not wrapping (D4, sync 00:15:25). Two lines rather than one: every
 * title in Jenny's mockups fits one line on a phone, but the service names are
 * admin-editable free text, so the second line is the margin that keeps a long
 * one readable instead of clipped mid-word.
 */
.ds-modal-title {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    margin: 0;
    font-family: var(--ds-font);
    font-size: var(--ds-size-h2);
    font-weight: var(--ds-weight-bold);
    line-height: var(--ds-leading-snug);
    color: var(--ds-text);
}

.ds-modal-sub {
    margin: var(--ds-space-1) 0 0;
    font-size: var(--ds-size-sm);
    line-height: var(--ds-leading-snug);
    color: var(--ds-text-muted);
    /*
     * Two lines, not one.
     *
     * This was `white-space: nowrap` with an ellipsis, which at 320px clipped
     * "Stay hydrated — you've got this." to "Stay hydrated — you've g…" — the
     * subtitle is the one line that has to survive a narrow phone. D4's "ellipses,
     * not wrapping" is about long *titles* running away; a subtitle still gets an
     * ellipsis here, just two lines later.
     */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/*
 * ICON NAMES: customer/layouts/header.blade.php loads bootstrap-icons@1.3.0,
 * which predates a lot of the icon set. `bi-x-lg` does not exist there — it
 * renders no glyph at all and the button looks like it is simply missing, with
 * no error anywhere. Caught only by opening the popup. Use `bi-x`, and check any
 * new icon actually resolves before relying on it:
 *
 *     getComputedStyle(el, '::before').content   // "none" means it does not exist
 */
.ds-modal-close {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--ds-tap);
    height: var(--ds-tap);
    margin: calc(var(--ds-space-1) * -1) calc(var(--ds-space-2) * -1) 0 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--ds-text-muted);
    /* 1.5rem, not the 1.25 this started at — `bi-x` draws a small glyph inside a
       generous box, and at 1.25 it read as a stray character rather than a
       control. Checked against the 44px tap target, which does not change. */
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease;
}

.ds-modal-close:hover,
.ds-modal-close:focus-visible {
    background: var(--ds-divider);
    color: var(--ds-text);
}

.ds-modal-close:focus-visible {
    outline: 2px solid var(--ds-blue);
    outline-offset: 2px;
}


/* ===========================================================================
 * Body and foot
 * ======================================================================== */

/*
 * A form that wraps both the body and the footer, as all four check-in popups
 * do — the submit button has to stay inside its own form, so the form sits
 * between the dialog and the two parts that need to flex. This makes it a
 * transparent passthrough rather than forcing the markup to be restructured.
 *
 * Added in B2. B1 had no popup whose form spanned the footer, so it shipped
 * without this rather than carry a class nothing used.
 */
.ds-modal-form {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
    margin: 0;
}

/*
 * There is exactly one scroll region per dialog. Two `.ds-modal-body` siblings
 * both flexing would split the available height between them on a short
 * viewport, and each would scroll on its own — put the form inside the body
 * rather than beside it, or make the form the passthrough above.
 */
.ds-modal-body {
    flex: 1 1 auto;
    /* See the note on .ds-modal-dialog — this is what makes the body scroll
       instead of the dialog growing past its max-height. */
    min-height: 0;
    overflow-y: auto;
    /*
     * A modal body scrolls vertically or not at all — sideways is always a bug.
     *
     * The upload row proved the point: three `1fr` tracks plus two gaps came to
     * 352.50 inside a 352.50 box, flush to the pixel with no tolerance, so
     * ordinary sub-pixel rounding tipped it into a 1-2px scroll. The track maths
     * is fixed where it belongs, but nothing downstream should be able to
     * reintroduce this by being a fraction too wide.
     */
    overflow-x: hidden;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    /*
     * The 4px of top padding is load-bearing, not spacing.
     *
     * `overflow-y: auto` clips at this element's edges, and the first child sat
     * flush against the top one. `.ds-modal-option`'s 2px hover lift moved the
     * card's top border straight into the clip, so it vanished on hover. Four
     * pixels is the lift plus a pixel of border, and it comes off the head's
     * bottom padding so the visual gap is unchanged.
     *
     * Anything else that lifts, scales or shadows inside this container needs
     * the same room reserving.
     */
    padding: var(--ds-space-1) var(--ds-space-5) var(--ds-space-5);
}

.ds-modal-foot {
    flex: 0 0 auto;
    padding: var(--ds-space-4) var(--ds-space-5);
    border-top: 1px solid var(--ds-divider);
    background: var(--ds-surface);
    /* Clears the home indicator on notched phones; 0 everywhere else. */
    padding-bottom: calc(var(--ds-space-4) + env(safe-area-inset-bottom, 0px));
}

/* A body with nothing under it still needs the safe-area padding. */
.ds-modal-body:last-child {
    padding-bottom: calc(var(--ds-space-5) + env(safe-area-inset-bottom, 0px));
}

/* A submit button that scrolls with the body rather than pinning to a footer.
   Short dialogs use this; long ones use `.ds-modal-foot`. */
.ds-modal-submit {
    margin-top: var(--ds-space-5);
}

/* Helper text under a control — "If left empty…", "Your information is private". */
.ds-modal-note {
    margin: var(--ds-space-2) 0 0;
    font-size: var(--ds-size-sm);
    line-height: var(--ds-leading-snug);
    color: var(--ds-text-muted);
    text-align: center;
}

/* A labelled divider: "Or go to an existing program". */
.ds-modal-divider {
    display: flex;
    align-items: center;
    gap: var(--ds-space-3);
    margin: var(--ds-space-5) 0 var(--ds-space-3);
    font-size: var(--ds-size-sm);
    color: var(--ds-text-muted);
}

.ds-modal-divider::before,
.ds-modal-divider::after {
    content: "";
    flex: 1 1 auto;
    height: 1px;
    background: var(--ds-divider);
}


/* ===========================================================================
 * Form controls
 *
 * Scoped to `.ds-modal-body` so this reaches only converted popups. Bootstrap's
 * `.form-control` is 14px in this app, which makes iOS zoom the viewport on
 * focus; `--ds-size-body` is 16px, the documented floor.
 * ======================================================================== */

.ds-modal-body .form-control,
.ds-modal-body select.form-control {
    height: auto;
    min-height: var(--ds-tap);
    padding: var(--ds-space-3) var(--ds-space-4);
    border: 1px solid var(--ds-border);
    border-radius: var(--ds-radius-md);
    background-color: var(--ds-surface);
    font-family: var(--ds-font);
    font-size: var(--ds-size-body);
    color: var(--ds-text);
    box-shadow: none;
}

/*
 * Selects draw their own chevron rather than the platform's.
 *
 * On iOS Safari the native `menulist` select lays its text out with its own
 * metrics, which do not account for CSS padding — with `height: auto` and 12px
 * of vertical padding it renders the label clipped through the middle, which is
 * what Mark and Jenny photographed on the More programs popup. Nothing about
 * that is reproducible in desktop Chrome, so the fix is to stop asking the
 * platform to lay the control out at all.
 *
 * `appearance: none` also means the box is now sized by the same rules as every
 * other field in the popup, so it matches the text inputs instead of being
 * whatever height iOS decided.
 *
 * The chevron is inline SVG in `--ds-text-muted`. It cannot take `currentColor`
 * inside a data URI, so if that token changes this needs changing with it.
 */
.ds-modal-body select.form-control {
    -webkit-appearance: none;
    appearance: none;
    line-height: 1.4;
    /* Room for the chevron, so a long programme name never runs under it. */
    padding-right: calc(var(--ds-space-4) + 22px);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right var(--ds-space-4) center;
    background-size: 14px 14px;
    /* iOS ignores text-overflow on a native select; it works once appearance is
       off, and long package names on this account are why it is here. */
    text-overflow: ellipsis;
}

.ds-modal-body .form-control:focus {
    border-color: var(--ds-modal-accent, var(--ds-teal));
    box-shadow: none;
    outline: 2px solid var(--ds-modal-accent, var(--ds-teal));
    outline-offset: -2px;
}

.ds-modal-body .form-control::placeholder {
    color: var(--ds-text-subtle);
}

.ds-modal-body label {
    display: block;
    margin-bottom: var(--ds-space-2);
    font-size: var(--ds-size-sm);
    font-weight: var(--ds-weight-semibold);
    color: var(--ds-text);
}

/*
 * One labelled field. Replaces the `<b>` + `<br>` pairs the check-in popups
 * used, which gave no consistent spacing and no label/input association —
 * tapping a label did nothing, and a screen reader read the two as unrelated.
 * The markup now carries `for`/`id`, and this supplies the rhythm.
 *
 * ── Density, 2026-09-15 (sync 09-11, items 2 and 5) ──────────────────────
 *
 * Mark: the answer boxes take too much room and force excessive scrolling on a
 * phone. Jenny: put a box around each question. The "75%" in the notes was a
 * number thrown out to convey "smaller", not a target.
 *
 * The box is the field's chrome, and the input inside it is borderless and
 * transparent. Boxing a field that keeps its own bordered input nets nothing —
 * the box padding gives back exactly what the tighter input saves (~92px a
 * field against 94 today). Letting the box BE the border is what pays:
 *
 *      label 20 + gap 2 + input 28 + padding 20 + border 2 + gap 8  =  ~80px
 *
 * ~15% shorter per field, or ~84px over a six-question weekly check-in, and it
 * is also what Jenny asked for drawn literally — one border per question.
 *
 * `font-size` stays at 16px and is not negotiable: iOS Safari zooms the
 * viewport on focus below that, which is the regression Jenny reported on
 * 08-03. The 44px floor in foundation.css:105 is deliberately scoped to
 * form-control *buttons*, so the input is free to be shorter than that — and
 * the tap target is the whole 72px box anyway, since the label carries `for`.
 */
.ds-field-boxed {
    padding: var(--ds-space-2) var(--ds-space-3);
    border: 0;
    border-radius: var(--ds-radius-md);
    background: var(--ds-bg);
}

/* Base rhythm, for every unboxed field — Calculate Macros and Weekly Photos. */
.ds-field + .ds-field {
    margin-top: var(--ds-space-4);
}

/* Boxed fields sit tighter: the border already separates them. */
.ds-field-boxed + .ds-field-boxed {
    margin-top: var(--ds-space-2);
}

/*
 * The question matches the answer in size — Jenny, via the 09-11 notes:
 * "questions and answers are closer in size". The label was 14px against a 16px
 * input, so the answer rendered LARGER than the question it belonged to.
 *
 * They are matched at every width, but the size differs by breakpoint, and that
 * is forced rather than chosen. foundation.css:79 pins text inputs at 16px with
 * `!important` under 768px, because anything smaller makes iOS Safari zoom the
 * viewport on focus and never zoom back — the regression Jenny reported on
 * 08-03. Nothing in this file can override it, and nothing should try.
 *
 * So: 16px on a phone, where the floor applies to both; 14px from 768px, where
 * it does not apply to either. Matching within a breakpoint is what reads as
 * deliberate — a 14px label beside a 16px input on a phone would just look like
 * the bug this replaced.
 */
/*
 * `--ds-leading-snug` on the label, not the inherited 1.5. A question is one
 * short line that does not need reading-copy leading, and at 16px the looser
 * value costs 3px a field for nothing — which matters because the phone cannot
 * take the font down. Wrapped questions still read fine at 1.3.
 */
/*
 * ABSOLUTE px, not the rem tokens, and that is deliberate.
 *
 * w3.css sets the root to 15px, so `--ds-size-body` (1rem) resolves to 15 and
 * `--ds-size-sm` (0.875rem) to 13.125 — the whole token scale is 6.25% under
 * its intended value on every page that loads w3.css, which is all of them.
 *
 * That matters here specifically because the thing being matched is NOT a
 * token: foundation.css:79 pins inputs at an absolute `16px !important` below
 * 768px. Sizing the label from the token gave 15px against that 16px input —
 * still mismatched, just less visibly, which visual QA caught on 2026-09-15
 * (F2). Matching an absolute floor takes an absolute value.
 *
 * The 15px root is a wider problem than this component and is left alone here.
 */
.ds-field-boxed label {
    margin-bottom: var(--ds-space-1);
    font-size: 16px;
    line-height: var(--ds-leading-snug);
}

@media (min-width: 768px) {

    .ds-field-boxed label,
    .ds-modal-body .ds-field-boxed .form-control {
        font-size: 14px;
    }
}

/*
 * The input keeps a white face and its own border inside the tinted box —
 * Jenny's mockup, 2026-09-15. Two surfaces, not one: the box carries the
 * question and the white field is where the answer goes.
 *
 * This costs height. An earlier pass made the input borderless so the box
 * could BE its chrome, which reached ~80px a field against 94 before. Two
 * bordered surfaces cannot go that low — the box padding has to clear the
 * input's own border rather than replace it. Tightened as far as it goes
 * without crowding: ~88px, so ~6% rather than ~15%.
 *
 * Height comes off the padding, not the font, which is pinned at 16px.
 */
.ds-modal-body .ds-field-boxed .form-control {
    min-height: 0;
    padding: var(--ds-space-2) var(--ds-space-3);
}

/* The asterisk on a required field. `aria-hidden` in the markup, because the
   input's own `required` is what actually announces it. */
.ds-req {
    margin-left: 2px;
    color: var(--ds-danger);
}

/* Two controls on one row — Height's feet and inches. Replaces a Bootstrap
   `col-sm-5` pair that collapsed to full width below 576px anyway. */
.ds-field-pair {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--ds-space-3);
}

/*
 * File inputs on the weekly-photos popup.
 *
 * `::file-selector-button` is the only way to reach the browser's own button.
 * Where it is not supported the input still renders and still works — it just
 * keeps the platform button, which is why nothing here is load-bearing.
 */
.ds-modal-body .form-control.ds-upload {
    padding: var(--ds-space-3);
    border-style: dashed;
    border-color: var(--ds-modal-accent, var(--ds-teal));
    background: var(--ds-modal-tint, var(--ds-teal-tint));
    font-size: var(--ds-size-sm);
    cursor: pointer;
}

.ds-modal-body .ds-upload::file-selector-button {
    margin-right: var(--ds-space-3);
    padding: var(--ds-space-2) var(--ds-space-4);
    border: 0;
    border-radius: var(--ds-radius-pill);
    background: var(--ds-surface);
    color: var(--ds-modal-accent, var(--ds-teal));
    font-family: var(--ds-font);
    font-size: var(--ds-size-sm);
    font-weight: var(--ds-weight-semibold);
    cursor: pointer;
}

/* Thumbnails of what is already uploaded — all.js appends lightbox links here. */
.ds-upload-preview {
    display: block;
}

.ds-upload-preview:not(:empty) {
    margin-bottom: var(--ds-space-2);
}

/* ---------------------------------------------------------------------------
 * Weekly photos — three slots in a row (09-11 sync, item 1)
 *
 * "The horizontal layout should be maintained using an 'upload photo'
 * placeholder, utilising the same popup interface for both fresh uploads and
 * deletions." Front / Side / Back sit side by side; each tile is a thumbnail
 * once something is chosen, and an Upload photo placeholder until then.
 *
 * The <input> is NOT hidden. It is stretched over the tile at zero opacity, so
 * the tile is the control and the input keeps `required`, parsley's message,
 * keyboard focus and the native picker. `display: none` would take all of that
 * away — parsley cannot report on a control with no box.
 * ------------------------------------------------------------------------ */

.ds-upload-grid {
    display: grid;
    /*
     * `minmax(0, 1fr)`, not `1fr`. A bare `1fr` is `minmax(auto, 1fr)`, and that
     * `auto` minimum is the same refusal-to-shrink that `min-width: 0` below
     * works around — stated on the track it cannot be argued with at all.
     */
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--ds-space-2);
}

/*
 * `min-width: 0` is what stops the row scrolling sideways.
 *
 * A grid item's default `min-width: auto` refuses to shrink below its content's
 * intrinsic width, so the three columns held the placeholder's natural size and
 * the track overflowed the dialog instead of dividing it. `1fr` cannot win that
 * argument on its own.
 */
.ds-upload-grid .ds-field {
    min-width: 0;
}

/* The slots are their own boxes; the shared field rhythm would double the gap. */
.ds-upload-grid .ds-field + .ds-field {
    margin-top: 0;
}

/*
 * No width cap from 768px up. One shipped in #403 (`max-width: 360px`) and left
 * the grid hard against the left edge of a 560px dialog — 163px of dead space
 * beside the tiles while the Save button below ran the full width (09-18 sync).
 * It was only ever looked at on a phone. The tiles are 4:5 portrait, so filling
 * the body gives three 169 x 211 tiles whose edges line up with the button.
 *
 * What the cap was really reaching for is HEIGHT, and a width query cannot say
 * that. On a landscape phone (844x390, 932x430) full-width tiles push the
 * dialog past 92dvh and the body scrolls ~70px with the tile bottoms under the
 * footer (visual QA 2026-09-21). So the grid is capped only where the viewport
 * is short, and centred so the leftover is even on both sides. 520px sits
 * below the shortest portrait phone (568) and above every landscape one, so
 * portrait phones and desktops never match it. 290px leaves ~13px to spare at
 * 667x375, where the bottom sheet's grab handle adds height; a 320px-high
 * viewport (iPhone SE landscape) still scrolls, as it did before — tiles
 * cannot shrink forever and the body scrolling there is the right outcome.
 */
@media (max-height: 520px) {

    .ds-upload-grid {
        max-width: 290px;
        margin-inline: auto;
    }
}

.ds-upload-tile {
    position: relative;
    /*
     * Portrait, because progress photos are. Also shorter than a square at the
     * same width, which is the direction the dialog needs.
     *
     * No `max-height` here. Capping the height made `aspect-ratio` shrink the
     * WIDTH to match — 105.6px inside a 112.5px column at 390 — so every tile
     * sat narrower than its track and the leftover read as uneven gaps. The
     * size is capped on the grid instead, where it costs nothing.
     */
    aspect-ratio: 4 / 5;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    border: 1px dashed var(--ds-modal-accent, var(--ds-teal));
    border-radius: var(--ds-radius-md);
    background: var(--ds-modal-tint, var(--ds-teal-tint));
}

/*
 * Covers the tile, invisible, still a real control. `opacity: 0` rather than
 * `visibility: hidden` — the latter takes it out of the hit test, and the tile
 * would stop opening the picker.
 */
/*
 * STACKING INSIDE A TILE — read this before changing a z-index here.
 *
 *   1  the file input        picks a file, and re-picks over a pending preview
 *   2  a saved photo's <a>   must beat the input, or the viewer is unreachable
 *   3  the remove button     beats both
 *
 * The input shipped at 2 over an unpositioned preview, which put it above a
 * saved photo's lightbox anchor: tapping a photo opened the file chooser and
 * the viewer never appeared — the exact flow item 4 set out to fix. Caught by
 * visual QA on 2026-09-16 (F5).
 *
 * A pending selection is a plain <span> with no z-index, so it stays below the
 * input and tapping the preview re-opens the picker. That is deliberate: only a
 * saved photo has somewhere to go.
 */
.ds-modal-body .ds-upload-tile .form-control.ds-upload {
    position: absolute;
    inset: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    opacity: 0;
    cursor: pointer;
}

/*
 * Parsley's message, outside the tile.
 *
 * It was being appended after the input — inside a tile with `overflow: hidden`
 * — so "Please choose atleast front 1 image" was clipped, losing its first
 * letters entirely at 320px (visual QA 2026-09-16, F6). The input now names
 * this element with `data-parsley-errors-container`, so the message lands under
 * the tile where there is room for it.
 *
 * Only the Front input carries the attribute; Side and Back are not required
 * and never produce one.
 */
.ds-upload-error:empty {
    display: none;
}

.ds-upload-error .parsley-errors-list {
    margin: var(--ds-space-1) 0 0;
    padding: 0;
    list-style: none;
    font-size: var(--ds-size-xs);
    line-height: var(--ds-leading-snug);
    color: var(--ds-danger);
}

/*
 * The label is one short word and must not widen its track. Measured at 390 the
 * label boxes were reporting 150px inside a 112px column, which kept the modal
 * body wider than its own box — invisible behind `overflow-x: hidden`, but
 * still wrong (visual QA 2026-09-16, F8).
 *
 * `min-width: 0` is the half that actually fixes it. The 150px comes from
 * `.weekly-image-gallary label { min-width: 150px }` (style.css:8241), and a
 * min-width beats a max-width — so `max-width: 100%` alone changed nothing and
 * the Back label still ran 19px past the dialog edge at 390. Found by measuring
 * on 2026-09-21; the 09-16 fix had been reasoned, not rendered.
 */
.ds-upload-grid .ds-field > label {
    display: block;
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.ds-upload-placeholder {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--ds-space-1);
    padding: var(--ds-space-1);
    font-size: var(--ds-size-xs);
    line-height: var(--ds-leading-snug);
    text-align: center;
    color: var(--ds-modal-accent, var(--ds-teal));
    /* The input above owns the clicks. */
    pointer-events: none;
}

.ds-upload-placeholder .bi {
    font-size: var(--ds-size-h2);
}

/*
 * One rule covers both filled states — a thumbnail the server rendered for an
 * already-uploaded week, and a pending selection this session. Whichever put
 * content in `.ds-upload-preview`, the placeholder gets out of the way.
 */
.ds-upload-tile:has(.ds-upload-preview:not(:empty)) .ds-upload-placeholder {
    display: none;
}

.ds-upload-tile:has(.ds-upload-preview:not(:empty)) {
    border-style: solid;
    border-color: var(--ds-border);
    background: var(--ds-surface);
}

/*
 * Absolute, not a flex item. As a sibling of the placeholder at `width: 100%`
 * this span took the whole row even while empty, and the placeholder was
 * pushed off the tile's centre — visible as an "Upload photo" that sat right of
 * where it belonged. Overlaying it instead leaves the placeholder alone with
 * the tile's own centring.
 */
.ds-upload-tile .ds-upload-preview {
    position: absolute;
    inset: 0;
    display: block;
    /*
     * `margin: 0` matters. `.ds-upload-preview:not(:empty)` still carries a
     * `margin-bottom` from the stacked layout this replaced, and on an absolute
     * box with `inset: 0` that pulls the bottom edge up — leaving a ~7px strip
     * of tile visible under every thumbnail (visual QA 2026-09-16, F7).
     */
    margin: 0;
}

/* Above the input, so a saved photo opens its viewer instead of the picker. */
.ds-upload-tile .ds-upload-preview a {
    position: relative;
    z-index: 2;
    display: block;
    width: 100%;
    height: 100%;
}

.ds-upload-tile .ds-upload-preview img {
    width: 100%;
    height: 100%;
    margin: 0;
    border-radius: 0;
    object-fit: cover;
}

/* Remove a pending selection. Added by js/upload-preview.js, never present for
   a photo that is already saved — that needs a server round trip and is not
   what the sync asked for. */
/*
 * The hit area is 44px; the circle drawn inside it is 26px.
 *
 * Sizing the button itself to 26px produced a tall oval, because
 * foundation.css:116 puts `min-height: 44px` on a bare `button` under 768px —
 * so the height became 44 while the width stayed 26. That floor is right and
 * should not be fought: this IS a tap target. So the button carries the 44px
 * box transparently and the glyph carries the visible circle, which also keeps
 * a comfortable target without a 44px disc covering a third of a small tile.
 */
.ds-upload-clear {
    position: absolute;
    top: 0;
    right: 0;
    z-index: 3;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    min-width: 44px;
    min-height: 44px;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
}

.ds-upload-clear .bi {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: rgba(14, 35, 64, 0.62);
    color: #fff;
    font-size: var(--ds-size-sm);
    line-height: 1;
}

.ds-upload-clear:hover .bi,
.ds-upload-clear:focus-visible .bi {
    background: rgba(14, 35, 64, 0.85);
}

.ds-upload-preview img {
    width: 56px;
    height: 56px;
    margin-right: var(--ds-space-2);
    border-radius: var(--ds-radius-sm);
    object-fit: cover;
}


/* ===========================================================================
 * Option cards
 *
 * The two-up choice in the More Programs popup. A generic pattern rather than a
 * one-off, because the mockups use it again on the food diary.
 * ======================================================================== */

.ds-modal-options {
    display: grid;
    /*
     * Fixed columns, not `auto-fit`. A card that changes size with how many
     * cards there are is the defect Mark reported on the service tiles in #395
     * — one option rendering at double width would read as a different control.
     * One card takes a column and packs left, which is what he asked for there.
     */
    grid-template-columns: 1fr;
    gap: var(--ds-space-3);
}

/*
 * A row on a phone, the mockup's centred card from 480px up.
 *
 * Stacked as centred cards, two options cost ~270px of a sheet that also has to
 * carry a header, a divider, a labelled select and a Save button — which is how
 * you end up unable to see both choices at once on a short screen. As rows they
 * cost ~150px, and the pair is comfortably in view together.
 *
 * Grid rather than flex here so the icon can span both text rows without the
 * markup needing a wrapper element around the title and subtitle. The `@media`
 * below switches back to the flex column, where the same three children stack.
 */
.ds-modal-option {
    display: grid;
    grid-template-columns: auto 1fr;
    grid-template-areas:
        "icon title"
        "icon sub";
    align-items: center;
    column-gap: var(--ds-space-3);
    min-height: var(--ds-tap);
    padding: var(--ds-space-3) var(--ds-space-4);
    border: 1px solid var(--ds-option-accent, var(--ds-teal));
    border-radius: var(--ds-radius-lg);
    /* `transparent` over the tint keeps the border readable in forced-colours. */
    background: var(--ds-option-tint, var(--ds-teal-tint));
    color: var(--ds-option-accent, var(--ds-teal));
    text-align: left;
    text-decoration: none;
    transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.ds-modal-option:hover,
.ds-modal-option:focus-visible {
    transform: translateY(-2px);
    box-shadow: var(--ds-shadow-md);
    color: var(--ds-option-accent, var(--ds-teal));
    text-decoration: none;
}

.ds-modal-option i {
    grid-area: icon;
    font-size: 1.5rem;
    line-height: 1;
}

.ds-modal-option-title {
    grid-area: title;
    font-size: var(--ds-size-h3);
    font-weight: var(--ds-weight-semibold);
    line-height: var(--ds-leading-snug);
}

.ds-modal-option-sub {
    grid-area: sub;
    font-size: var(--ds-size-sm);
    line-height: var(--ds-leading-snug);
    color: var(--ds-text-muted);
}


/* ===========================================================================
 * Accent modifiers
 *
 * One per tint pair in tokens.css. These mirror the task-row mapping in
 * pages/dashboard.css:1652-1658 exactly — if a row's colour changes, change it
 * in both places or the continuity breaks.
 * ======================================================================== */

.ds-modal-teal   { --ds-modal-accent: var(--ds-teal);   --ds-modal-tint: var(--ds-teal-tint); }
.ds-modal-blue   { --ds-modal-accent: var(--ds-blue);   --ds-modal-tint: var(--ds-blue-tint); }
.ds-modal-purple { --ds-modal-accent: var(--ds-purple); --ds-modal-tint: var(--ds-purple-tint); }
.ds-modal-amber  { --ds-modal-accent: var(--ds-amber);  --ds-modal-tint: var(--ds-amber-tint); }
.ds-modal-indigo { --ds-modal-accent: var(--ds-indigo); --ds-modal-tint: var(--ds-indigo-tint); }
.ds-modal-pink   { --ds-modal-accent: var(--ds-pink);   --ds-modal-tint: var(--ds-pink-tint); }


/* ===========================================================================
 * Motion
 * ======================================================================== */

@keyframes ds-modal-rise {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {

    .ds-modal-dialog {
        animation: none;
    }

    .ds-modal-option:hover,
    .ds-modal-option:focus-visible {
        transform: none;
    }
}


/* ===========================================================================
 * Larger screens
 *
 * The sheet becomes a centred dialog. This is the only breakpoint the component
 * needs, and it is `min-width` — style.css's 90 `max-width` queries are the
 * problem this layer exists to escape.
 * ======================================================================== */

@media (min-width: 768px) {

    .ds-modal {
        /* Still not `center` — `margin-block: auto` below does the centring and
           degrades safely when the dialog is taller than the viewport. */
        padding: var(--ds-space-6) var(--ds-space-4);
    }

    .ds-modal-dialog {
        width: min(560px, 100%);
        /*
         * 88, not 92, because this breakpoint adds 24px of padding above and
         * below; 88vh + 48px still clears a 640px-tall viewport. Same `vh`
         * fallback as the base rule, for the same reason.
         */
        max-height: 88vh;
        max-height: 88dvh;
        /* Centres while it fits, collapses to zero when it does not, so tall
           content overflows into the backdrop's scroll rather than off the top. */
        margin-block: auto;
        border-radius: var(--ds-radius-xl);
        animation-name: ds-modal-zoom;
    }

    .ds-modal-handle {
        display: none;
    }

    .ds-modal-head {
        padding-top: var(--ds-space-5);
    }

    .ds-modal-foot {
        padding-bottom: var(--ds-space-4);
    }

    .ds-modal-body:last-child {
        padding-bottom: var(--ds-space-5);
    }

    .ds-modal-options {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }

    /*
     * Back to the mockup's centred card once the options sit side by side.
     * Below this they are rows — see the note on `.ds-modal-option`.
     */
    .ds-modal-option {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: flex-start;
        gap: var(--ds-space-2);
        padding: var(--ds-space-4);
        text-align: center;
    }

    /*
     * A lone option is centred, and stays the width it would have been in a
     * pair.
     *
     * Both halves matter. Centring is what it looked like it wanted — packed
     * left under a centred header read as a layout bug rather than a choice.
     * Holding the width is the #395 lesson: a card that doubles in size when
     * it happens to be the only one stops reading as the same control.
     *
     * `:only-child` is exact here. The hidden `#extensionForm` is inside the
     * same `@if` as the second option, so a single option really is the grid's
     * only child — there is no case where one is visible and a sibling is not.
     */
    .ds-modal-option:only-child {
        grid-column: 1 / -1;
        justify-self: center;
        width: calc(50% - var(--ds-space-3) / 2);
    }

    /*
     * Unless the options are the whole dialog, in which case a lone card takes
     * the full width.
     *
     * Holding a column's width is right while something follows — the card is
     * one of a pair that happens to have one member, and the divider and picker
     * below give the dialog its shape. With nothing below it, the same card
     * left the dialog mostly empty and read as a layout accident. Here there is
     * no pair to be consistent with, so filling the width is what makes it look
     * deliberate.
     */
    .ds-modal-options-sole .ds-modal-option:only-child {
        width: 100%;
    }
}

@keyframes ds-modal-zoom {
    from { opacity: 0; transform: scale(0.96); }
    to   { opacity: 1; transform: scale(1); }
}


/* ===========================================================================
 * lightbox2, when it is opened from inside a modal
 *
 * Tapping a weekly photo in `#id03` opens lightbox2, which appends its own
 * overlay to <body>. Two things broke once that popup became a `.ds-modal`.
 *
 * 1. STACKING. `.lightboxOverlay` is z-index 9999 — below `.ds-modal`'s 10000 —
 *    so its dark backdrop hid behind the popup, while `.lightbox` (10000) tied
 *    and won on DOM order. The photo floated over a popup that was still fully
 *    lit, with no dimming between them.
 *
 * 2. POSITION, which is the one that was reported. `.lightboxOverlay` is
 *    `position: absolute; top: 0`, and lightbox2 sets `.lightbox`'s top to
 *    `scrollTop() + positionFromTop`. The body scroll lock in js/modal.js (#400)
 *    sets `body { position: fixed; top: -scrollY }` — so `scrollTop()` reads 0
 *    while the document is visually shifted up by the captured offset. Both
 *    elements land that many pixels ABOVE the viewport.
 *
 *    That is the "shifts or disappears" from the 09-11 sync (00:07:38). It is
 *    scroll-dependent, which is why it looks intermittent: a client who opened
 *    the popup without scrolling saw nothing wrong, and one who had scrolled
 *    down the dashboard lost the viewer entirely.
 *
 * Fixed positioning anchors both to the viewport, where the body offset cannot
 * reach them, and lightbox2's own `0 + positionFromTop` then resolves correctly
 * because the locked page really is at scroll 0.
 *
 * SCOPED TO `body.ds-modal-locked`, AND THAT IS LOAD-BEARING.
 *
 * An unscoped version of this shipped in the first pass and broke /food-log,
 * caught by visual QA on 2026-09-15 (F1). `position: fixed` is only correct
 * while the lock is on. Off it, `scrollTop()` returns the real offset, so
 * lightbox2's `scrollTop() + positionFromTop` is a document coordinate — fine
 * for the `absolute` element it was written for, and far off-screen once the
 * element is fixed. Measured at 390px on the food-log gallery, which sits well
 * down the page: the viewer landed at 2186px in an 844px viewport, so the page
 * dimmed and nothing appeared, on every tap.
 *
 * js/modal.js adds the class alongside the body lock and removes it with it, so
 * these rules apply exactly when the assumption behind them holds. Everywhere
 * else lightbox2 keeps its own positioning untouched.
 *
 * NOT addressed here: the sync recorded the cause as "production cropping the
 * image preview to a smaller size". That was the room's hypothesis, and image
 * dimensions do not explain a popup moving off-screen. If photos still render
 * at the wrong size on a device once this is on staging, that is a separate
 * fault and wants its own look.
 * ======================================================================== */

/*
 * `inset: 0` with the inline dimensions overridden, not just `position: fixed`.
 *
 * lightbox2 sizes this element in JS — `$overlay.width($(document).width())
 * .height($(document).height())` — so it carries an inline width and height
 * taken from the document. Under the body lock the document is a fixed,
 * clipped box, so that measurement is whatever the lock happens to leave
 * behind rather than the viewport, and a `fixed` element sized from it can
 * come up short. Mark's staging capture (2026-09-15, 390x844) shows exactly
 * that failure mode: the photo on screen with the sheet below it still fully
 * lit, no dimming anywhere.
 *
 * `inset: 0` plus `auto` dimensions makes a fixed element fill the viewport by
 * definition, and `!important` is what beats the inline values.
 */
body.ds-modal-locked .lightboxOverlay {
    z-index: 10010;
}

body.ds-modal-locked .lightbox {
    z-index: 10011;
}

/*
 * The POSITION half applies only under the fixed-body lock — that is the lock
 * that shifts the document and zeroes `scrollTop()`. On iOS (modal.js, the
 * event lock) the document is not moved at all, `scrollTop()` is the real
 * offset, and lightbox2's own `absolute` placement at `scrollTop + 50` is
 * correct as written; forcing `fixed` there would push the viewer DOWN by the
 * scroll offset instead — the 09-11 defect with the opposite sign.
 */
body.ds-modal-locked-fixed .lightboxOverlay {
    position: fixed;
    inset: 0;
    width: auto !important;
    height: auto !important;
}

body.ds-modal-locked-fixed .lightbox {
    position: fixed;
}


/* ==========================================================================
 * SweetAlert2 over a popup
 *
 * SweetAlert's container ships at `z-index: 1060`, and `.ds-modal` is 10000, so
 * an alert raised from inside a popup opens UNDERNEATH it — a sliver of white
 * above and below the dialog, the page dimming a second time, and nothing to
 * click.
 *
 * That is not cosmetic for Calculate Macros. Its button does not submit; it
 * calls handleFormSubmission() (custom/all.js), which asks "Are you sure?" and
 * only posts the form on Confirm. With the alert buried the form can never be
 * posted, and because that popup is deliberately non-dismissible the client is
 * stuck on it. Mark demonstrated exactly this at the 09-11 sync (00:09:20), and
 * no `users_nutrient_targets` row was written. It regressed when the popup
 * became a `.ds-modal` in #399; the old w3 modal sat below 1060.
 *
 * The weekly-photo "file too large" alert (dashboard.blade.php) is raised from
 * inside a popup too, and was hidden the same way.
 *
 * NOT scoped to `body.ds-modal-locked`, unlike the lightbox rules above, and
 * the difference is deliberate. Those change how lightbox2 is POSITIONED, which
 * is only right under the lock. This changes stacking order alone, and an alert
 * is meant to be the topmost thing on the page whether or not a popup is open —
 * so there is no state in which it is wrong, and it does not depend on the lock
 * class having been applied.
 *
 * Above the lightbox (10010 / 10011) for the same reason.
 * ======================================================================== */

.swal2-container {
    z-index: 10020;
}
