/* ═══════════════════════════════════════════════════════════════════
   ip-premium.css — the finishing layer.

   base.html already owns the palette, the glass surfaces, the grain and
   the component library. This file adds the things a design system needs
   once those exist and stops being a collection of one-off values:

     1. a real fluid type ramp (size + tracking + leading, together)
     2. a named z-index scale, so no page has to guess 9999
     3. cross-document View Transitions, so navigation stops flashing
     4. tabular figures everywhere a number is compared to another number
     5. line-breaking and focus details that only show up on close reading

   Loads after ip-motion.css. Everything is either a new token, a new
   opt-in class, or a property no page was already setting — so no
   existing rule loses a specificity fight it used to win.
   ══════════════════════════════════════════════════════════════════ */

/* ═══════════════════════════════════════════
   1. TYPE RAMP

   Sizes are clamped against viewport width so a headline is large on a
   monitor and still readable at 375px without a media query per page.
   Each step carries its own tracking: optical sizing means large type
   needs negative tracking to stop looking loose, and small caps need
   positive tracking to stop looking cramped. Shipping them as separate
   ad-hoc values is what makes a type system drift.
   ═══════════════════════════════════════════ */
:root {
  --fs-micro:   clamp(0.6875rem, 0.672rem + 0.08vw, 0.75rem);
  --fs-xs:      clamp(0.75rem,   0.731rem + 0.10vw, 0.8125rem);
  --fs-sm:      clamp(0.8125rem, 0.789rem + 0.12vw, 0.875rem);
  --fs-base:    clamp(0.9375rem, 0.911rem + 0.14vw, 1rem);
  --fs-md:      clamp(1.0625rem, 1.016rem + 0.23vw, 1.1875rem);
  --fs-lg:      clamp(1.25rem,   1.164rem + 0.43vw, 1.5rem);
  --fs-xl:      clamp(1.5rem,    1.328rem + 0.86vw, 2rem);
  --fs-2xl:     clamp(1.875rem,  1.573rem + 1.51vw, 2.75rem);
  --fs-3xl:     clamp(2.25rem,   1.646rem + 3.02vw, 4rem);
  --fs-display: clamp(2.75rem,   1.802rem + 4.74vw, 5.5rem);

  /* Tracking pairs with the step above it. Negative above ~24px,
     neutral at reading sizes, positive only for uppercase labels. */
  --tr-display: -0.038em;
  --tr-3xl:     -0.034em;
  --tr-2xl:     -0.028em;
  --tr-xl:      -0.022em;
  --tr-lg:      -0.016em;
  --tr-md:      -0.011em;
  --tr-base:    -0.006em;
  --tr-label:    0.075em;

  /* Leading. Display type sets tighter than one em because the cap
     height, not the em box, is what the eye measures. */
  --lh-display: 0.94;
  --lh-tight:   1.06;
  --lh-snug:    1.24;
  --lh-body:    1.62;
  --lh-loose:   1.75;

  /* Reading measure. 65ch is the ceiling for body copy. */
  --measure:       65ch;
  --measure-tight: 46ch;
}

/* Opt-in ramp classes. Page templates can reach for these instead of
   inventing another rem value; nothing is applied to bare elements. */
.u-display { font-family: var(--font-display); font-size: var(--fs-display); letter-spacing: var(--tr-display); line-height: var(--lh-display); }
.u-h1      { font-family: var(--font-display); font-size: var(--fs-3xl);     letter-spacing: var(--tr-3xl);     line-height: var(--lh-tight); }
.u-h2      { font-family: var(--font-display); font-size: var(--fs-2xl);     letter-spacing: var(--tr-2xl);     line-height: var(--lh-tight); }
.u-h3      { font-family: var(--font-body);    font-size: var(--fs-xl);      letter-spacing: var(--tr-xl);      line-height: var(--lh-snug); font-weight: 600; }
.u-h4      { font-family: var(--font-body);    font-size: var(--fs-lg);      letter-spacing: var(--tr-lg);      line-height: var(--lh-snug); font-weight: 600; }
.u-lead    { font-size: var(--fs-md); letter-spacing: var(--tr-md); line-height: var(--lh-snug); color: var(--text-secondary); max-width: var(--measure); }
.u-body    { font-size: var(--fs-base); letter-spacing: var(--tr-base); line-height: var(--lh-body); max-width: var(--measure); }
.u-small   { font-size: var(--fs-sm); line-height: var(--lh-snug); color: var(--text-secondary); }

