/* ============================================================================================
   Appearance → Themes: the scene layer.

   Everything the Themes feature draws lives in this file and in theme-engine.js. Nothing in
   styles.css was changed to make Themes work, and nothing here applies until theme-engine.js puts
   a `data-utheme` attribute on the document — so with Default Unify selected, or with this file
   or its script missing entirely, the application renders exactly as it always has.

   The whole feature is three things:

     1. A backdrop. One fixed, non-scrolling element behind the app holding one <img> of a
        photograph, and — on a device that can afford it — a couple of gradient-painted layers that
        drift very slowly across it. Everything that moves is a compositor-only animation of
        `transform` or `opacity` inside a `contain: strict` box, so a scrolling chat neither repaints
        it nor is slowed by it. See section 1b.

     2. Readability. Unify's surface colours are re-pointed at translucent versions of themselves,
        so cards, sheets and bubbles let the photograph tint through while text keeps its contrast.
        Only the colour tokens change; no layout, spacing, position or z-index is touched, and the
        header, composer and bottom navigation keep the exact geometry the fixed-UI rule requires.

     3. The Themes screen itself — the cards, the full preview, and its Cancel / Apply Theme bar.

   Removing Themes later means deleting this file, theme-engine.js, scene-manifest.js,
   assets/scenes/, and the handful of lines that reference them. Nothing else depends on any of it.
   ============================================================================================ */

/* ---- 1. The backdrop ---------------------------------------------------------------------- */

/* The scene sits behind the whole application. `z-index: -1` on a child of <body> paints it above
   the page canvas but below every piece of interface, which is what lets the app keep its normal
   stacking without a single z-index in styles.css being touched.

   `contain: strict` walls it off from the rest of the page: the browser knows nothing inside can
   affect layout or paint outside it, so a repaint of a chat never repaints the photograph and the
   photograph never invalidates the app's layout. */
.utheme-backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  contain: strict;
  background-color: var(--bg);
  pointer-events: none;
}

/* The photograph. `object-fit: cover` fills the screen at any size or orientation without
   distorting it, and the image is loaded and decoded off the main thread before it is ever
   attached, so this only ever fades in — it never appears mid-decode and never janks a scroll. */
.utheme-backdrop img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0;
  transition: opacity .55s var(--ease, ease);
}
.utheme-backdrop.is-ready img { opacity: 1; }

/* The instant placeholder: a ~30-byte-per-row version of the same photograph, inlined in
   scene-manifest.js as a data URI, blown up and blurred. It costs no network request at all, so a
   scene has its colour and shape on screen in the first frame while the real file is still being
   fetched. theme-engine.js removes this element once the photograph has faded in. */
.utheme-lqip {
  position: absolute;
  inset: -6%;
  background-size: cover;
  background-position: center;
  filter: blur(28px) saturate(1.06);
}

/* The scrim. It is deliberately gentle and mostly at the top and bottom edges — where the header
   and the bottom navigation sit — and nearly clear through the middle, so the photograph stays a
   photograph rather than a picture behind a sheet of grey. */
.utheme-backdrop .utheme-scrim {
  position: absolute;
  inset: 0;
  background: var(--utheme-scrim);
}

/* ---- 1a. Animated wallpaper ----------------------------------------------------------------- */

/* An uploaded theme is a video in exactly the same fixed, contained box the photograph uses, so
   everything the backdrop already guarantees about the interface holds unchanged: it is behind every
   piece of Unify, it cannot affect layout or paint outside itself, and it cannot move, resize or
   repaint one pixel of the header, the composer or the bottom navigation.

   `object-fit: cover` is the whole of the vertical-wallpaper requirement. The video fills the screen
   in both directions at any size or orientation, keeps its aspect ratio, and is CROPPED rather than
   stretched or letterboxed — which is why there is never a black bar on any phone or tablet, and why
   the Theme Manager asks for upright masters with the important part of the scene near the middle:
   the centre is the part every screen shape keeps. */
.utheme-backdrop video,
.utheme-stage video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0;
  transition: opacity .5s var(--ease, ease);
  /* A wallpaper is not something to tap, scrub, or pick up with a gesture. There are no controls on
     the element, and this makes sure a touch meant for the app never lands on it. */
  pointer-events: none;
}
.utheme-backdrop.is-ready video,
.utheme-stage.is-ready video { opacity: 1; }

/* The still frame. It is on screen from the first moment, underneath the video for the whole
   session, and it is the entire wallpaper on a device that will not autoplay one — an iPhone in Low
   Power Mode, a managed Android device, a browser where the member turned autoplay off. It is drawn
   `cover` for the same reason the video is, so the two are the same crop and the swap between them
   is invisible. */
.utheme-still {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

/* A library card. The still frame is an ordinary lazy-loaded image; the small preview video, when
   this is the one card playing, sits on top of it in the same crop. */
.utheme-card-art.is-video { position: relative; overflow: hidden; }
.utheme-card-art.is-video img,
.utheme-card-art.is-video video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}
.utheme-card-art.is-video video { opacity: 0; transition: opacity .35s var(--ease, ease); }
.utheme-card-art.is-video.is-ready video { opacity: 1; }

/* ---- 1b. Motion ---------------------------------------------------------------------------- */

