/* ============================================================================
 *  portal-ios.css — the grouped-settings-list component family.
 *
 *  The iOS Settings idiom, built entirely out of the portal's own tokens: a
 *  page is a stack of GROUPS; a group is a title, an inset rounded LIST, and a
 *  footer note; a list is a stack of ROWS; a row is [icon] [title/subtitle]
 *  [value] [control or chevron]. Anything that needs more than a row opens a
 *  SHEET.
 *
 *  GLOBAL, loaded last in base.twig's stack. It began as an opt-in per-page
 *  sheet for /account/notifications; the 2026-08-13 app-look sweep made the
 *  group the portal's standard section container, at which point "remember to
 *  link it" was a step ~40 pages could forget. Last in the stack because that
 *  is where headEnd used to put it — in particular it must stay after
 *  portal-kit.css, which .portal-ios-row relies on to win at equal specificity.
 *
 *  A group holds whatever the section holds. Rows are the common case, but a
 *  table or a form in a .portal-ios-panel is equally correct and is what
 *  /system-management's Audit Log tab does — taking the chrome without
 *  becoming a list is the whole reason this generalises.
 *
 *  House rules this file keeps to:
 *    · Every colour is a --portal-c-* token or a color-mix of one, so both
 *      themes come for free and neither can drift. No literals except the
 *      geometry (iOS is a spec — 44px rows, 51x31 switches — and those are
 *      lengths, not colours).
 *    · Radii come off the Bootstrap scale the portal re-points
 *      (--bs-border-radius-lg is 10px, which IS the iOS grouped-list radius).
 *    · Cards never get backdrop-filter (nested blur is what cost frames the
 *      first time round) — lists are translucent ink + edge light, same
 *      material as .card. Only the sheet, an overlay, blurs.
 *    · Every motion has a prefers-reduced-motion answer at the end.
 * ========================================================================== */

/* On :root, not on a .portal-ios scope class. The family was opt-in per page
   until the app-look sweep; now that the sheet is in the global stack, needing
   a class on <main> as well would leave a step a new page can forget — and a
   forgotten step here does not fail loudly, it just renders a list with a 0
   gutter and 0-height rows. Nothing ever scoped to .portal-ios but this block,
   so hoisting it costs nothing and the class is gone. */
:root {
    /* Separator inset. iOS starts the hairline at the TEXT, not the row edge,
       so a list with icons indents further — set per row by :has() below. */
    --portal-ios-gutter: 1rem;
    --portal-ios-icon: 1.8125rem;      /* 29px — the iOS Settings glyph tile */
    --portal-ios-icon-gap: 0.75rem;
    --portal-ios-sep-inset: var(--portal-ios-gutter);
    --portal-ios-row-min: 2.75rem;     /* 44px — the HIG touch target */
    /* The family's own 15px row text (v3 ladder role). */
    --portal-ios-row-text: 0.9375rem;
}

/* ── Group: title, list, footer note ──────────────────────────────────── */

.portal-ios-group {
    margin-bottom: 1.75rem;
}

.portal-ios-group:last-child {
    margin-bottom: 0;
}

/* Mono uppercase — the house treatment for a label that CLASSIFIES data
   rather than decorating it (same voice as the alerts grid's group heads),
   and coincidentally the classic iOS grouped-table header.

   TWO selectors, ONE declaration block, deliberately. `.portal-section-label`
   is the same label standing above content that is NOT a group surface — a
   grid of launcher cards on /tools, a prose section on a playbook. That case
   is real and the kit had no answer for it, so /tools grew a private copy in
   tools.css and it drifted: 11.52px/500/1.38px-tracking against this block's
   11px/600/0.88px, close enough to look intentional and wrong enough that the
   page did not match the rest of the portal. Two more templates then used the
   class name off that page, where the rule does not load at all, and their
   headings rendered as plain <h2>s.

   So the voice lives in one place and takes two selectors. The ONLY difference
   between them is the gutter below, and that difference is structural: a group
   title insets to line up with its list's padding, and a bare label lines up
   with whatever it labels (a Bootstrap .row's columns already carry their own
   gutter, so the inset would push the label right of the cards under it). */