/* Eyebrow / label. Uppercase at small sizes only works with real
   tracking and a weight bump — otherwise it reads as noise. */
.u-label {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  color: var(--text-muted);
  line-height: 1.2;
}

.u-measure       { max-width: var(--measure); }
.u-measure-tight { max-width: var(--measure-tight); }

/* ═══════════════════════════════════════════
   2. LINE BREAKING

   `balance` on headings evens the last line so a two-word orphan does
   not hang under a six-word first line. `pretty` on body copy only
   fixes the last line, which is what it is for — using `balance` on
   long paragraphs is slow and re-wraps text that was already fine.

   Applied to bare elements because no page in this codebase sets
   text-wrap, so nothing is being overridden.
   ═══════════════════════════════════════════ */
h1, h2, h3, h4, h5, h6,
.u-display, .u-h1, .u-h2, .u-h3, .u-h4,
blockquote, figcaption, .u-lead {
  text-wrap: balance;
}
p, li, dd, .u-body {
  text-wrap: pretty;
}

/* Hyphenation only where a narrow column would otherwise open rivers. */
@media (max-width: 560px) {
  p, li { hyphens: auto; -webkit-hyphens: auto; }
}

/* ═══════════════════════════════════════════
   3. NUMERALS

   Proportional figures make a column of numbers wobble — a 1 is
   narrower than a 0, so totals never line up and a live-updating
   counter shifts its own layout on every tick. Every place this app
   shows a number that is compared to another number gets tabular
   figures and slashed zeros.
   ═══════════════════════════════════════════ */
.u-nums,
[data-numeric],
.stat-value, .stat-number, .stat-num,
.td-stat-val, .pd-stat-val, .pet-stat-num, .ipd-stat-value,
.nav-streak-count, .nav-pet-level,
.grade-value, .gpa-value, .score-value, .countdown-value,
table td, table th,
time, .date-badge {
  font-variant-numeric: tabular-nums slashed-zero;
  font-feature-settings: "tnum" 1, "zero" 1;
}

/* Big numbers read as data, not as prose: tighten them optically. */
.stat-value, .stat-number, .stat-num,
.td-stat-val, .pd-stat-val, .pet-stat-num, .ipd-stat-value {
  letter-spacing: -0.022em;
  font-variant-numeric: tabular-nums slashed-zero;
}

/* ═══════════════════════════════════════════
   4. Z-INDEX SCALE

   Named stops so a new component does not have to reverse-engineer the
   stack by trying 9999. The values match what base.html already uses
   (nav 200-ish, modal 1000, toast above it) so adopting a token is
   never a behaviour change.
   ═══════════════════════════════════════════ */
:root {
  --z-below:     -1;
  --z-base:       0;
  --z-raised:     1;
  --z-sticky:    50;
  --z-sidebar:  180;
  --z-nav:      200;
  --z-dropdown: 400;
  --z-backdrop: 900;
  --z-modal:   1000;
  --z-toast:   1100;
  --z-tooltip: 1200;
  --z-max:     9000;
}

/* ═══════════════════════════════════════════
   5. MOTION VOCABULARY (additions)

   base.html has --ease-out and --ease-in-out. These are the two curves
   it is missing: an emphasized curve that leaves fast and arrives slow
   without overshooting, and the flat-entry curve used for anything that
   should feel driven rather than dropped.
   ═══════════════════════════════════════════ */
:root {
  --ease-emph:  cubic-bezier(0.2, 0, 0, 1);
  --ease-swift: cubic-bezier(0.32, 0.72, 0, 1);
  --dur-micro: 120ms;
  --dur-quick: 180ms;
  --dur-base:  260ms;
  --dur-slow:  420ms;
}

/* ═══════════════════════════════════════════
   6. CROSS-DOCUMENT VIEW TRANSITIONS

   This is a multi-page Flask app, so every click is a full document
   load and the browser paints white between them. `navigation: auto`
   makes the browser hold the old frame and cross-fade into the new
   one, which is the single largest perceived-quality change available
   here for the number of lines it costs.

   The header, sidebar and footer are given stable names so they are
   treated as the *same* element across documents and hold still while
   the page body changes. Chrome 126+ honours this; every other browser
   ignores the whole block and navigates exactly as it does today.
   ═══════════════════════════════════════════ */
@view-transition { navigation: auto; }

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root) {
    animation: ip-vt-exit var(--dur-quick) var(--ease-swift) both;
  }
  ::view-transition-new(root) {
    animation: ip-vt-enter var(--dur-base) var(--ease-swift) both;
  }
}