/* The scene moves, and it is meant to be seen moving. The requirement this is written against is a
   visual one, not a technical one: open the Galaxy preview and within a few seconds you should be
   able to say "yes, that is moving" without staring at it and without looking at any code.

   An earlier version of this section aimed for the opposite — movement below the threshold of
   noticing. Measured in a browser, the photograph travelled five hundredths of a pixel in five
   seconds and a thousandth of one percent of the frame changed. That is not a slow animation, it is
   a still picture, and it is what this section exists to correct.

   WHAT MOVES, AND HOW FAST
   ========================
   Four things, at four clearly different speeds, which is what makes it read as depth rather than as
   a picture being dragged:

     · The photograph pushes in and out between 1.14× and 1.30× while panning a continuous ellipse.
       A full circuit takes 48 seconds at a constant rate — `linear`, and an ellipse rather than a
       there-and-back, so it never slows to a stop at the turn and there is no moment at which
       opening the preview shows a stationary picture.
     · Three star layers slide across it on their own ellipses: the far layer at roughly one pixel a
       second, the middle at about four, the near one at about ten. That eight-fold spread between
       nearest and furthest is the parallax — the near stars visibly overtake the far ones.
     · Each of those layers brightens and dims on its own period — 17s, 11s and 7.5s — so different
       stars are at different points of their swing at any moment and the sky twinkles rather than
       pulsing as one sheet.
     · Three meteors cross on 13, 17 and 23 second cycles. Between them one crosses every five or six
       seconds on average, so a member who opens the preview reliably catches one.

   The amplitudes are given as percentages of the layer, so the same rules produce the same *visible*
   movement on a phone as on a desktop; the two breakpoints below only widen the pan on a narrow
   screen, where a percentage of a 390 px box would otherwise be a handful of pixels.

   SHARPNESS IS NEVER SPENT ON MOVEMENT
   ====================================
   The push-in magnifies the photograph, so the peak of the scale below — 1.30 — is also
   `MAX_DRIFT_SCALE` in theme-engine.js, and that is what the file a device downloads is sized for. A
   moving scene is therefore handed a picture with at least as many pixels as it is drawn with at its
   most magnified. Nothing in this file blurs, downsamples, or swaps the photograph, at any motion
   level, on any device, for any reason.

   WHY IT IS FREE
   ==============
   Every rule below animates `transform` and `opacity` and nothing else. Those two are the only
   properties a browser can animate entirely on the compositor: each layer is rasterised once and
   then re-used, so a moving scene costs no layout, no paint, and no main-thread work per frame. That
   is what keeps it clear of the things the application is doing — a chat scrolls, a keyboard opens,
   and a message arrives at exactly the speed it does with Default Unify, because none of them share
   a thread with this. There is no JavaScript animation loop anywhere in the feature: these are CSS
   animations, declared once and left to the compositor.

   All of it lives inside `.utheme-backdrop`, which is `position: fixed` and `contain: strict`. It
   therefore cannot move, resize, or repaint one pixel of the interface. The header, the composer and
   the bottom navigation are not named here, are not animated here, and keep exactly the geometry the
   permanent fixed-UI rule requires.

   WHEN IT DOES NOT RUN
   ====================
   `prefers-reduced-motion` is honoured below, and theme-engine.js sets `data-utheme-motion` on the
   root element to say how much of this a device should be doing. "off" is the whole thing gone — a
   device that says it is short of memory or cores, one asking for reduced data, one on a low battery,
   or one that still cannot hold a smooth frame rate after being stepped down. "reduced" is that step
   down: the near star layer and the meteors go, and the photograph's circuit is stretched from 48
   seconds to 78 — slower, but still plainly moving, because a scene that has quietly stopped is the
   bug this section was written to fix. Every one of those states is still the same photograph at the
   same resolution — quality is never what gets spent to buy frames. */

/* The photograph moves in two ways at once, on two elements, on two periods that share no factor.
   That split is the fix for the reported bug, so it is worth being precise about what the bug was.

   Both movements used to live in one `@keyframes` on one element: a pan around an ellipse and a
   push-in, locked to a single 42-second circuit. Measured on the deployed site, that produced this —

     0s → 5s   the picture grew 74 px and slid 42 px      (visible)
     0s → 10s  the picture grew  2 px and slid  1 px      (identical)

   — because a shared period means the two halves of the loop retrace each other. Ten seconds is
   almost exactly the interval at which the push-in returns to where it started, so two screenshots
   taken ten seconds apart were the same picture. "I cannot visibly see the Galaxy moving" is the
   correct description of that, and speeding the loop up would only have moved the dead interval.

   So:

     <picture>   breathes  — scale 1.20 → 1.32 → 1.20, over 23s
     <img>       pans      — a constant-speed circle of radius `--utheme-pan-r`, over 30s

   Two independent CSS animations on two nested elements, composing into one transform because one
   element is inside the other. 23 and 30 share no factor, so the pair does not repeat for eleven and
   a half minutes.

   The pan is what guarantees the requirement. A circle traversed at constant angular speed has no
   turning point and no dwell: the displacement between any instant t and t+Δ is `2r·sin(π·Δ/T)`,
   which depends on Δ alone and not at all on when you started looking. At r = 8vmin and T = 30s that
   is exactly one radius over five seconds — about 72 px on a laptop, about 31 px on a phone — every
   five seconds, forever. Two screenshots five seconds apart cannot come back the same.

   Nested elements rather than the individual `scale:`/`translate:` properties on one element, which
   would be tidier: those need Chrome 104 / Safari 14.1, and on anything older they do not degrade to
   a slower animation, they degrade to no animation — which is the exact failure being fixed.

   Both are `transform` only, so both run on the compositor, and neither costs the main thread
   anything per frame. */
@keyframes utheme-scene-breathe {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1.20); }
  50%      { transform: translate3d(0, 0, 0) scale(1.32); }
}

/* A circle, sampled every eighth of a turn. `vmin` rather than a percentage on purpose: a percentage
   translate resolves against the element's own width for x and its height for y, so on a phone the
   same number would be a large horizontal move and a tiny vertical one — an ellipse so flat that
   half the circuit reads as barely moving. `vmin` is the same number of real pixels on both axes, so
   the path is a true circle and the speed is genuinely constant.

   0.7071 is cos 45°. Straight-line interpolation between eight points sits within 2% of the circle,
   which is far below anything visible and keeps the speed constant to the same tolerance. */
@keyframes utheme-scene-pan {
  0%    { transform: translate3d(var(--utheme-pan-r), 0, 0); }
  12.5% { transform: translate3d(calc(var(--utheme-pan-r) * .7071), calc(var(--utheme-pan-r) * .7071), 0); }
  25%   { transform: translate3d(0, var(--utheme-pan-r), 0); }
  37.5% { transform: translate3d(calc(var(--utheme-pan-r) * -.7071), calc(var(--utheme-pan-r) * .7071), 0); }
  50%   { transform: translate3d(calc(var(--utheme-pan-r) * -1), 0, 0); }
  62.5% { transform: translate3d(calc(var(--utheme-pan-r) * -.7071), calc(var(--utheme-pan-r) * -.7071), 0); }
  75%   { transform: translate3d(0, calc(var(--utheme-pan-r) * -1), 0); }
  87.5% { transform: translate3d(calc(var(--utheme-pan-r) * .7071), calc(var(--utheme-pan-r) * -.7071), 0); }
  100%  { transform: translate3d(var(--utheme-pan-r), 0, 0); }
}