.portal-section-label,
.portal-ios-group-title {
    font-family: var(--portal-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
    font-size: var(--portal-text-2xs);
    font-weight: var(--portal-weight-label);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
    color: var(--portal-c-text-muted);
    /* line-height 1, NOT normal: the mono face's normal at 11px is 13.1875px
       — a fractional height that every section below a label inherits as a
       fractional offset, which is what made glyph rasterisation BIMODAL in
       the screenshot suite (the long-standing one-in-four stat-row flake,
       finally diagnosed 2026-08-24). A label is one line by construction;
       whole pixels are worth more than 2px of leading nobody reads. */
    line-height: 1;
    margin: 0 0 0.5rem;
}

.portal-ios-group-title {
    padding: 0 var(--portal-ios-gutter);
}

/* A group title with controls at its far end — a period picker, a refresh, an
   export. A settings group normally has nothing to say at the top, which is why
   the family shipped without this; then /system-management and
   /account/notifications each invented one within 48 hours, so it is a slot,
   not an exception. Takes over the title's own gutter padding so the actions
   land the same 1rem in from the list edge that the title does.

   It is a LABEL ROW, not a surface: no background, no border, no radius. If a
   section wants those, it wanted a card and the conversion to a group is
   wrong. */
.portal-group-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
    padding: 0 var(--portal-ios-gutter);
    margin-bottom: 0.5rem;
}

.portal-group-head .portal-ios-group-title {
    padding: 0;
    margin-bottom: 0;
}

.portal-group-head-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-left: auto;
}

/* The footer note under a group. iOS puts the explanation HERE rather than
   inside the row, which is why these lists stay scannable however much needs
   saying — the row carries the control, the note carries the sentence. */
.portal-ios-group-note {
    font-size: var(--portal-text-sm);
    line-height: 1.45;
    color: var(--portal-c-text-muted);
    margin: 0.5rem 0 0;
    padding: 0 var(--portal-ios-gutter);
}

.portal-ios-group-note + .portal-ios-group-note {
    margin-top: 0.35rem;
}

/* A note that is a problem, not an aside. */
.portal-ios-group-note.is-warning {
    color: var(--bs-warning-text-emphasis, var(--bs-warning));
}

/* ── List: the inset rounded container ────────────────────────────────── */

.portal-ios-list {
    background-color: var(--portal-c-surface);
    background-image: var(--portal-c-surface-sheen);
    border: 1px solid var(--portal-c-border);
    border-radius: var(--bs-border-radius-lg);
    box-shadow: var(--portal-c-edge-card), var(--portal-c-shadow);
    /* Rows are square-cornered; the list clips them into its own radius, which
       is how the first and last row get their corners without either of them
       having to know it is first or last. */
    overflow: hidden;
}

/* A list holding a form, prose, or anything else that is not a row. Rows carry
   their own padding, so .portal-ios-list has none; everything else needs it
   back. This is the class that lets a group wrap arbitrary section content
   instead of only a settings list, which is what makes the container swap a
   portal-wide move rather than a settings-page one.

   Content that brings its OWN padding stays flush and skips this — a table
   (its cells pad themselves) or an embedded list of cards. */
.portal-ios-panel {
    padding: 1rem;
}

/* ── Row ──────────────────────────────────────────────────────────────── */

.portal-ios-row {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--portal-ios-icon-gap);
    width: 100%;
    min-height: var(--portal-ios-row-min);
    padding: 0.5rem var(--portal-ios-gutter);
    margin: 0;
    background: none;
    border: 0;
    color: var(--portal-c-text);
    text-align: left;
    text-decoration: none;
    font-size: var(--portal-title-card);
}

/* Hairline separator, inset past the icon. ::after rather than a border so it
   can be inset from the left without the row losing its full-bleed background
   on hover/press. */
.portal-ios-row:not(:last-child)::after {
    content: "";
    position: absolute;
    left: var(--portal-ios-sep-inset);
    right: 0;
    bottom: 0;
    height: 1px;
    /* The standard hairline, not -subtle: at 5% on dark glass a subtle rule
       vanishes, and a grouped list with no visible separators is just a
       paragraph of controls. */
    background-color: var(--portal-c-border);
    pointer-events: none;
}

/* A row WITH an icon starts its hairline at the text. Per-row rather than a
   list modifier, so a mixed list (icon rows + a plain footer row) still lines
   each separator up with the thing above it. */