@keyframes ip-vt-exit {
  to { opacity: 0; transform: translateY(-6px); }
}
@keyframes ip-vt-enter {
  from { opacity: 0; transform: translateY(10px); }
}

/* Persistent chrome. Named groups are matched across documents, so the
   nav does not fade out and back in on every navigation.

   Scoped to direct children of <body> on purpose: a view-transition-name
   has to be unique in the document, and several pages (command_center,
   my_stats, tool_gpa) use <header> for section headings. A bare `header`
   selector would name six elements at once, which makes the browser
   abort the entire transition — the exact bug base.html documents at the
   top of its own nested-<header> note. */
body > header      { view-transition-name: ip-header; }
body > aside.app-side { view-transition-name: ip-sidebar; }
body > footer      { view-transition-name: ip-footer; }

::view-transition-group(ip-header),
::view-transition-group(ip-sidebar),
::view-transition-group(ip-footer) {
  animation-duration: var(--dur-quick);
}
::view-transition-old(ip-header),
::view-transition-new(ip-header),
::view-transition-old(ip-sidebar),
::view-transition-new(ip-sidebar),
::view-transition-old(ip-footer),
::view-transition-new(ip-footer) {
  /* Hold steady: no cross-fade on chrome that exists on both pages. */
  animation: none;
  mix-blend-mode: normal;
}

/* The site's own reduce-motion switch has to win over the media query,
   because a user who set it in Settings did not set it in their OS. */
html.a11y-reduce-motion::view-transition-old(root),
html.a11y-reduce-motion::view-transition-new(root),
html[data-a11y-motion="reduce"]::view-transition-old(root),
html[data-a11y-motion="reduce"]::view-transition-new(root) {
  animation: none;
}

/* ═══════════════════════════════════════════
   7. FOCUS

   base.html draws a 2px accent outline. That reads well on the page
   background and disappears against a card of the same accent family.
   Adding a second ring in the surface colour guarantees separation on
   any background without changing the accent ring anyone already sees.

   Deliberately NOT applied to buttons, nav tabs or anything in the glass
   family: those carry a real elevation shadow, and box-shadow replaces
   rather than appends, so a halo there would delete the elevation for as
   long as the element held focus. Buttons keep the accent outline they
   already have.
   ═══════════════════════════════════════════ */
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible {
  box-shadow: 0 0 0 4px var(--bg), 0 0 0 6px color-mix(in srgb, var(--accent) 32%, transparent);
}
/* The bold-focus a11y mode already draws its own heavy ring — do not
   stack a second halo underneath it. */
html[data-a11y-focus="bold"] a:focus-visible,
html[data-a11y-focus="bold"] input:focus-visible,
html[data-a11y-focus="bold"] select:focus-visible,
html[data-a11y-focus="bold"] textarea:focus-visible,
html[data-a11y-focus="bold"] summary:focus-visible { box-shadow: none; }

/* ═══════════════════════════════════════════
   8. SELECTION, CARET, SCROLL ANCHORING
   ═══════════════════════════════════════════ */
::selection {
  background: color-mix(in srgb, var(--accent) 22%, transparent);
  color: var(--text-primary);
}
input, textarea, [contenteditable] { caret-color: var(--accent); }

/* Anchored scrolling clears the fixed header instead of parking the
   target underneath it. */
html { scroll-padding-top: calc(var(--nav-height, 62px) + 16px); }

/* Content that loads above the viewport (async lists, lazy images)
   stops shoving the page down under the reader. */
main, .page-wrap, [data-async-list] { overflow-anchor: auto; }

/* ═══════════════════════════════════════════
   9. SURFACE DETAIL

   A hairline that reads as a real edge: the top border catches light,
   the bottom sits in shadow. One property, and cards stop looking like
   flat rectangles with a stroke.
   ═══════════════════════════════════════════ */
.u-edge-light {
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 60%, transparent),
    inset 0 -1px 0 color-mix(in srgb, #000 6%, transparent),
    var(--shadow);
}
[data-theme="dark"] .u-edge-light {
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent),
    inset 0 -1px 0 color-mix(in srgb, #000 24%, transparent),
    var(--shadow);
}

/* Divider that fades at both ends rather than butting into the gutter. */
.u-rule {
  border: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--border-strong) 18%, var(--border-strong) 82%, transparent);
}