/* The three star layers, each on its own ellipse, each going round the other way from the one behind
   it. The distances and periods are what separate them: measured in a browser at 1440 px wide, the
   far layer covers about three pixels a second, the middle about eight, and the near one about
   fifteen. Watching any two at once, the near stars visibly pull ahead of the far ones — that
   difference is the parallax, and it is the whole reason there are three layers rather than one.
   The near layer is deliberately kept slower than the ten-a-second-and-rising it can easily reach:
   past that it stops reading as depth behind the photograph and starts reading as specks travelling
   across the front of it. */
@keyframes utheme-stars-far {
  0%   { transform: translate3d(-3.5%, 0%, 0); }
  25%  { transform: translate3d(0%, 2.2%, 0); }
  50%  { transform: translate3d(3.5%, 0%, 0); }
  75%  { transform: translate3d(0%, -2.2%, 0); }
  100% { transform: translate3d(-3.5%, 0%, 0); }
}
@keyframes utheme-stars-mid {
  0%   { transform: translate3d(6.5%, 0%, 0); }
  25%  { transform: translate3d(0%, -4.2%, 0); }
  50%  { transform: translate3d(-6.5%, 0%, 0); }
  75%  { transform: translate3d(0%, 4.2%, 0); }
  100% { transform: translate3d(6.5%, 0%, 0); }
}
@keyframes utheme-stars-near {
  0%   { transform: translate3d(-10%, 0%, 0); }
  25%  { transform: translate3d(0%, -6.5%, 0); }
  50%  { transform: translate3d(10%, 0%, 0); }
  75%  { transform: translate3d(0%, 6.5%, 0); }
  100% { transform: translate3d(-10%, 0%, 0); }
}

/* Brightness. Stars are meant to be seen brightening and dimming, so each layer swings between
   roughly half and full over seconds rather than by a tenth over minutes. What keeps it from reading
   as one sheet blinking is that the three layers are on three periods — 17s, 11s and 7.5s, no two of
   which share a factor — and each starts at a different point in its own swing, so at any instant
   some stars are climbing while others are fading. `opacity` is a compositor property, so all of it
   costs the main thread nothing. */
@keyframes utheme-stars-glow-far {
  0%, 100% { opacity: .58; }
  50%      { opacity: 1; }
}
@keyframes utheme-stars-glow-mid {
  0%, 100% { opacity: 1; }
  50%      { opacity: .5; }
}
@keyframes utheme-stars-glow-near {
  0%, 100% { opacity: .45; }
  55%      { opacity: 1; }
}

/* A shooting star. The streak crosses in about a second and a half and is invisible for the rest of
   its cycle, which is what makes it an event rather than a loop. Three of them on different cycles
   put a streak on screen roughly every seven seconds on average, so opening the preview catches one
   almost immediately without the sky ever looking busy — measured across a forty-second window, a
   meteor is lit about a sixth of the time. The distance is given in `vw` on both axes so the streak
   always travels along the line it is drawn at, whatever the shape of the screen. */
@keyframes utheme-meteor {
  0%     { opacity: 0; transform: translate3d(0, 0, 0) rotate(var(--utheme-meteor-angle)) scaleX(.12); }
  0.8%   { opacity: 1; }
  4.5%   { opacity: .9; }
  6.5%   { opacity: 0; transform: translate3d(var(--utheme-meteor-dx), var(--utheme-meteor-dy), 0) rotate(var(--utheme-meteor-angle)) scaleX(1); }
  100%   { opacity: 0; transform: translate3d(var(--utheme-meteor-dx), var(--utheme-meteor-dy), 0) rotate(var(--utheme-meteor-angle)) scaleX(1); }
}

/* The two moving elements.

   <picture> is normally an inline box that lays out around its <img>; here it is the positioned
   frame, and the <img> fills it. That is what gives the two animations somewhere separate to live.

   The negative delays are not decoration. They start each animation part-way round rather than at
   the top of its loop, so the very first frame a member sees is already mid-movement — the scale is
   changing at its fastest and the pan is a quarter turn in — instead of the scene appearing to sit
   still for a moment and then set off. They are also different from each other, which is what stops
   the two circuits from lining up at the start of every session.

   The pan radius has to stay inside the margin the scale leaves off-screen. At the bottom of the
   breath, scale 1.20, the picture overhangs the viewport by 10% of its own size on every side; the
   binding case is the narrow dimension of a phone, where 10% of 390 px is 39 px against a radius of
   8vmin = 31 px. There is room on every screen, and a frame edge can never come into view. Raising
   `--utheme-pan-r` past 10vmin, or lowering the 1.20 in `utheme-scene-breathe`, breaks that and a
   test checks the pair. `MAX_DRIFT_SCALE` in theme-engine.js is the 1.32 at the top of the breath;
   changing one without the other hands a magnified picture fewer pixels than it is drawn with. */
.utheme-backdrop picture,
.utheme-stage picture {
  position: absolute;
  inset: 0;
  display: block;
  transform-origin: center center;
  animation: utheme-scene-breathe 23s ease-in-out infinite;
  animation-delay: -5.75s;
}
.utheme-backdrop img,
.utheme-stage img {
  --utheme-pan-r: 8vmin;
  animation: utheme-scene-pan 30s linear infinite;
  animation-delay: -7.5s;
}

/* The three drifting star layers. They are painted from gradients — no images, no requests, nothing
   to decode — and they exist to give the photograph's own stars a sense of depth. Each layer's inset
   is larger than the distance it travels, so sliding it never uncovers an edge. */