.portal-ios-row:has(.portal-ios-row-icon) {
    --portal-ios-sep-inset: calc(var(--portal-ios-gutter) + var(--portal-ios-icon) + var(--portal-ios-icon-gap));
}

/* Tappable rows: links, buttons, and anything that opens a sheet. iOS gives
   these a press wash rather than a hover state — the hover is the desktop
   affordance, the :active is the phone one, and they share a colour. */
a.portal-ios-row,
button.portal-ios-row,
.portal-ios-row--tappable {
    cursor: pointer;
    transition: background-color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease-out);
}

a.portal-ios-row:hover,
button.portal-ios-row:hover,
.portal-ios-row--tappable:hover {
    background-color: var(--portal-c-tab-hover);
    color: var(--portal-c-text);
}

/* Rows do NOT scale. A settings row is up to 900px wide, and scaling one by
   even 1% moves its hairlines off the pixel grid — the whole list tears. The
   wash deepens instead, and it deepens FAST, because on a row the wash is the
   only feedback there is. */
a.portal-ios-row:active,
button.portal-ios-row:active,
.portal-ios-row--tappable:active {
    background-color: var(--portal-c-accent-muted);
    transition-duration: var(--portal-dur-0, 80ms);
}

.portal-ios-row:focus-visible {
    outline: 2px solid var(--portal-c-accent);
    outline-offset: -2px;
}

/* A row whose control cannot be used yet (channel not set up, alert not live).
   Dimmed, never hidden — the reason lives in the group note underneath. */
.portal-ios-row.is-disabled {
    opacity: 0.5;
}

.portal-ios-row.is-disabled,
.portal-ios-row.is-disabled * {
    cursor: not-allowed;
}

/* Quieted, but still yours to tap — a paused or snoozed row is a state you
   chose and will want to undo, so it must not read as unavailable the way
   .is-disabled does. */
.portal-ios-row.is-dim {
    opacity: 0.55;
}

/* ── Row parts ────────────────────────────────────────────────────────── */

/* The squircle glyph tile. Tinted fill + light glyph, one tint per meaning —
   derived from the accent/state inks by color-mix so this can never become a
   rainbow of literals that drifts away from the palette. */
.portal-ios-row-icon {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--portal-ios-icon);
    height: var(--portal-ios-icon);
    border-radius: var(--bs-border-radius);
    background-color: var(--portal-ios-tint, var(--portal-c-text-muted));
    color: #fff;
    font-size: var(--portal-text-sm);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}