/* ═══════════════════════════════════════════
   10. SPOTLIGHT BORDER

   A card border that brightens under the cursor. The gradient is painted
   on a ::before that sits behind the content and is masked to the border
   box, so the fill, the shadow and the radius all keep working — nothing
   here touches the card's own background.

   Opt in with [data-spotlight]. Guarded on pointer:fine because on a
   touch screen there is no cursor to follow and the paint is wasted.
   ═══════════════════════════════════════════ */
@media (pointer: fine) {
  /* :where() so this carries zero specificity. Several of these cards are
     positioned by their own page CSS, and a 0,1,0 attribute selector
     landing later in the cascade would silently win that fight and move
     them. At zero specificity the card's own rule always wins, and
     position:relative only applies where nothing else set it. */
  :where([data-spotlight]) { position: relative; }

  /* ::before, not ::after: base.html already paints a dark-theme inset
     highlight on .stat-card::after at higher specificity, so a ring there
     would be dead on exactly the surfaces that need it most.

     No isolation and no negative z-index either — isolating the card would
     trap any dropdown inside it below the next card, which is a real bug
     on the dashboard. The ring is masked to the rim, where card content
     never reaches, so painting it above the content is harmless. */
  [data-spotlight]::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur-base) var(--ease-swift);
    background: radial-gradient(
      16rem 16rem at var(--sx, 50%) var(--sy, 50%),
      color-mix(in srgb, var(--accent) 60%, transparent) 0%,
      color-mix(in srgb, var(--accent) 16%, transparent) 40%,
      transparent 72%
    );
    /* Punch out the middle so only the 1px rim survives — the card's real
       surface shows through instead of being tinted. */
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
  }
  [data-spotlight]:hover::before,
  [data-spotlight]:focus-within::before { opacity: 1; }
}

/* ═══════════════════════════════════════════
   11. CONDENSED HEADER

   The header is sticky, so once the page scrolls it stops being part of
   the page and starts being chrome floating over it. It should look like
   chrome: a stronger edge and a real drop shadow so the content passing
   underneath reads as underneath.

   Only shadow, border-colour and a transform change — no height, no
   padding. Animating the height of a sticky bar reflows the document on
   every frame of the transition, which is the bug the previous motion
   pass removed from autoscaling inputs.
   ═══════════════════════════════════════════ */
.ip-scroll-sentinel {
  position: absolute;
  top: 0; left: 0;
  width: 1px; height: 1px;
  pointer-events: none;
  opacity: 0;
}

body > header {
  transition:
    box-shadow    var(--dur-base) var(--ease-swift),
    border-color  var(--dur-base) var(--ease-swift);
}
html.is-scrolled body > header {
  border-bottom-color: var(--border-strong);
  box-shadow:
    0 1px 0 var(--border-strong),
    0 10px 34px -14px rgba(0, 0, 0, 0.22);
}
html.is-scrolled .logo-text {
  transform: scale(0.94);
  transform-origin: left center;
}
.logo-text { transition: transform var(--dur-base) var(--ease-swift); }

/* ═══════════════════════════════════════════
   12. READING PROGRESS

   Scroll-driven where the browser supports it, which means the bar is
   painted by the compositor off the main thread and costs nothing while
   scrolling. ip-premium.js only steps in for browsers without a scroll
   timeline, and there it writes a single custom property per frame.

   Opt in per page with <body data-reading-progress>.
   ═══════════════════════════════════════════ */
.ip-progress {
  display: none;
  position: fixed;
  inset: 0 0 auto 0;
  height: 2px;
  z-index: var(--z-nav);
  transform-origin: 0 50%;
  transform: scaleX(var(--ip-progress, 0));
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--accent) 70%, transparent),
    var(--accent));
  pointer-events: none;
}
body[data-reading-progress] .ip-progress { display: block; }

@supports (animation-timeline: scroll()) {
  body[data-reading-progress] .ip-progress {
    animation: ip-progress-grow linear both;
    animation-timeline: scroll(root block);
  }
}
@keyframes ip-progress-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  .ip-progress { transition: none; }
}

/* ═══════════════════════════════════════════
   13. SHEEN

   One sweep of light across a primary action on hover. Opt-in, because
   applied to every button it becomes decoration; on the single page-level
   CTA it reads as the surface catching light.
   ═══════════════════════════════════════════ */