.utheme-motion {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.utheme-stars {
  position: absolute;
  background-repeat: repeat;
}
.utheme-stars.far {
  inset: -8%;
  background-image:
    radial-gradient(1.2px 1.2px at 14% 9%, rgba(255, 255, 255, .58), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 63% 6%, rgba(206, 226, 255, .48), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 31% 27%, rgba(255, 255, 255, .42), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.3px 1.3px at 84% 22%, rgba(255, 246, 232, .52), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 47% 41%, rgba(255, 255, 255, .38), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 9% 58%, rgba(214, 232, 255, .46), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.2px 1.2px at 72% 63%, rgba(255, 255, 255, .44), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 38% 79%, rgba(255, 240, 224, .40), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.1px 1.1px at 89% 88%, rgba(255, 255, 255, .38), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.2px 1.2px at 21% 94%, rgba(226, 238, 255, .46), rgba(255, 255, 255, 0) 100%),
    /* Cosmic dust: two very large, very faint glows. They sit well below the nebulosity already in
       the photograph — they add a little depth to the empty sky as they pass, and they are nowhere
       near strong enough to read as a coloured shape of their own. */
    radial-gradient(60% 52% at 24% 32%, rgba(96, 141, 224, .07), rgba(0, 0, 0, 0) 70%),
    radial-gradient(52% 46% at 78% 71%, rgba(196, 138, 96, .06), rgba(0, 0, 0, 0) 70%);
  background-size:
    300px 300px, 300px 300px, 300px 300px, 300px 300px, 300px 300px,
    300px 300px, 300px 300px, 300px 300px, 300px 300px, 300px 300px,
    100% 100%, 100% 100%;
  background-repeat:
    repeat, repeat, repeat, repeat, repeat,
    repeat, repeat, repeat, repeat, repeat,
    no-repeat, no-repeat;
  animation:
    utheme-stars-far 84s linear infinite,
    utheme-stars-glow-far 17s ease-in-out infinite;
}
.utheme-stars.mid {
  inset: -14%;
  background-image:
    radial-gradient(1.4px 1.4px at 19% 21%, rgba(255, 255, 255, .62), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.3px 1.3px at 55% 12%, rgba(214, 232, 255, .54), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.4px 1.4px at 78% 44%, rgba(255, 248, 236, .58), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.3px 1.3px at 34% 58%, rgba(255, 255, 255, .50), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.4px 1.4px at 8% 76%, rgba(226, 238, 255, .56), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.3px 1.3px at 66% 87%, rgba(255, 255, 255, .48), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.4px 1.4px at 93% 68%, rgba(232, 242, 255, .52), rgba(255, 255, 255, 0) 100%);
  background-size: 400px 400px;
  animation:
    utheme-stars-mid 62s linear infinite,
    utheme-stars-glow-mid 11s ease-in-out infinite;
}
.utheme-stars.near {
  inset: -20%;
  background-image:
    radial-gradient(1.9px 1.9px at 26% 17%, rgba(255, 255, 255, .82), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.7px 1.7px at 71% 38%, rgba(222, 236, 255, .72), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.7px 1.7px at 12% 62%, rgba(255, 244, 230, .70), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.9px 1.9px at 58% 81%, rgba(255, 255, 255, .68), rgba(255, 255, 255, 0) 100%),
    radial-gradient(1.7px 1.7px at 91% 55%, rgba(232, 242, 255, .66), rgba(255, 255, 255, 0) 100%);
  background-size: 520px 520px;
  animation:
    utheme-stars-near 58s linear infinite,
    utheme-stars-glow-near 7.3s ease-in-out infinite;
}

/* Three of them, on different lines, at different speeds. The three periods share no common factor,
   so the pattern of arrivals never settles into a repeat a member could anticipate, and the negative
   delays put the first streak across the sky about a second after the scene appears while keeping
   any two from arriving together. The glow is a static box-shadow: it is painted once with the layer
   and never animated. */
.utheme-meteor {
  position: absolute;
  width: 230px;
  height: 2px;
  border-radius: 2px;
  opacity: 0;
  transform-origin: 0 50%;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .55) 60%, #ffffff);
  box-shadow: 0 0 7px rgba(190, 220, 255, .65);
  animation: utheme-meteor 19s linear infinite;
}
.utheme-meteor.a {
  left: 6%;
  top: 10%;
  --utheme-meteor-angle: 24deg;
  --utheme-meteor-dx: 62vw;
  --utheme-meteor-dy: 27vw;
  animation-delay: -18.2s;
}
.utheme-meteor.b {
  right: 5%;
  top: 6%;
  --utheme-meteor-angle: 156deg;
  --utheme-meteor-dx: -58vw;
  --utheme-meteor-dy: 30vw;
  animation-duration: 26s;
  animation-delay: -19.5s;
}
.utheme-meteor.c {
  left: 12%;
  top: 34%;
  --utheme-meteor-angle: 33deg;
  --utheme-meteor-dx: 48vw;
  --utheme-meteor-dy: 31vw;
  animation-duration: 33s;
  animation-delay: -19s;
}

/* A backgrounded tab keeps nothing running: theme-engine.js sets this class the moment the page is
   hidden, so a scene in a tab nobody is looking at costs exactly nothing. */
html.utheme-motion-paused .utheme-backdrop picture,
html.utheme-motion-paused .utheme-stage picture,
html.utheme-motion-paused .utheme-backdrop img,
html.utheme-motion-paused .utheme-stage img,
html.utheme-motion-paused .utheme-stars,
html.utheme-motion-paused .utheme-meteor { animation-play-state: paused; }

/* Stepped down. Either the device told us it is short of memory, cores or data, or it was measured
   twice failing to hold a frame rate with everything running. The two layers that cost the most for
   the least are gone — the near star field, which travels furthest, and the meteors — and both of the
   photograph's own circuits are stretched by about half.

   Stretched, not stopped, and that distinction is the whole point of this tier. At 46 seconds the pan
   still carries the picture two thirds of a radius every five seconds — roughly 48 px on a laptop —
   which is plainly moving to anyone looking at it. A scene that has quietly become a still picture is
   the exact failure this section exists to prevent, and it is what a member on a modest phone used to
   get with no indication that anything had been taken away. theme-engine.js removes those layers from
   the document as well, so this is belt and braces. */
html[data-utheme-motion="reduced"] .utheme-stars.near,
html[data-utheme-motion="reduced"] .utheme-meteor { display: none; }
html[data-utheme-motion="reduced"] .utheme-backdrop img,
html[data-utheme-motion="reduced"] .utheme-stage img { animation-duration: 46s; }
html[data-utheme-motion="reduced"] .utheme-backdrop picture,
html[data-utheme-motion="reduced"] .utheme-stage picture { animation-duration: 35s; }

/* Off. The member has asked their device for less motion. Nothing else reaches this state: a guess
   about the hardware steps down to "reduced" above, never to a still picture. The scene is then
   exactly what it was before the feature moved — one photograph, costing the compositor a single
   static layer and nothing per frame. */