.portal-ios-row-icon[data-tint="accent"] { --portal-ios-tint: var(--portal-c-accent); color: var(--portal-c-on-accent); }
.portal-ios-row-icon[data-tint="brand"]  { --portal-ios-tint: var(--portal-c-brand); }
.portal-ios-row-icon[data-tint="success"]{ --portal-ios-tint: var(--bs-success); }
.portal-ios-row-icon[data-tint="warning"]{ --portal-ios-tint: var(--bs-warning); color: #1c1b24; }
.portal-ios-row-icon[data-tint="danger"] { --portal-ios-tint: var(--bs-danger); }
.portal-ios-row-icon[data-tint="muted"]  { --portal-ios-tint: color-mix(in srgb, var(--portal-c-text-muted) 70%, transparent); }

/* PARSE FLOOR (gate 4, portal-components.css) — the sixteenth color-mix token,
   and the only one not on a theme block. On an engine without color-mix the
   declaration above is accepted, stored as guaranteed-invalid, and the tile
   loses its tint entirely: a grey square where five of its six siblings are
   coloured, which reads as a broken row rather than a plain one.

   The twin is the muted ink at full strength rather than a per-theme literal.
   --portal-c-text-muted is theme-dependent, so a literal would need one value
   per theme for a difference of 30% alpha on one icon; a slightly firmer grey
   is the honest degrade. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
    .portal-ios-row-icon[data-tint="muted"] { --portal-ios-tint: var(--portal-c-text-muted); }
}

html[data-portal-engine="legacy"] .portal-ios-row-icon[data-tint="muted"] {
    --portal-ios-tint: var(--portal-c-text-muted);
}

.portal-ios-row-main {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.075rem;
}

.portal-ios-row-title {
    font-weight: var(--portal-weight-medium);
    line-height: 1.3;
}

.portal-ios-row-sub {
    font-size: var(--portal-text-sm);
    line-height: 1.35;
    color: var(--portal-c-text-muted);
}

/* Trailing value text ("On", "3 alerts", "Instant"). Sits immediately before
   the chevron, muted, and never wraps — a value that grows pushes the title
   into ellipsis instead of stacking the row. */
.portal-ios-row-value {
    flex: 0 1 auto;
    min-width: 0;
    font-size: var(--portal-text-md);
    color: var(--portal-c-text-muted);
    text-align: right;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* The value is the row's WARNING when what it reports is a problem. */
.portal-ios-row-value.is-warning {
    color: var(--bs-warning-text-emphasis, var(--bs-warning));
}

/* A value that is a FIGURE rather than a state ("42", "$18.50", "73%"). Reads
   brighter than the muted default because in a stats list the number is the
   point of the row, and tabular so a column of them lines up on the decimal
   instead of shimmering. */
.portal-ios-row-value--figure {
    font-family: var(--portal-font-mono, var(--bs-font-monospace));
    font-variant-numeric: tabular-nums;
    color: var(--portal-c-text);
}

.portal-ios-row-chevron {
    flex: 0 0 auto;
    font-size: var(--portal-text-xs);
    /* iOS's chevron is notably lighter than the label beside it. */
    color: color-mix(in srgb, var(--portal-c-text-muted) 65%, transparent);
    margin-left: 0.125rem;
}

/* Right-hand controls (switch, stepper, select) never shrink. */
.portal-ios-row-control {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
}

/* An inline select inside a row — trimmed to read as iOS's grey value text
   with a chevron rather than as a form field in a list. */
.portal-ios-row-select {
    border: 0;
    background: none;
    padding: 0.15rem 1.35rem 0.15rem 0.35rem;
    font-size: var(--portal-text-md);
    color: var(--portal-c-text-muted);
    text-align: right;
}

.portal-ios-row-select:focus-visible {
    outline: 2px solid var(--portal-c-accent);
    outline-offset: 1px;
    border-radius: var(--bs-border-radius-sm);
}

/* A full-width action row that is a verb, not a setting (iOS's blue/red
   centred rows: "Sign Out", "Delete Account"). */
.portal-ios-row--action {
    justify-content: center;
    color: var(--portal-c-accent);
    font-weight: var(--portal-weight-medium);
}

.portal-ios-row--action:hover {
    color: var(--portal-c-accent);
}

.portal-ios-row--action.is-danger {
    color: var(--bs-danger);
}

/* ── Switch ───────────────────────────────────────────────────────────
   Lives in portal-kit.css as `.portal-switch`, not here. It stopped being an
   iOS-list component the moment /system-management's feature flags adopted it:
   that page needs the same control in a list this family does NOT own (its
   switches LEAD their labels, deliberately — see .sysmgmt-flag-item). portal-kit
   is loaded globally, so any page can use the switch without opting into the
   list family. Rows here just reserve space for it via .portal-ios-row-control
   or by letting it sit as the row's last flex child. */

/* ── Status pill ──────────────────────────────────────────────────────
   Folded into the kit's .portal-chip (portal-kit.css) — same block carries
   .portal-ios-status as an alias for one release. The bs-emphasis colours
   it used here are the badge-token family there, so the page's one green
   stopped being two. */

/* ── Phone: a row's value gets its own line ───────────────────────────
   A row with a subtitle AND a value has three things competing for the ~300px
   left after the icon and the chevron. The value lost: "Coming soon" rendered
   as "Comin…", "In-app, Email · Digest" as "In…" — and the value is the one
   part of the row that is a STATE rather than an explanation, so truncating it
   deletes the answer and keeps the question.

   So it drops to its own full-width line, indented to line up with the text
   above it. Nothing is hidden and nothing is ellipsed.

   The condition is doing real work, not decoration:
     · :has(.portal-ios-row-sub) — a row with no subtitle has room already
       (that is the plain "Sound … [switch]" shape), and stacking it would put
       a lone value under a title for no reason.
     · --inline — a value that is only a count badge is two characters wide and
       never competed for anything. The Inbox row opts out and keeps it inline.

   EVERY rule below repeats the full condition rather than sharing a shorter
   prefix. The first cut scoped the wrap one way and the `order` another, so on
   rows that matched only the looser half the orders applied while the wrap did
   not — which threw the summary card's status pill to the front of its row and
   crushed each alert's text into a column two words wide. A partial match here
   is worse than no rule at all.

   Do NOT nest :has() inside :has() to express this. It is invalid CSS, the
   whole selector is dropped silently, and that is exactly how the above
   shipped for one round. `:has(.x:not(.y))` is fine; `:has(.x:not(:has(.y)))`
   is not. */
@media (max-width: 575.98px) {
    .portal-ios-row:has(.portal-ios-row-sub):has(.portal-ios-row-value:not(.portal-ios-row-value--inline)) {
        flex-wrap: wrap;
        row-gap: 0.2rem;
    }

    .portal-ios-row:has(.portal-ios-row-sub):has(.portal-ios-row-value:not(.portal-ios-row-value--inline)) > .portal-ios-row-icon    { order: 1; }
    /* flex-basis 0, not auto. With wrapping on, the flex algorithm places items
       by their basis — and `auto` means the subtitle's max-content width, which
       is wider than the row. Main claimed line 1 by itself and grew into it,
       so the CHEVRON wrapped onto a line of its own above the value. A zero
       basis lets icon + text + chevron share line 1, then main grows into
       whatever is left. */
    .portal-ios-row:has(.portal-ios-row-sub):has(.portal-ios-row-value:not(.portal-ios-row-value--inline)) > .portal-ios-row-main    { order: 2; flex: 1 1 0; }
    .portal-ios-row:has(.portal-ios-row-sub):has(.portal-ios-row-value:not(.portal-ios-row-value--inline)) > .portal-ios-row-chevron { order: 3; }

    .portal-ios-row:has(.portal-ios-row-sub):has(.portal-ios-row-value:not(.portal-ios-row-value--inline)) > .portal-ios-row-value {
        order: 4;
        /* Align with the title above rather than the row edge — the value is
           part of what the row says, not a second column.

           Two things had to be true and only one was. The indent comes off
           --portal-ios-sep-inset rather than off the icon width, because the
           inset is already the per-row answer to "how far in does the text
           start" and resolves to the gutter alone on a row with no icon — the
           first cut indented by icon + gap unconditionally, so an icon-LESS row
           with a subtitle and a value (a label/value pair on /my-event) pushed
           its value clear of the list.

           And the basis has to pay for that indent. `flex: 1 0 100%` resolves
           against the row's content box and the margin sits OUTSIDE it, so
           basis + margin was always one indent wider than the row — with an
           icon that was 24px, and because flex-shrink is 0 and the list clips,
           it did not spill, it quietly ate the last 24px of every line of a
           wrapped value. Invisible on /account/notifications for a day, and
           invisible to its 24 assertions. */
        flex: 1 0 calc(100% - (var(--portal-ios-sep-inset) - var(--portal-ios-gutter)));
        margin-left: calc(var(--portal-ios-sep-inset) - var(--portal-ios-gutter));
        text-align: left;
        white-space: normal;
    }
}

/* ── Sheet ────────────────────────────────────────────────────────────── */
/* Built on Bootstrap's modal so it inherits the portal's overlay glass, focus
   trap and backdrop. This only changes the GEOMETRY: a phone gets a sheet that
   rises from the bottom edge with a grabber; anything wider keeps the centred
   dialog, which is what iPadOS and macOS do with the same content. */

.portal-ios-sheet .modal-content {
    /* The grabber is positioned against this. */
    position: relative;
}

.portal-ios-sheet .portal-ios-sheet-grabber {
    display: none;
}

.portal-ios-sheet .modal-body {
    padding: 1rem;
}

/* The sheet's lists sit on the overlay material, which is already a surface —
   a second translucent layer on top of it reads as a smudge, so lists inside
   a sheet go opaque-on-header instead. */
.portal-ios-sheet .portal-ios-list {
    background-color: var(--portal-c-surface-header);
    background-image: none;
    box-shadow: none;
}

@media (max-width: 575.98px) {
    .portal-ios-sheet .modal-dialog {
        margin: 0;
        position: absolute;
        bottom: 0;
        left: 0;
        right: 0;
        max-width: none;
        /* These sheets carry -centered and -scrollable so they stay ordinary
           centred dialogs on desktop, and BOTH of those size the dialog rather
           than its content: -centered sets min-height:100% and centres what is
           inside it, -scrollable sets height:calc(100% - margin*2). Pinned to
           the bottom edge, that left a viewport-tall box with the sheet
           floating in the middle of it — first half a screen off the floor,
           then a stubborn 24px. Hugging the content is what puts a bottom sheet
           on the bottom. */
        min-height: 0;
        height: auto;
    }

    /* The selector is long ON PURPOSE. portal-components.css styles every
       portal dialog through
         body:has(.portal-page-themed) .modal .modal-content:not(.portal-search-content)
       which outweighs a plain `.portal-ios-sheet .modal-content` — so the first
       cut's radius override silently lost and the sheet kept all four of the
       dialog's rounded corners, leaving the page visible through the two
       sitting on the bottom edge. Matching that weight (and adding one class)
       is what makes this win without an !important.
       The RADIUS itself stays the house dialog radius: a sheet is not a
       different material from a modal, it is a modal resting on the floor. */
    body:has(.portal-page-themed) .modal.portal-ios-sheet .modal-content:not(.portal-search-content),
    .portal-ios-sheet .modal-content {
        border-radius: var(--bs-border-radius-xl) var(--bs-border-radius-xl) 0 0;
    }

    .portal-ios-sheet .modal-content {
        /* Stop BELOW the topbar, don't cover it. 92dvh was a guess that only
           held on a tall viewport: measured at 390x480 it put the sheet's top
           edge at y=38 against a topbar running to y=40, and the modal (1055)
           outranks the topbar (1040), so a tall sheet on a short viewport ate
           the navigation. --portal-chrome-offset-top is the same token the
           content column clears the topbar with, so the sheet stops at the
           topbar plus the house 1rem gap — and it already folds in the
           status-bar inset and the impersonation banner's measured height
           (portal-components.css re-points it on body), which a percentage
           could never see. */
        max-height: calc(100dvh - var(--portal-chrome-offset-top));
        /* Only the home indicator. An earlier cut also reserved
           --portal-tab-bar-height here, on the premise that the phone tab bar
           is fixed chrome drawing OVER the sheet — it isn't: the bar is
           z-index 1035, under both the sheet (1055) and the backdrop (1050),
           so the sheet covers it (which is what iOS does with a presented
           sheet anyway) and the reservation was a 50px dead band along the
           bottom of every sheet. */
        padding-bottom: var(--portal-safe-bottom, env(safe-area-inset-bottom, 0px));
    }

    /* The grab handle. Decorative — the header's Close button is the real
       control, since a drag-to-dismiss gesture the browser doesn't implement
       would be a lie. */
    .portal-ios-sheet .portal-ios-sheet-grabber {
        display: block;
        width: 2.25rem;
        height: 0.3125rem;
        margin: 0.5rem auto 0;
        border-radius: var(--bs-border-radius-pill, 50rem);
        background-color: color-mix(in srgb, var(--portal-c-text-muted) 45%, transparent);
    }

    .portal-ios-sheet .modal-header {
        border-top-left-radius: var(--bs-border-radius-xxl);
        border-top-right-radius: var(--bs-border-radius-xxl);
    }

    /* Rise from the bottom edge instead of dropping in from above. */
    .portal-ios-sheet.fade .modal-dialog {
        transform: translateY(100%);
        transition: transform var(--portal-dur-3, 320ms) var(--portal-ease-emphasized, cubic-bezier(0.05, 0.7, 0.1, 1));
    }

    .portal-ios-sheet.show .modal-dialog {
        transform: none;
    }
}

/* ── Reduced motion ───────────────────────────────────────────────────── */

/* .portal-switch answers this in portal-kit.css, where it now lives. */
@media (prefers-reduced-motion: reduce) {
    a.portal-ios-row,
    button.portal-ios-row,
    .portal-ios-row--tappable,
    .portal-ios-sheet.fade .modal-dialog {
        transition: none;
    }
}

/* ── Degrade gates ────────────────────────────────────────────────────
   None here, deliberately. .portal-ios-list is painted with exactly the
   .card recipe (--portal-c-surface + --portal-c-surface-sheen + the edge
   bundle), and the four gates at the END of portal-components.css degrade it
   by re-pointing --portal-c-surface at the token level — no-backdrop-filter,
   prefers-reduced-transparency, and html[data-portal-glass="off"] all land
   for free. A local gate here would have covered fewer of them and drifted
   the moment a fifth was added. */