@media (pointer: fine) {
  .u-sheen {
    position: relative;
    overflow: hidden;
  }
  .u-sheen::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: linear-gradient(
      105deg,
      transparent 38%,
      color-mix(in srgb, #fff 26%, transparent) 50%,
      transparent 62%
    );
    transform: translateX(-120%);
    transition: transform 620ms var(--ease-swift);
  }
  .u-sheen:hover::after,
  .u-sheen:focus-visible::after { transform: translateX(120%); }
  @media (prefers-reduced-motion: reduce) {
    .u-sheen::after { display: none; }
  }
}

/* ═══════════════════════════════════════════
   14. SCREEN-READER-ONLY TEXT

   The site had a skip link but no general way to expose text to assistive
   tech alone — so a loading placeholder that is visually obvious (three
   shimmering rows) announced nothing at all. clip-path over the old
   clip:rect() because rect() is deprecated and drops the element from the
   a11y tree in some engines when combined with height:1px.
   ═══════════════════════════════════════════ */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ═══════════════════════════════════════════
   15. FORM VALIDITY

   :user-invalid, not :invalid. :invalid matches a required field that
   the reader has not typed in yet, so a signup form would paint itself
   red before anyone touched it. :user-invalid only matches after the
   field has been interacted with or the form submitted, which is the
   behaviour a hand-rolled "touched" flag is usually written to fake.

   No JS, and it degrades to the existing focus styling in browsers that
   do not support the selector.
   ═══════════════════════════════════════════ */
.form-input:user-invalid,
[aria-invalid="true"].form-input {
  border-color: var(--danger);
  background: var(--danger-soft);
}
/* The background is repeated here because base.html's .form-input:focus
   sets its own, and a focused invalid field would otherwise drop the red
   tint the moment the reader clicked into it to fix the problem. */
.form-input:user-invalid:focus,
[aria-invalid="true"].form-input:focus {
  border-color: var(--danger);
  background: var(--danger-soft);
  box-shadow: 0 0 0 3px var(--danger-soft), inset 0 1px 2px rgba(0, 0, 0, 0.04);
}

/* Inline message under a field. Colour alone never carries the error —
   contrast-blind readers get the same red border and no other signal —
   so this pairs the tint with words. */
.form-error-text {
  display: flex;
  align-items: flex-start;
  gap: 5px;
  margin-top: 6px;
  font-size: var(--fs-xs, 0.78rem);
  line-height: 1.45;
  color: var(--danger-text);
}

/* ═══════════════════════════════════════════
   16. PASSWORD REVEAL

   The button is injected by ip-premium.js so every password field on the
   site gets one without three templates having to agree on the markup.
   ═══════════════════════════════════════════ */
.pw-field { position: relative; display: block; }
.pw-field > .form-input { padding-right: 46px; }

.pw-toggle {
  position: absolute;
  top: 50%;
  right: 6px;
  transform: translateY(-50%);
  width: 34px; height: 34px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color      var(--dur-quick) var(--ease-swift),
    background var(--dur-quick) var(--ease-swift);
}
.pw-toggle:hover { color: var(--text-primary); background: var(--accent-soft); }
.pw-toggle:focus-visible { color: var(--accent); }
.pw-toggle svg { width: 18px; height: 18px; pointer-events: none; }

/* ═══════════════════════════════════════════
   17. LONG-FORM READING BASELINE

   Every blog template styles .blog-body, its h2, its lists and its
   strong — and not one of them styles h3, while all nine use h3 in the
   article. Those headings were rendering at the browser default: bold,
   1.17em, default margins, no relationship to the h2 above them or the
   paragraph below.

   This is a floor, not an override. Page <style> blocks come later in
   the document, so anything a template does set still wins; these rules
   only land where nothing was declared.
   ═══════════════════════════════════════════ */
.blog-body h3 {
  font-family: var(--font-body);
  font-size: var(--fs-md);
  font-weight: 600;
  letter-spacing: var(--tr-md);
  line-height: var(--lh-snug);
  color: var(--text-primary);
  /* Asymmetric on purpose: a heading belongs to what follows it, so the
     space above has to be clearly larger than the space below or the
     reader cannot tell which section it opens. */
  margin: 2rem 0 0.5rem;
}
.blog-body h4 {
  font-family: var(--font-body);
  font-size: var(--fs-base);
  font-weight: 600;
  letter-spacing: var(--tr-base);
  color: var(--text-primary);
  margin: 1.6rem 0 0.4rem;
}
.blog-body p { margin: 0 0 1.1rem; }

/* No leading gap at the top of the article, and no trailing gap that
   doubles up with the section padding underneath. */