html[data-utheme-motion="off"] .utheme-motion { display: none; }
html[data-utheme-motion="off"] .utheme-backdrop picture,
html[data-utheme-motion="off"] .utheme-stage picture,
html[data-utheme-motion="off"] .utheme-backdrop img,
html[data-utheme-motion="off"] .utheme-stage img { animation: none; }
@media (prefers-reduced-motion: reduce) {
  .utheme-motion { display: none; }
  .utheme-backdrop picture,
  .utheme-stage picture,
  .utheme-backdrop img,
  .utheme-stage img { animation: none; }
}

/* With a scene on, <body> stops painting its own background so the backdrop shows through. The
   colour moves to the root element, which paints below everything — including the backdrop — so
   there is never a bare white flash before the photograph arrives, and an overscrolled edge shows
   Unify's own background rather than the page canvas. */
html[data-utheme] { background: var(--bg); }
html[data-utheme] body {
  background-color: transparent;
  background-image: none;
}

/* ---- 2. Readability ----------------------------------------------------------------------- */

/* Only colour tokens. Every one of these is a variable styles.css already reads, so re-pointing it
   changes what the existing rules paint without a single existing rule being edited or overridden.

   These are written against `[data-utheme]` rather than `html[data-utheme]` on purpose: the Themes
   preview puts the same attribute on a single element so the mock interface inside it is coloured
   for the scene being previewed, whatever the app outside it is currently set to.

   `--bg` is deliberately NOT re-pointed. The chat header and the composer paint it to mask the
   messages scrolling underneath them; making it translucent would let text show through the very
   bars the fixed-UI rule exists to keep solid and stationary. */
[data-utheme] {
  --surface: rgba(255, 255, 255, .84);
  --surface-2: rgba(244, 247, 252, .82);
  --surface-inset: rgba(237, 242, 251, .86);
  --line: rgba(15, 27, 61, .10);
  --line-strong: rgba(15, 27, 61, .17);
  --bubble-theirs: rgba(255, 255, 255, .90);
  --bubble-theirs-ink: #12203c;
  /* Secondary text is the one thing that regularly sits directly on the photograph rather than on a
     surface — section labels, page subtitles, helper lines. Against a flat background `--muted` has
     plenty of contrast; against a picture it does not, so it is darkened here. `--ink` is already
     near-black and is left alone. */
  --muted: #465271;
  /* The opaque colour the bottom navigation keeps — see the rule below. This is Unify's ordinary
     `--surface`, held here so re-pointing that token cannot reach the one bar that must stay solid. */
  --utheme-bar: #ffffff;
  /* Floating panels keep that same opaque colour, for the same reason. The @mention results, the "+"
     menu and the emoji / GIPHY pickers are drawn OVER the message history: through a translucent
     surface the conversation behind them shows through the list a member is trying to read a name
     out of, and the scene shows through it too. styles.css already paints these from
     `--overlay-surface`, so re-pointing it here is the whole of the fix. */
  --overlay-surface: var(--utheme-bar);
  /* A photograph is busier than a flat background, so cards need a slightly deeper shadow to keep
     reading as raised surfaces rather than as patches of haze. */
  --shadow-sm: 0 1px 2px rgba(10, 20, 45, .10), 0 6px 20px rgba(10, 20, 45, .10);
  --shadow-md: 0 12px 30px rgba(10, 20, 45, .18);
  /* The scrim, aligned to the viewport rather than to the picture: the backdrop is fixed, so the
     photograph always occupies the same band of the screen and the veil can be tuned to it exactly.
     It is weakest through the middle — where the interface is mostly cards and bubbles that provide
     their own contrast, and where the photograph earns its place — and firmest at the two edges,
     where a heading or a line of helper text can sit directly on the picture. It is a haze, not a
     sheet: the scene is still plainly the scene underneath it. */
  --utheme-scrim: linear-gradient(
    180deg,
    rgba(255, 255, 255, .58) 0%,
    rgba(255, 255, 255, .46) 18%,
    rgba(255, 255, 255, .42) 52%,
    rgba(255, 255, 255, .52) 80%,
    rgba(255, 255, 255, .66) 100%);
}

/* The bottom navigation is the only fixed bar that paints `--surface` instead of `--bg`, so
   re-pointing that token would have made it translucent and let messages smear through it as they
   scrolled past. It is handed its opaque colour back here.

   This sets a background colour and nothing else. No transform, transition, animation, position,
   z-index, or size — the bar keeps the exact geometry the permanent fixed-UI rule requires, and it
   still never moves for a scroll, a keyboard, a rotation, or a tab change. */
html[data-utheme] .nav { background: var(--utheme-bar); }

[data-theme="dark"] [data-utheme],
html[data-theme="dark"][data-utheme] {
  --surface: rgba(12, 20, 42, .80);
  --surface-2: rgba(17, 27, 54, .80);
  --surface-inset: rgba(22, 33, 64, .84);
  --line: rgba(200, 218, 255, .13);
  --line-strong: rgba(200, 218, 255, .21);
  --bubble-theirs: rgba(22, 34, 66, .88);
  --bubble-theirs-ink: #eef3ff;
  --muted: #a9b7d8;
  --utheme-bar: #0f1830;
  --overlay-surface: var(--utheme-bar);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px rgba(0, 0, 0, .38);
  --shadow-md: 0 14px 34px rgba(0, 0, 0, .55);
  --utheme-scrim: linear-gradient(
    180deg,
    rgba(4, 8, 18, .70) 0%,
    rgba(4, 8, 18, .42) 20%,
    rgba(4, 8, 18, .34) 52%,
    rgba(4, 8, 18, .48) 80%,
    rgba(4, 8, 18, .74) 100%);
}

