        /* ══════════════════════════════════════════════════════════════
           THE TWO OVERLAYS THIS APP SPEAKS THROUGH: the toast, and the confirm dialog.
           Plus the two utilities only they use — .sr-only and .btn-loading.

           Renamed from 18-legacy-overlays.css on 2026-09-22. It was never legacy: it uses ZERO
           outgoing-world tokens and every rule in it draws something live. The name was the only
           thing old about it, and it very nearly cost something — reading "legacy" I started
           emptying it into 05-interaction.css before checking, which would have put 421 lines of
           unrelated component into the shared part and left 05 at 1,670 lines of everything.

           IT HAS NO SURFACE, and that is why it is its own part rather than somebody else’s.
           DESIGN.md says a surface’s rules go in that surface’s part; a toast and a dialog
           belong to no room, they belong to the app. The number stays 18 because the position in
           the cascade is load-bearing and renaming must not move it.

           Three rules left here on 2026-09-22 and went to css/24-tail.css, where rules that are
           deliberately late belong: the oldstyle-figures heading rule, .amount-input’s tabular
           figures, and the currency option’s icon size. None of them was an overlay.
           ══════════════════════════════════════════════════════════════ */
        /* ========================================
           #9  Toast Notification System
           ========================================= */
        /* ---- The toast, in the deck log (2026-09-05) ------------------------------
           Was a floating card: paper, a 1px border, a coloured icon, a ×, 90% wide with
           a gap below the top edge. It is now the log's note: a strip the width of the
           frame, flush under the top edge, on the band with a firm rule beneath, the
           message in the write face. No icon and no × — words, and a tap anywhere takes
           it away. The one colour: a fault (error, warning) is written in port red,
           the fault colour the ruled entries already use. Success and information are
           ink; a toast that has to be a colour to be understood has failed as words.
           The container takes the world's tokens itself (see the scope selector). */
        .toast-container {
            position: fixed;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            /* 页栈根 1200、弹窗被抬到 2300、国家下拉再 +1 —— 提示条要盖过
               所有这些。它不吃点击(pointer-events:none),抬高没有副作用。 */
            z-index: 100000;
            display: flex;
            flex-direction: column;
            align-items: stretch;
            gap: 0;
            padding-top: env(safe-area-inset-top, 0px);
            pointer-events: none;
            width: 100%;
            max-width: 430px;
        }
        .toast {
            display: flex;
            align-items: center;
            min-height: 44px;
            padding: var(--sp-xs) var(--sp-lg);
            border-radius: 0;
            background: var(--log-stock-band, #CFD7D5);
            border: none;
            border-bottom: 1px solid var(--log-rule-firm, #6E7A77);
            width: 100%;
            pointer-events: auto;
            animation: toastIn 0.25s ease forwards;
            font-family: var(--log-write, var(--log-write));
            font-size: var(--fs-small, 13px);
            line-height: 1.5;
            color: var(--log-ink, #43276B);
            cursor: pointer;
        }
        .toast + .toast { border-top: 1px solid var(--log-rule, #9FAAA7); }
        .toast.toast-exit {
            animation: toastOut 0.2s ease forwards;
        }
        .toast-error,
        .toast-warning { color: var(--log-port, #C4111F); }
        .toast-body { flex: 1; }
        @media (prefers-reduced-motion: reduce) {
            .toast, .toast.toast-exit { animation: none; }
        }
        @keyframes toastIn {
            from { opacity: 0; transform: translateY(-12px); }
            to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes toastOut {
            from { opacity: 1; transform: translateY(0); }
            to   { opacity: 0; transform: translateY(-12px); }
        }

        /* ========================================
           #9  Confirm / Prompt Dialog
           =========================================
           Brought into the deck log on 2026-09-08, the last thing in the app still wearing
           the outgoing world: Vollkorn, --bg-secondary, --border, a filled --primary button
           and a brick red #9B4D4D hardcoded for danger. It survived because it is opened
           from seven places and drawn from none of them, so no surface's rebuild ever
           reached it — the owner found it on Logout and Delete, which is where anybody
           would.

           A small ruled sheet, not a card. The words are the controls, as everywhere else:
           print caps, underlined, in the one ink. No filled buttons anywhere in this world.

           MAGENTA WHERE THE ACT CHANGES THE RECORD, and nowhere else. `danger` used to mean
           "paint it red for fright"; here it means "this writes to the record", which is
           what the colour is reserved for. Logout is the only caller that does not take it,
           because signing out changes nothing but this device. */
        /* ⚠️ THE ROOT IS THE THING THAT IS FIXED, and it lives on <body>. It was inside
           .app-frame, whose desktop transform makes a stacking context — so this overlay's
           20000 was confined to the frame and lost to the page stack's 1200 outside it. See
           the note in dialogShell (01-core.js): on a handset the frame has no transform and
           the same markup worked, which is how it survived a capture suite that is 430 wide.

           `left` and `width` are set by alignOverlaysToFrame(), like every other overlay on
           <body>; without them a dialog would cover a 1280px window rather than the 430px
           app inside it. Transparent to taps when it holds nothing, so the page under it
           stays usable — the overlay itself takes them back. */
        #confirmDialogRoot {
            position: fixed;
            top: 0;
            height: 100dvh;
            z-index: 20000;
            pointer-events: none;
        }

        .confirm-overlay {
            position: absolute;
            inset: 0;
            pointer-events: auto;
            /* The oilskin at a low pressure, the same wash the drawer's scrim uses. */
            background: rgba(22, 25, 26, 0.42);
            /* Above the page stack, including the two pages that leave it.
               Ordinary pages are z-index 1; the sign-in and register sheets are fixed at
               10000 (css/02-auth.css), so at the old 8000 a dialog opened over either of
               them was painted BEHIND it — laid out, opaque, and invisible. Nothing on
               those two sheets opens one today, so no reader has met it; the capture
               fixture written for these dialogs met it immediately, which is the whole
               argument for photographing a thing rather than reasoning about it. Below
               the toast at 100000, which must outrank everything.

               ⚠️ The number now lives on #confirmDialogRoot above, because a z-index is only
               ever read within its own stacking context and this one was being read inside
               .app-frame's. */
            display: flex;
            align-items: center;
            justify-content: center;
            padding: var(--sp-lg);
            animation: fadeIn 0.2s ease;
        }
        @media (prefers-reduced-motion: reduce) {
            .confirm-overlay { animation: none; }
        }

        /* ── carried from css/10-legacy-home-sheet.css on 2026-09-21, as that part was deleted.
           It was written to outlive its own rules ("so it is written down here rather than lost
           with the rules") and it names this dialog, so this is where it goes. ── */
        /* THE WARNING DIALOG'S DRESSING WENT ON 2026-09-11, with the five modals that wore
           it (owner: delete this entry 也是旧世界的 … 格式可以统一改吗). Deleting anything
           goes through `showConfirm` now — .confirm-overlay / .confirm-dialog in
           css/18-legacy-overlays.css, the dialog Delete Account already used.

           ⚠️ WHAT THE OLD BLOCK ARGUED IS STILL TRUE AND IS STILL HONOURED, so it is
           written down here rather than lost with the rules: nothing in this app can be
           undone, so this layer is the only gate and it must actually be read. The block
           that stood here had already fixed that once — it took out the 60px ⚠ box, the
           centred title and the two lines of prose whose FIRST SENTENCE WAS THE SAME
           "This action cannot be undone" in all five places. A sentence that appears in
           every delete dialog is a sentence that appears in none of them; what needs
           reading is what ELSE goes with it. The new dialogs keep that rule and go one
           further — they say what is being deleted BY NAME, which none of the five did.

           It also killed a padding that four layers each gave once (.modal 24 →
           .modal-content 24 → .modal-body 24/32 → .warning-content 24), leaving 132px of
           text in a 340px box. `.confirm-dialog` has one owner for its padding. */

        .confirm-dialog {
            background: var(--log-stock);
            border: 1px solid var(--log-rule-firm);
            width: 100%;
            max-width: 360px;
            box-sizing: border-box;
            /* Nine types of activity ask on this card since 2026-09-13; on a short screen it
               scrolls inside itself rather than running off the bottom. */
            max-height: calc(100dvh - 2 * var(--sp-lg));
            overflow-y: auto;
            overscroll-behavior: contain;
        }

        .confirm-message {
            margin: 0;
            white-space: pre-line;   /* a message that lists things puts each on its own line (the spreadsheet preview) */
            padding: var(--sp-md) var(--sp-lg);
            font-family: var(--log-write);
            font-size: var(--fs-small);
            line-height: 1.5;
            color: var(--log-ink);
        }

        /* The writing line's grammar, the same one Settings, the password form, Saved's
           search and the Tool kit's amount all use: no box, transparent on the stock, the
           ink caret, and a two-pixel ink baseline when it has the cursor. */
        .confirm-input {
            display: block;
            width: 100%;
            box-sizing: border-box;
            min-height: 44px;
            margin: 0;
            padding: 0 var(--sp-lg);
            border: none;
            border-bottom: 1px solid var(--log-rule);
            background: transparent;
            font-family: var(--log-write);
            font-size: var(--fs-body);
            color: var(--log-ink);
            caret-color: var(--log-ink);
            -webkit-appearance: none;
            appearance: none;
        }
        .confirm-input::placeholder { color: var(--log-ink-soft); }

        /* A name for the field that is not drawn. The prompt dialog's placeholder carries
           the whole instruction — type DELETE — and a placeholder is not a label: it is
           gone at the first keystroke and is not announced as a name. Everywhere else in
           this world the label is drawn, because a drawn label is better; here the sentence
           above the field already says it in full, and printing it twice would be the app
           reading itself aloud. */
        .sr-only {
            position: absolute;
            width: 1px;
            height: 1px;
            padding: 0;
            margin: -1px;
            overflow: hidden;
            clip: rect(0 0 0 0);
            clip-path: inset(50%);
            white-space: nowrap;
            border: 0;
        }
        .confirm-input:focus {
            outline: none;
            background: var(--log-stock-lit, #DEE4E2);
            box-shadow: inset 0 -2px 0 0 var(--log-ink);
        }

        /* A SHEET OF CHOICES, for showChoice — several things you could add, rather than a
           yes and a no. It replaced a floating menu of four icons, so the thing it must not
           become is a stack of buttons: these are ruled rows, read down, the way every other
           list in this world is read.

           Every line here writes to the record, which is what the magenta marks. That is the
           one case where a sheet carries more than one: not several acts of different kinds
           competing, but one kind of act offered four ways. */
        .confirm-choices {
            display: block;
            border-top: 1px solid var(--log-rule);
        }
        .confirm-choice {
            display: block;
            width: 100%;
            box-sizing: border-box;
            min-height: 56px;
            padding: var(--sp-sm) var(--sp-lg);
            text-align: left;
            background: none;
            border: none;
            border-bottom: 1px solid var(--log-rule);
            cursor: pointer;
        }
        .confirm-choice-what {
            display: block;
            font-family: var(--log-print);
            font-size: var(--fs-micro);
            font-weight: 600;
            letter-spacing: 0.16em;
            text-transform: uppercase;
            text-decoration: underline;
            text-underline-offset: 0.25em;
            text-decoration-thickness: 1px;
            color: var(--log-magenta);
        }
        /* What it does, in the traveller's own voice — the printed word says the act, the
           written line says what will be written down. */
        .confirm-choice-note {
            display: block;
            margin-top: 3px;
            font-family: var(--log-write);
            font-size: var(--fs-small);
            line-height: 1.35;
            color: var(--log-ink-soft);
        }
        .confirm-choice:active { box-shadow: inset 0 -2px 0 0 var(--log-magenta); }

        .confirm-actions {
            display: flex;
            gap: var(--sp-lg);
            justify-content: flex-end;
            align-items: center;
            padding: 0 var(--sp-lg);
            border-top: 1px solid var(--log-rule);
        }

        .confirm-btn {
            min-height: 44px;
            padding: 0;
            background: none;
            border: none;
            cursor: pointer;
            font-family: var(--log-print);
            font-size: var(--fs-micro);
            font-weight: 600;
            letter-spacing: 0.16em;
            text-transform: uppercase;
            text-decoration: underline;
            text-underline-offset: 0.25em;
            text-decoration-thickness: 1px;
            color: var(--log-ink);
        }
        /* The Ink-Not-Glow Rule: pressing thickens a baseline rather than tinting a ground. */
        .confirm-btn:active { box-shadow: inset 0 -2px 0 0 currentColor; }
        .confirm-btn:focus-visible {
            outline: 2px solid var(--log-ink);
            outline-offset: 2px;
        }
        /* Cancel is the quieter of the two, in the softer pressure of the same ink. */
        .confirm-btn-cancel { color: var(--log-ink-soft); }
        .confirm-btn-ok     { color: var(--log-ink); }
        .confirm-btn-danger { color: var(--log-magenta); }

        /* The currency picker dialog's dressing stood here — .currency-picker-grid and
           .currency-pick-item, a three-column grid of fifteen codes in the outgoing world's
           filled --primary. Its function had no caller anywhere in public/ and is gone too
           (see the note where showCurrencyPicker was, in 01-core.js). The app's own
           searchable picker has all hundred and fifty-five codes and is what every form
           actually uses. Removed 2026-09-08. */

        /* #11 was the notification drawer's dress: the sliding panel, its backdrop,
           its header actions, the list, the rows, the icon circles and the empty
           state. Nothing opened the drawer and the markup is gone (2026-09-07); the
           page that replaced it is public/css/17a-notifications.css, in the deck log.
           ========================================= */

        /* ========================================
           #12 Loading States
           ========================================= */
        .btn-loading {
            position: relative;
            pointer-events: none;
            opacity: 0.7;
        }
        .btn-loading::after {
            content: '';
            position: absolute;
            width: 14px;
            height: 14px;
            border: 2px solid transparent;
            border-top-color: currentColor;
            border-radius: 50%;   /* gate:allow rotate — 旋转动画元素，方形旋转会露角，是视觉故障不是风格选择 */
            animation: btnSpin 0.6s linear infinite;
            right: 0.75rem;
            top: 50%;
            margin-top: -7px;
        }
        @keyframes btnSpin {
            to { transform: rotate(360deg); }
        }
        /* .loading-overlay and its spinner moved to css/17zd-leftovers.css, in the deck log, 2026-09-14. */

        @keyframes fadeIn {
            from { opacity: 0; }
            to   { opacity: 1; }
        }

        /* ===== "Add Expense — mirrors the Add Activity card exactly" — REMOVED 2026-09-21 =====
           188 lines, every rule scoped `#expenseModal .form-section ...` (plus one
           `#expenseModal .modal-body > form > .form-row-split`). The block's own note said
           "Scoped to .form-section, which only the new markup uses … Delete this whole block once
           both forms share one template." Both forms do share one template now — the receipt of
           2026-09-13 — and it has no .form-section and no .modal-body at all. Verified in the live
           DOM: #expenseModal counts 0 for .form-section, .modal-body, .form-label, .form-input,
           .amount-input, .category-selector and .form-row-split. The instruction was left in 2026;
           this is it being carried out. Took 5 outgoing-world tokens with it. */

        /* ===== Expense modal dropdowns — removed 2026-09-13 =====
           Two rules stood here, #expenseModal .currency-dropdown and #expenseModal
           .currency-option, "placed at the end of the stylesheet so equal-specificity rules
           above cannot win on source order". That is exactly what they did to the receipt: at
           the same specificity as 17v-expense-form.css and loaded after it, they gave the
           currency list a white field ground, 13px sans and #242424, while the category and
           method lists beside it were the deck log's. The receipt names every property of
           its lists in 17v; nothing of the old dropdown is wanted, so they are gone rather
           than overridden. */

        /* The category menu carries icons; scale them to the option's type size. */

        /* 原来这里写着「让列表按内容自己撑开，而不是跟随它挂着的那个 26% 单元格」——
           `right: auto; min-width: 132px`。那是搜索行之前的权衡：列表要横着放下
           "AED إ.د" 这类标签，所以宁可比触发器宽。
           现在列表是筛出来的，一次只看几行，不需要为最长的那个标签留位置 ——
           所以撤掉，宽度回到跟随触发器（基底的 left:0 / right:0）。 */

        /* ══════════════════════════════════════════════════════════════
           MOVED OUT OF css/24-tail.css · 2026-09-21  (appended)
           1 rules. DESIGN.md: a surface's rules go in that surface's part, and the
           legacy parts only shrink.
           ⚠️ 24-tail.css loaded AFTER this file. Placing them
           at the foot keeps their order against THIS PART’S OWN rules — and nothing else.
           Against the parts BETWEEN #46 and #51 the order is reversed by this move, and
           whether that matters was a separate question somebody had to answer. See DESIGN.md.
           ══════════════════════════════════════════════════════════════ */
        /* The heading band, as on every sheet in this world. */
        /* ⚠️ THIS BELONGS UP WITH THE REST OF THE DIALOG, not at the foot. It was swept into
           css/24-tail.css on 2026-09-22 by a pattern meant for the five-selector typography rule
           that also names .confirm-title, and brought back the same day. Nothing drew differently
           either way (checked with dev/css-flip.mjs, both directions), so it is left where the
           move put it rather than shuffled a second time for tidiness. */
        .confirm-title {
            margin: 0;
            padding: var(--sp-xs) var(--sp-lg);
            background: var(--log-stock-band);
            border-bottom: 1px solid var(--log-rule-firm);
            font-family: var(--log-print);
            font-size: var(--fs-micro);
            font-weight: 600;
            letter-spacing: 0.18em;
            text-transform: uppercase;
            color: var(--log-ink);
        }