.blog-body > :first-child { margin-top: 0; }
.blog-body > :last-child  { margin-bottom: 0; }

/* A heading followed immediately by its paragraph should not carry both
   the heading's bottom margin and the paragraph's top margin. */
.blog-body h2 + p,
.blog-body h3 + p,
.blog-body h4 + p { margin-top: 0; }

.blog-body li::marker { color: var(--text-muted); }

.blog-body code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  padding: 0.12em 0.36em;
  border-radius: var(--r-xs);
  background: var(--bg-elevated);
  color: var(--text-primary);
  /* Long identifiers should wrap rather than push the column wider. */
  overflow-wrap: anywhere;
}

.blog-body hr {
  border: 0;
  height: 1px;
  margin: 2.25rem 0;
  background: linear-gradient(90deg, transparent, var(--border-strong) 20%, var(--border-strong) 80%, transparent);
}

/* ═══════════════════════════════════════════
   18. CONFIRM DIALOG

   Styling for IP.confirm(). window.confirm() freezes the page behind an OS
   dialog carrying the domain name, no product styling, and buttons the page
   cannot label — so "OK" ends up meaning "delete my account forever".

   Cancel is the quiet one and Confirm carries the weight, because the reader
   arrives here already intending to act; the dialog exists to make them say
   it out loud, not to talk them out of it.
   ═══════════════════════════════════════════ */
.ip-confirm-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal, 1000);
  display: grid;
  place-items: center;
  padding: 1.25rem;
  background: var(--scrim);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity var(--dur-quick, 180ms) var(--ease-swift, ease);
}
.ip-confirm-overlay.is-in { opacity: 1; }

.ip-confirm {
  width: 100%;
  max-width: 25rem;
  padding: 1.5rem;
  border-radius: var(--ipd-radius-lg, 20px);
  background: var(--bg-card);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-xl);
  transform: translateY(8px) scale(0.985);
  transition:
    transform var(--dur-base, 260ms) var(--ease-swift, ease),
    opacity   var(--dur-base, 260ms) var(--ease-swift, ease);
}
.ip-confirm-overlay.is-in .ip-confirm { transform: none; }

.ip-confirm__title {
  font-family: var(--font-display);
  font-size: var(--fs-lg, 1.3rem);
  font-weight: 400;
  letter-spacing: var(--tr-lg, -0.016em);
  line-height: 1.2;
  color: var(--text-primary);
  margin: 0 0 0.4rem;
}
.ip-confirm__body {
  font-size: var(--fs-sm, 0.88rem);
  line-height: var(--lh-snug, 1.5);
  color: var(--text-secondary);
  margin: 0;
  /* Messages interpolate record titles, which can be a single long word. */
  overflow-wrap: anywhere;
  /* The engine writes these with \n in a couple of places. */
  white-space: pre-line;
}
.ip-confirm__actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 1.5rem;
}
.ip-confirm__btn {
  min-height: 38px;
  padding: 0 14px;
  border-radius: var(--r-sm);
  font-family: var(--font-body);
  font-size: var(--fs-sm, 0.85rem);
  font-weight: 600;
  cursor: pointer;
  border: 1px solid var(--glass-btn-border);
  background: var(--glass-btn-bg);
  color: var(--text-primary);
  transition:
    background   var(--dur-quick, 180ms) var(--ease-swift, ease),
    border-color var(--dur-quick, 180ms) var(--ease-swift, ease),
    transform    var(--dur-micro, 120ms) var(--ease-swift, ease);
}
.ip-confirm__btn:hover { background: var(--glass-btn-hover); }
.ip-confirm__btn:active { transform: scale(0.98); }

.ip-confirm__btn--go {
  background: var(--glass-primary-bg);
  color: var(--glass-primary-text);
  border-color: transparent;
}
.ip-confirm__btn--go:hover { background: var(--glass-primary-hover); }

.ip-confirm--danger .ip-confirm__btn--go {
  background: var(--danger);
  color: #fff;
  border-color: transparent;
}
.ip-confirm--danger .ip-confirm__btn--go:hover {
  background: color-mix(in srgb, var(--danger) 85%, #000);
}

@media (prefers-reduced-motion: reduce) {
  .ip-confirm-overlay, .ip-confirm { transition: none; }
}
html[data-a11y-motion="reduce"] .ip-confirm-overlay,
html[data-a11y-motion="reduce"] .ip-confirm { transition: none; }