/* Realistic Galaxy is a night scene, and it is coloured as one in both appearance settings.

   This is a deliberate exception, and the reason is the photograph itself. The generic light-mode
   treatment above lifts a scene with a white veil so that navy text sitting directly on it keeps its
   contrast. On a daylight photograph that veil reads as haze. On a deep-space exposure it reads as
   grey: the black sky between the galaxies — the thing that makes the picture look like a real
   telescope image rather than an illustration — turns into a flat wash, and the veil still is not
   enough for a line of secondary text over an unlit patch of sky.

   So this scene keeps the photograph as photographed — deep blacks, natural colour, a gentle dark
   scrim — and hands the interface the night palette to match, whichever appearance setting the
   account is on. It is still only colour tokens, all of them ones styles.css already reads: no rule
   in styles.css is edited or overridden, no layout, spacing, position or z-index is touched, and the
   header, composer and bottom navigation keep exactly the geometry the fixed-UI rule requires.

   `--bg` is re-pointed here, unlike in the generic blocks, and it stays fully opaque. It has to move:
   the chat header and the composer paint it, and light text on the standard near-white `--bg` would
   be unreadable. Opaque is the part that matters — those bars still mask the messages scrolling
   underneath them.

   The three selectors are one rule, not three palettes: the first applies in light mode, and the
   other two carry enough specificity to beat the generic dark block above in dark mode. */
[data-utheme="galaxy"],
[data-theme="dark"] [data-utheme="galaxy"],
html[data-theme="dark"][data-utheme="galaxy"] {
  --bg: #070c1a;
  --bg-grad-1: rgba(21, 101, 255, .20);
  --bg-grad-2: rgba(21, 101, 255, .12);
  --surface: rgba(12, 20, 42, .80);
  --surface-2: rgba(17, 27, 54, .80);
  --surface-inset: rgba(22, 33, 64, .84);
  --ink: #eef3ff;
  --ink-soft: #c2ccea;
  --muted: #a9b7d8;
  --line: rgba(200, 218, 255, .13);
  --line-strong: rgba(200, 218, 255, .21);
  --soft: #14254d;
  --soft-ink: #8fb6ff;
  --orange-soft: #3a2a14;
  --danger: #ff859a;
  --danger-soft: #3a1620;
  --success: #44d39c;
  --success-soft: #0d2b22;
  --bubble-mine-ink: #ffffff;
  --bubble-theirs: rgba(22, 34, 66, .88);
  --bubble-theirs-ink: #eef3ff;
  --utheme-bar: #0f1830;
  --overlay-surface: var(--utheme-bar);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px rgba(0, 0, 0, .38);
  --shadow-md: 0 14px 34px rgba(0, 0, 0, .55);
  --shadow-lg: 0 22px 55px rgba(0, 0, 0, .6);
  /* Gentle, and dark rather than light: it settles the sky under the top and bottom edges without
     touching the blacks in the middle of the frame, where the galaxies are. */
  --utheme-scrim: linear-gradient(
    180deg,
    rgba(4, 8, 18, .70) 0%,
    rgba(4, 8, 18, .42) 20%,
    rgba(4, 8, 18, .34) 52%,
    rgba(4, 8, 18, .48) 80%,
    rgba(4, 8, 18, .74) 100%);
}

/* ---- 2b. Uploaded themes: two palettes, no per-theme CSS ------------------------------------ */

/* An uploaded theme cannot bring a stylesheet with it — the Owner uploads a video, not code — so
   readability over it has to be decided from something the Owner can actually set. That something is
   the theme's TONE: "night" for a dark wallpaper, "daylight" for a bright one, chosen in the Theme
   Manager and carried on the root element as `data-utheme-tone`. Every uploaded theme, including
   every one added years from now, gets its readability from one of the two blocks below and needs no
   new rule of its own.

   Both blocks are only colour tokens — every one of them a variable styles.css already reads — so
   nothing in styles.css is edited or overridden, and no layout, spacing, position, or z-index is
   touched anywhere. The header, the composer and the bottom navigation keep exactly the geometry the
   permanent fixed-UI rule requires, and `--utheme-bar` keeps the bottom navigation opaque so
   messages never smear through it.

   These follow the generic blocks above deliberately: they carry the same specificity, so being
   later is what lets a theme's own tone win over the default treatment. */

/* NIGHT. For dark wallpapers — a night sky, a city after dark, rain on glass. The interface wears
   its dark palette whichever appearance setting the account is on, for the same reason Realistic
   Galaxy does: a white veil over a dark scene reads as grey haze and still is not enough for a line
   of secondary text over an unlit patch of it. `--bg` moves and stays fully opaque, because the chat
   header and the composer paint it and must keep masking the messages scrolling underneath them. */
[data-utheme-tone="night"],
[data-theme="dark"] [data-utheme-tone="night"],
html[data-theme="dark"][data-utheme-tone="night"] {
  --bg: #070c1a;
  --bg-grad-1: rgba(21, 101, 255, .20);
  --bg-grad-2: rgba(21, 101, 255, .12);
  --surface: rgba(12, 20, 42, .80);
  --surface-2: rgba(17, 27, 54, .80);
  --surface-inset: rgba(22, 33, 64, .84);
  --ink: #eef3ff;
  --ink-soft: #c2ccea;
  --muted: #a9b7d8;
  --line: rgba(200, 218, 255, .13);
  --line-strong: rgba(200, 218, 255, .21);
  --soft: #14254d;
  --soft-ink: #8fb6ff;
  --orange-soft: #3a2a14;
  --danger: #ff859a;
  --danger-soft: #3a1620;
  --success: #44d39c;
  --success-soft: #0d2b22;
  --bubble-mine-ink: #ffffff;
  --bubble-theirs: rgba(22, 34, 66, .88);
  --bubble-theirs-ink: #eef3ff;
  --utheme-bar: #0f1830;
  --overlay-surface: var(--utheme-bar);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px rgba(0, 0, 0, .38);
  --shadow-md: 0 14px 34px rgba(0, 0, 0, .55);
  --shadow-lg: 0 22px 55px rgba(0, 0, 0, .6);
  /* Dark rather than light, and firmest at the two edges: it settles the wallpaper under the header
     and the bottom navigation without flattening the middle of the frame, which is the part of a
     moving scene worth seeing. */
  --utheme-scrim: linear-gradient(
    180deg,
    rgba(4, 8, 18, .72) 0%,
    rgba(4, 8, 18, .44) 20%,
    rgba(4, 8, 18, .34) 52%,
    rgba(4, 8, 18, .50) 80%,
    rgba(4, 8, 18, .76) 100%);
}

/* DAYLIGHT. For bright wallpapers — a forest at noon, a beach, a bright sky. The interface keeps
   Unify's ordinary light palette; what changes is that the surfaces are held a little more opaque
   and the veil a little firmer than the generic treatment, because a bright MOVING wallpaper carries
   more contrast into the text sitting over it than a still photograph does.

   Nothing is stated here for dark mode on purpose: a member whose account is set to dark keeps the
   dark surfaces from the generic block above, which is more specific than this one and therefore
   still wins there. */
[data-utheme-tone="daylight"] {
  --surface: rgba(255, 255, 255, .88);
  --surface-2: rgba(244, 247, 252, .86);
  --surface-inset: rgba(237, 242, 251, .90);
  --bubble-theirs: rgba(255, 255, 255, .93);
  --muted: #3f4a68;
  --utheme-bar: #ffffff;
  --overlay-surface: var(--utheme-bar);
  --utheme-scrim: linear-gradient(
    180deg,
    rgba(255, 255, 255, .64) 0%,
    rgba(255, 255, 255, .50) 18%,
    rgba(255, 255, 255, .44) 52%,
    rgba(255, 255, 255, .56) 80%,
    rgba(255, 255, 255, .70) 100%);
}

/* Default Unify, stated rather than assumed. The preview uses this to show the plain appearance
   inside the same mock interface, and it is what guarantees that "Default Unify" really is the app
   exactly as it is today rather than a scene with no picture. */
[data-utheme="default"] {
  --bg: #f5f7fb;
  --bg-grad-1: rgba(21, 101, 255, .07);
  --bg-grad-2: rgba(21, 101, 255, .05);
  --surface: #ffffff;
  --surface-2: #f5f7fb;
  --surface-inset: #eef2fa;
  --line: #e6ebf5;
  --line-strong: #d3dbeb;
  --bubble-theirs: #eef2fa;
  --bubble-theirs-ink: #14223f;
  /* Stated here as well as the surfaces, because a night scene re-points the text colours too: a
     Default Unify card or preview sitting inside a galaxy-themed page has to bring its own ink back
     rather than inherit light text onto a white card. */
  --ink: #0f1b3d;
  --ink-soft: #33405e;
  --muted: #6b7793;
  --utheme-bar: #ffffff;
  --overlay-surface: var(--utheme-bar);
  --shadow-sm: 0 1px 2px rgba(15, 27, 61, .06), 0 2px 8px rgba(15, 27, 61, .05);
  --shadow-md: 0 10px 26px rgba(15, 50, 120, .12);
  --utheme-scrim: none;
}
[data-theme="dark"] [data-utheme="default"],
html[data-theme="dark"][data-utheme="default"] {
  --bg: #070c1a;
  --bg-grad-1: rgba(21, 101, 255, .20);
  --bg-grad-2: rgba(21, 101, 255, .12);
  --surface: #0f1830;
  --surface-2: #131e3a;
  --surface-inset: #182343;
  --line: #1e2a4b;
  --line-strong: #2a3964;
  --bubble-theirs: #1a2748;
  --bubble-theirs-ink: #eef3ff;
  --ink: #eef3ff;
  --ink-soft: #c2ccea;
  --muted: #8d99bd;
  --utheme-bar: #0f1830;
  --overlay-surface: var(--utheme-bar);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-md: 0 12px 28px rgba(0, 0, 0, .5);
}

/* ---- 3. The Settings row ------------------------------------------------------------------ */

/* The one piece of Themes that appears inside the existing Settings page: a normal Settings row
   with a live crop of the current scene in place of an icon. */
.utheme-row-swatch {
  width: 42px;
  height: 42px;
  border-radius: 11px;
  flex: 0 0 auto;
  overflow: hidden;
  border: 1px solid var(--line-strong);
  background: linear-gradient(135deg, #1565ff, #4f8bff 55%, #f59324);
  background-size: cover;
  background-position: center;
}

/* ---- 4. The Themes screen ----------------------------------------------------------------- */

.utheme-screen {
  position: fixed;
  inset: 0;
  z-index: 2200;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  background: var(--bg);
  background-image:
    radial-gradient(1100px 560px at 0% -10%, var(--bg-grad-1), transparent 60%),
    radial-gradient(900px 560px at 110% 0%, var(--bg-grad-2), transparent 55%);
  color: var(--ink);
  padding: calc(14px + env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
           calc(32px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
.utheme-screen-inner { max-width: var(--maxw, 640px); margin: 0 auto; }

.utheme-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.utheme-head h2 { font-family: var(--font-display); font-size: 1.35rem; margin: 0; }
.utheme-back {
  width: 42px; height: 42px; flex: 0 0 auto;
  display: grid; place-items: center;
  border: 1px solid var(--line); border-radius: 14px;
  background: var(--surface); color: var(--ink);
  cursor: pointer;
}
.utheme-back svg { width: 20px; height: 20px; }
.utheme-intro { color: var(--muted); font-size: .88rem; margin: 0 0 18px; }

.utheme-grid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 520px) { .utheme-grid { grid-template-columns: 1fr 1fr; } }

/* A theme card. The picture inside it is a crop of the very photograph the scene applies — the
   card never shows a stand-in image for something else. */
.utheme-card {
  position: relative;
  display: block;
  width: 100%;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg, 20px);
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
  cursor: pointer;
  text-align: left;
  color: inherit;
  transition: transform .18s var(--ease, ease), box-shadow .18s var(--ease, ease), border-color .18s var(--ease, ease);
}
.utheme-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.utheme-card:active { transform: translateY(0); }
.utheme-card:focus-visible { outline: 3px solid var(--primary, #1565ff); outline-offset: 2px; }

.utheme-card-art {
  display: block;
  position: relative;
  aspect-ratio: 4 / 3;
  background: var(--surface-2);
  overflow: hidden;
}
.utheme-card-art img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* The Default Unify card has no photograph, because Default Unify has no photograph. It shows the
   real Unify background and a real card sitting on it, which is precisely what selecting it gives. */
.utheme-card-art.is-plain {
  background: var(--bg);
  background-image:
    radial-gradient(340px 200px at 0% -10%, var(--bg-grad-1), transparent 60%),
    radial-gradient(300px 200px at 110% 0%, var(--bg-grad-2), transparent 55%);
  display: grid;
  place-items: center;
}
.utheme-card-art.is-plain span {
  width: 62%; height: 46%;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--surface);
  box-shadow: var(--shadow-sm);
}

.utheme-card-body { padding: 12px 14px 14px; display: flex; align-items: flex-start; gap: 10px; }
.utheme-card-body .grow { flex: 1; min-width: 0; }
.utheme-card-name { display: block; font-weight: 700; font-size: 1rem; }
.utheme-card-note { display: block; color: var(--muted); font-size: .8rem; margin-top: 2px; }

.utheme-badge {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; gap: 5px;
  padding: 4px 9px; border-radius: 999px;
  background: var(--soft); color: var(--soft-ink);
  font-size: .7rem; font-weight: 800; letter-spacing: .02em;
}
.utheme-card.is-active { border-color: var(--primary, #1565ff); }

/* ---- 5. The full preview ------------------------------------------------------------------ */

.utheme-preview {
  position: fixed;
  inset: 0;
  z-index: 2300;
  display: flex;
  flex-direction: column;
  background: #05070f;
}

/* The stage is where the scene and the mock interface are composited together. It carries its own
   `data-utheme`, so the mock is coloured by the scene being previewed and not by whatever the app
   is currently showing — a member on Default Unify sees exactly what applying the scene would do. */
.utheme-stage {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  background: var(--bg);
}
/* The preview stage carries the same nested pair as the backdrop — <picture> as the positioned frame
   that breathes, <img> inside it panning — so the preview a member is shown is the identical scene
   they get after Apply, at the identical speeds. */
.utheme-stage picture {
  position: absolute; inset: 0; display: block;
}
.utheme-stage img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity .4s var(--ease, ease);
}
.utheme-stage.is-ready img { opacity: 1; }
.utheme-stage > .utheme-scrim { position: absolute; inset: 0; background: var(--utheme-scrim); }

.utheme-stage-loading {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  padding: 7px 14px; border-radius: 999px;
  background: rgba(8, 12, 24, .55); color: #fff;
  font-size: .78rem; font-weight: 700;
}

/* The mock interface. It is a faithful still of Unify — the chat header, a few messages, the
   composer and the bottom navigation — built from the same colour tokens the real screens use, so
   what the preview shows about contrast and readability is true of the real app. It is a picture:
   nothing in it is interactive, and it is marked aria-hidden so a screen reader never reads a fake
   conversation as a real one. */
.utheme-mock {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  color: var(--ink);
  font-size: 13px;
  pointer-events: none;
}
.utheme-mock-head {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
.utheme-mock-avatar {
  width: 32px; height: 32px; border-radius: 50%;
  background: linear-gradient(135deg, #1565ff, #4f8bff);
  color: #fff; display: grid; place-items: center;
  font-size: 12px; font-weight: 800; flex: 0 0 auto;
}
.utheme-mock-title { font-weight: 700; font-size: 14px; }
.utheme-mock-sub { color: var(--muted); font-size: 11px; }

.utheme-mock-thread {
  flex: 1 1 auto;
  min-height: 0;
  display: flex; flex-direction: column; justify-content: flex-end;
  gap: 8px; padding: 14px 14px 10px;
}
.utheme-bubble {
  max-width: 78%;
  padding: 8px 12px;
  border-radius: 16px;
  line-height: 1.35;
  box-shadow: var(--shadow-sm);
}
.utheme-bubble.theirs {
  align-self: flex-start;
  background: var(--bubble-theirs);
  color: var(--bubble-theirs-ink);
  border-bottom-left-radius: 6px;
}
.utheme-bubble.mine {
  align-self: flex-end;
  background: var(--accent-grad, linear-gradient(135deg, #1565ff, #4f8bff));
  color: #fff;
  border-bottom-right-radius: 6px;
}
.utheme-bubble b { display: block; font-size: 11px; opacity: .72; margin-bottom: 1px; }

.utheme-mock-card {
  align-self: stretch;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 10px 12px;
  box-shadow: var(--shadow-sm);
}
.utheme-mock-card b { display: block; font-size: 12px; }
.utheme-mock-card span { color: var(--muted); font-size: 11px; }

.utheme-mock-composer {
  display: flex; align-items: center; gap: 8px;
  padding: 10px 14px;
  background: linear-gradient(to top, var(--bg) 72%, transparent);
}
.utheme-mock-input {
  flex: 1;
  padding: 9px 13px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--muted);
}
.utheme-mock-send {
  width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto;
  background: var(--accent-grad, linear-gradient(135deg, #1565ff, #4f8bff));
  color: #fff; display: grid; place-items: center;
}
.utheme-mock-send svg { width: 16px; height: 16px; }

.utheme-mock-nav {
  display: flex;
  background: var(--utheme-bar);
  border-top: 1px solid var(--line);
  padding: 6px 4px 8px;
}
.utheme-mock-nav span {
  flex: 1; text-align: center;
  color: var(--muted); font-size: 10px; font-weight: 700;
}
.utheme-mock-nav span.on { color: var(--primary, #1565ff); }
.utheme-mock-nav i {
  display: block; width: 17px; height: 17px; margin: 0 auto 3px;
  border-radius: 5px; background: currentColor; opacity: .55;
}
.utheme-mock-nav span.on i { opacity: 1; }

/* Cancel / Apply Theme. Nothing above this bar is saved: the preview writes no preference, sends
   no request and changes nothing about the running app until Apply Theme is pressed. The bar itself
   is deliberately opaque — it belongs to the Themes screen, not to the picture being previewed. */
.utheme-bar {
  flex: 0 0 auto;
  display: flex; gap: 10px;
  padding: 12px max(16px, env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  background: var(--bg);
  border-top: 1px solid var(--line-strong);
}
.utheme-bar-info {
  margin: 0;
  padding: 12px max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
  background: var(--bg);
  color: var(--muted);
  font-size: .76rem;
}
.utheme-btn {
  flex: 1;
  padding: 13px 16px;
  border-radius: 14px;
  border: 1px solid transparent;
  font-weight: 700;
  font-size: .95rem;
  cursor: pointer;
}
.utheme-btn.ghost { background: transparent; border-color: var(--line-strong); color: var(--ink); }
.utheme-btn.primary { background: var(--accent-grad, linear-gradient(135deg, #1565ff, #4f8bff)); color: #fff; }
.utheme-btn[disabled] { opacity: .6; cursor: default; }

.utheme-credit {
  padding: 14px 2px 0;
  color: var(--muted);
  font-size: .72rem;
}
