/* --z-dropdown / --z-modal -- the one shared reference point for every
   dropdown/popover/overlay site-wide, replacing what used to be eight
   different hand-picked numbers (30/50/60/200/300/400/9999/99999)
   across separate files for conceptually the same handful of roles.
   Wide gap between the two tiers (500 vs 1000) so a full-page modal or
   lightbox always renders above any dropdown/popover left open behind
   it, with no risk of collision. header's own z-index:100 is
   deliberately left as its own untouched, already-consistent value --
   nothing here needs to compete with it. */
:root{--void:#05070d;--panel:#0c1322;--panel-2:#101a2e;--line:#1b2a44;--line-soft:#121c30;--ion:#4c8dff;--ion-rgb:76,141,255;--ion-dim:#2d5faf;--star:#8fd3ff;--ember:#c9e6ff;--text:#edf2fa;--text-dim:#7c8aa6;--text-dimmer:#4d5a75;--display:'Space Grotesk',sans-serif;--body:'Inter',sans-serif;--mono:'Space Mono',monospace;--z-dropdown:500;--z-modal:1000;}
*{margin:0;padding:0;box-sizing:border-box;}
html{scroll-behavior:smooth;scrollbar-width:thin;scrollbar-color:var(--line) var(--void);}
::-webkit-scrollbar{width:10px;}
::-webkit-scrollbar-track{background:var(--void);}
::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px;border:2px solid var(--void);}
::-webkit-scrollbar-thumb:hover{background:var(--ion-dim);}
body{background:var(--void);color:var(--text);font-family:var(--body);line-height:1.6;overflow-x:hidden;position:relative;min-height:100vh;display:flex;flex-direction:column;}
a{color:inherit;text-decoration:none;}
::selection{background:var(--ion);color:var(--void);}
body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(var(--line-soft) 1px,transparent 1px),linear-gradient(90deg,var(--line-soft) 1px,transparent 1px);background-size:64px 64px;opacity:.28;pointer-events:none;z-index:0;mask-image:radial-gradient(ellipse 80% 60% at 50% 0%,black 40%,transparent 90%);}
.grain{position:fixed;inset:0;z-index:999;pointer-events:none;opacity:.045;mix-blend-mode:overlay;background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");}
#stars{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.6;}
.wrap{max-width:1120px;margin:0 auto;padding:0 24px;position:relative;z-index:1;}
header{position:fixed;top:0;left:0;right:0;z-index:100;background:rgba(5,7,13,.72);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line);}
nav{max-width:1120px;margin:0 auto;padding:18px 24px;display:flex;align-items:center;justify-content:space-between;}
.brand{display:flex;align-items:center;gap:10px;font-family:var(--display);font-weight:600;font-size:14.5px;letter-spacing:.05em;text-shadow:0 0 15px rgba(143,211,255,.5);white-space:nowrap;}
.brand .mark{width:20px;height:20px;flex-shrink:0;}
.brand .mark svg{width:100%;height:100%;display:block;}
.nav-links{display:flex;gap:36px;font-size:14px;color:var(--text-dim);margin-left:28px;}
.nav-links a{transition:color .25s ease;}
.nav-links a:hover{color:var(--text);}
.nav-links a.active-link{color:var(--star);}
.nav-cta{display:flex;align-items:center;gap:20px;margin-left:20px;}

/* ===================================================================
   MY SIGNAL entry point.

   Recovered verbatim from the approved placement demo (git c98ffec,
   revision 4) rather than rebuilt -- every number here was measured on
   a real device and signed off, so re-deriving them would only risk
   landing somewhere slightly different for no gain. The demo file
   itself was deleted after approval; git history is the source.

   Currently rendered by nothing: header-full.php gates the markup
   behind AXIOM_SIGNAL_ICON_LIVE, which is false until My Signal is
   proven. This block is deliberately dormant, not dead.

   Luminescent gold, warmer and more saturated than the amber the Admin
   link already uses (#f0a868) so the two never read as the same thing.
   Paired with an -rgb companion exactly the way --ion / --ion-rgb
   already works in this file. */
:root{--sig-gold:#ffc65c;--sig-gold-bright:#ffdd94;--sig-gold-rgb:255,198,92;}

/* Every other control in the utility cluster is a 20px icon in quiet
   grey. This one is deliberately bigger and gold, because it is not a
   utility control -- it is a destination, and the only one in the bar. */
.sig-nav-icon{display:flex;align-items:center;justify-content:center;position:relative;color:var(--sig-gold);padding:8px 4px;transition:color .25s ease;}
.sig-nav-icon:hover{color:var(--sig-gold-bright);}
.sig-nav-icon svg{width:24px;height:24px;display:block;animation:sigBeat 6s linear infinite;}

/* pulse, pulse, glooow, rest -- on a 6s loop. The two quick pulses are
   short, tight brightness bumps; the swell is the opposite shape, a
   wider warmer halo that rises slowly, lingers, and fades over more
   than a second. The two events differ in KIND, not just amount, which
   is what stops it reading as a generic three-beat blink.
     0.00-0.18s pulse 1 rise   0.18-0.48s fall
     0.48-0.66s pulse 2 rise   0.66-0.96s fall
     0.96-2.28s slow swell     2.28-3.12s linger
     3.12-4.32s slow fade      4.32-6.00s rest
   Roughly a second of heartbeat, three and a half of breath, two of
   silence -- alive, but with gaps long enough that it never nags while
   someone is actually reading the page.

   Per-keyframe timing functions give the pulses their snap and the
   swell its ease; one global easing would flatten both into the same
   gesture, which is why the animation itself is declared linear.

   drop-shadow, never box-shadow, so the light follows the icon's drawn
   strokes instead of haloing a rectangle. */
@keyframes sigBeat{
  0%{filter:drop-shadow(0 0 4px rgba(var(--sig-gold-rgb),.35)) drop-shadow(0 0 9px rgba(var(--sig-gold-rgb),.18)) brightness(1);animation-timing-function:ease-out;}
  3%{filter:drop-shadow(0 0 7px rgba(var(--sig-gold-rgb),.72)) drop-shadow(0 0 13px rgba(var(--sig-gold-rgb),.32)) brightness(1.28);animation-timing-function:ease-in;}
  8%{filter:drop-shadow(0 0 4px rgba(var(--sig-gold-rgb),.35)) drop-shadow(0 0 9px rgba(var(--sig-gold-rgb),.18)) brightness(1);animation-timing-function:ease-out;}
  11%{filter:drop-shadow(0 0 7px rgba(var(--sig-gold-rgb),.72)) drop-shadow(0 0 13px rgba(var(--sig-gold-rgb),.32)) brightness(1.28);animation-timing-function:ease-in;}
  16%{filter:drop-shadow(0 0 4px rgba(var(--sig-gold-rgb),.35)) drop-shadow(0 0 9px rgba(var(--sig-gold-rgb),.18)) brightness(1);animation-timing-function:ease-in-out;}
  38%{filter:drop-shadow(0 0 12px rgba(var(--sig-gold-rgb),.82)) drop-shadow(0 0 26px rgba(var(--sig-gold-rgb),.50)) brightness(1.16);animation-timing-function:ease-in-out;}
  52%{filter:drop-shadow(0 0 12px rgba(var(--sig-gold-rgb),.78)) drop-shadow(0 0 26px rgba(var(--sig-gold-rgb),.47)) brightness(1.15);animation-timing-function:ease-in-out;}
  72%{filter:drop-shadow(0 0 4px rgba(var(--sig-gold-rgb),.35)) drop-shadow(0 0 9px rgba(var(--sig-gold-rgb),.18)) brightness(1);}
  100%{filter:drop-shadow(0 0 4px rgba(var(--sig-gold-rgb),.35)) drop-shadow(0 0 9px rgba(var(--sig-gold-rgb),.18)) brightness(1);}
}

/* Explicit, not relying on this file's global reduced-motion rule --
   that one only clamps animation-duration/iteration-count, which would
   freeze this mid-gesture at whatever the 0% frame happens to be.
   States the intended fallback outright: steady gold, no motion. */
@media(prefers-reduced-motion:reduce){
  .sig-nav-icon svg{animation:none;filter:drop-shadow(0 0 5px rgba(var(--sig-gold-rgb),.55)) drop-shadow(0 0 11px rgba(var(--sig-gold-rgb),.30));}
}

/* Mobile: bigger icon, and real breathing room around it. Two separate
   problems, not one. The icon grows to 28px because a thumb destination
   should look like a destination. The cluster gap goes to 22px because
   at the original 10px a thumb could genuinely land between the icon
   and the avatar and hit the wrong one -- a risky tap, not just a tight
   one.

   The gap is derived, not picked. Measured live at 386px: the icon box
   is 40px wide carrying a 52px hit area (6px overhang each side), the
   avatar is 34px carrying 44px (5px each side). So the hit areas sit
   22 - 6 - 5 = 11px apart, and avatar-to-hamburger 22 - 5 - 3 = 14px.
   Real daylight between targets rather than a hairline. (18px was tried
   first and measured only 7px of clearance, which is why this is 22.)

   52px rather than 44px on purpose: 44 is the accessibility floor for
   any control, and this is the primary one.

   The gap rule is scoped with :has() so it applies ONLY when the icon
   is actually present. Applying it unconditionally would move the
   avatar and hamburger apart today, while the icon is still gated off
   -- a live visual change for a feature nobody can see yet. */
@media(max-width:1400px){
  header .nav-cta:has(.sig-nav-icon){gap:22px;}
  .sig-nav-icon{padding:8px 6px;}
  .sig-nav-icon svg{width:28px;height:28px;}
  .sig-nav-icon::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:52px;height:52px;}
}
/* ================================================================= */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 20px;border-radius:6px;font-family:var(--body);font-weight:600;font-size:13.5px;cursor:pointer;border:1px solid transparent;transition:all .25s ease;white-space:nowrap;}
.btn-primary{background:linear-gradient(135deg,var(--ion),#6ea6ff);color:#03060d;}
.btn-primary:hover{box-shadow:0 6px 24px rgba(76,141,255,.35);transform:translateY(-1px);}
.btn-ghost{border-color:var(--line);color:var(--text);background:rgba(255,255,255,.02);}
.btn-ghost:hover{border-color:var(--ion);background:rgba(76,141,255,.08);}
.btn-lg{padding:13px 26px;font-size:14.5px;}
.btn-block{width:100%;}
/* Destructive and cautionary variants. Both were page-local duplicates
   until 2026-07-28: .btn-danger existed twice, byte-identical, in
   account-2fa.php and admin-user-account.php; .btn-warn once, in
   admin-user-account.php. Semantic variants of a shared component belong
   in the shared component, so they live here now.

   ORDER IS LOAD-BEARING -- these must stay AFTER .btn. Both these and .btn
   are specificity (0,1,0), and .btn sets border:1px solid transparent, so
   if they were moved above it .btn would win on source order and every one
   of these buttons would silently lose its coloured border. Confirmed live
   before the move rather than reasoned from source: an element carrying
   .btn-danger with no matching rule computes borderTopColor rgba(0,0,0,0). */
.btn-danger{background:rgba(138,45,59,.12);border:1px solid #8a2d3b;color:#ff9baa;}
.btn-danger:hover{background:rgba(138,45,59,.24);}
.btn-warn{background:rgba(255,179,95,.12);border:1px solid #8a5f2d;color:#ffb35f;}
.btn-warn:hover{background:rgba(255,179,95,.22);}
/* Opt-in frosted-glass modifier. Was a backdrop-filter buried inside
   index.php's private re-declaration of .btn-ghost, so the effect existed
   on exactly one page and no other page could ask for it. Promoted to a
   named variant on 2026-07-28: any page can now apply it, and nothing has
   to redefine a button to get it. Carries the -webkit- prefix the
   original lacked, so it works in Safari too. */
/* NATIVE SCROLL INDICATOR -- hidden on mobile only.
   Reported from a real device: a persistent thin scrollbar down the right
   edge of My Signal that never appeared in any screenshot, because a
   screenshot captures the page and not the browser's own scroll overlay.

   Verified before hiding it that this is the PAGE's scrollbar and not a
   symptom of something else: the only scrolling element on My Signal is
   HTML itself -- no nested overflow container anywhere on the page has a
   scrollHeight exceeding its clientHeight. Hiding a scrollbar that belonged
   to an accidentally-scrolling inner div would have buried a layout bug
   instead of fixing a cosmetic one.

   1023px, the site's own established mobile breakpoint, so this tracks the
   same boundary every other mobile rule uses rather than inventing a
   second one. Desktop keeps its scrollbar; nothing above 1024px is touched.

   Scrolling itself is untouched -- these properties hide the indicator, they
   do not disable overflow. */
@media (max-width: 1023px) {
  html, body { scrollbar-width: none; -ms-overflow-style: none; }
  html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; }
}

/* FILE INPUTS. There was no rule for these anywhere on the site and NINE
   pages have one, so every single upload control on the platform was
   rendering the browser's OS-default grey "Choose File / No file chosen"
   against a page of dark bordered fields. Confirmed on a real phone before
   touching it. This is the shared component that should always have
   existed, not a per-page patch -- one rule fixes all nine.

   display is deliberately NEVER set here. feed.php and includes/
   feed-post-card.php both hide their input with the `hidden` attribute and
   drive it from a custom button; the UA's [hidden] rule is (0,1,0) and this
   selector is (0,0,1), so hidden keeps winning -- but only for as long as
   nobody adds a display to this block.

   Both the standard ::file-selector-button and the legacy WebKit
   ::-webkit-file-upload-button are written out as separate rules rather
   than one selector list. A selector list containing one pseudo-element a
   browser does not know is invalid as a whole and drops the entire rule, so
   combining them would mean older WebKit gets nothing instead of the
   fallback it is there for. */
input[type="file"]{
  width:100%;min-height:44px;padding:8px 12px;
  background:rgba(5,7,13,.5);border:1px solid var(--line);border-radius:8px;
  color:var(--text-dim);font-family:var(--body);font-size:.85rem;cursor:pointer;
}
input[type="file"]:hover:not(:disabled){border-color:var(--ion-dim);}
input[type="file"]:focus-visible{outline:none;border-color:var(--ion);box-shadow:0 0 0 3px rgba(76,141,255,.15);}
/* community-edit.php renders the banner input disabled for a non-premium
   community. Without this it looked identical to a working field, which is
   a control that lies about whether it can be used. */
input[type="file"]:disabled{opacity:.5;cursor:not-allowed;}
input[type="file"]::file-selector-button{
  margin-right:12px;padding:8px 16px;border:1px solid var(--line);border-radius:6px;
  background:rgba(255,255,255,.02);color:var(--text);font-family:var(--body);
  font-weight:600;font-size:.8rem;cursor:pointer;transition:all .25s ease;
}
input[type="file"]::file-selector-button:hover{border-color:var(--ion);background:rgba(76,141,255,.08);}
input[type="file"]::-webkit-file-upload-button{
  margin-right:12px;padding:8px 16px;border:1px solid var(--line);border-radius:6px;
  background:rgba(255,255,255,.02);color:var(--text);font-family:var(--body);
  font-weight:600;font-size:.8rem;cursor:pointer;transition:all .25s ease;
}
input[type="file"]::-webkit-file-upload-button:hover{border-color:var(--ion);background:rgba(76,141,255,.08);}

.btn-frosted{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);}
.nav-link-login{font-size:14px;color:var(--text-dim);padding:8px 4px;transition:color .25s ease;}
.nav-link-login:hover{color:var(--star);}
.btn-join{display:inline-flex;align-items:center;justify-content:center;padding:9px 20px;border-radius:999px;background:linear-gradient(135deg,var(--ion),var(--star));color:#03060d;font-family:var(--body);font-weight:700;font-size:13.5px;white-space:nowrap;transition:all .25s ease;animation:join-pulse 3s ease-in-out infinite;}
.btn-join:hover{box-shadow:0 6px 24px rgba(76,141,255,.5);transform:translateY(-1px) scale(1.03);animation-play-state:paused;}
@keyframes join-pulse{0%,100%{box-shadow:0 0 10px rgba(76,141,255,.3);}50%{box-shadow:0 0 20px rgba(76,141,255,.55);}}
.user-menu{position:relative;z-index:var(--z-dropdown);display:flex;align-items:center;}
.user-avatar-btn{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--ion),#6ea6ff);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;}
.user-avatar-btn:hover{box-shadow:0 0 0 3px rgba(76,141,255,.3);transform:scale(1.05);}
.user-avatar-icon{width:19px;height:19px;color:#03060d;}
.user-dropdown{position:absolute;top:calc(100% + 10px);right:0;min-width:160px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:8px;box-shadow:0 16px 40px rgba(0,0,0,.55);opacity:0;visibility:hidden;transform:translateY(-6px);transition:all .2s ease;z-index:var(--z-dropdown);}
.user-dropdown.open{opacity:1;visibility:visible;transform:translateY(0);}

/* Persistent sitewide game-switcher -- one real component
   (axiom_game_switcher_html() in feed-helpers.php), rendered twice
   (header nav-cta, footer "Playing" column) via the same markup, not
   two separate implementations. Works logged out; a footer instance
   sits inside .footer-col so its link-reset rules don't apply here
   (this isn't a plain footer link, it's an interactive control). */
.game-switcher{position:relative;}
.game-switcher-trigger{display:flex;align-items:center;gap:6px;background:none;border:1px solid var(--line);color:var(--text-dim);padding:7px 10px;border-radius:6px;font-family:var(--body);font-size:.82rem;cursor:pointer;white-space:nowrap;}
.game-switcher-trigger:hover{border-color:var(--ion-dim);color:var(--text);}
.game-switcher-panel{position:absolute;top:calc(100% + 6px);left:0;width:220px;z-index:var(--z-dropdown);background:var(--panel-2);border:1px solid var(--line);border-radius:8px;box-shadow:0 16px 40px rgba(0,0,0,.55);padding:8px;}
.nav-cta .game-switcher-panel{left:auto;right:0;}
.game-switcher-search{width:100%;padding:8px 10px;margin-bottom:6px;background:rgba(5,7,13,.6);border:1px solid var(--line);color:var(--text);border-radius:6px;font-family:var(--body);font-size:.85rem;box-sizing:border-box;}
.game-switcher-search:focus{outline:none;border-color:var(--ion);}
.game-switcher-list{list-style:none;margin:0;padding:0;max-height:240px;overflow-y:auto;}
.game-switcher-list li[hidden]{display:none;}
/* Not a control -- a message shown only when a search matched nothing.
   Styled unmistakably differently from a real entry so it can never be
   mistaken for a game called "No games match that." */
.game-switcher-empty{padding:10px;color:var(--text-dimmer);font-family:var(--mono);font-size:.72rem;text-align:center;}
/* Separated from the list by a real rule: it navigates away from the
   switcher's own job rather than being another destination within it. */
.game-switcher-manage{display:block;margin-top:8px;padding:8px 10px;border-top:1px solid var(--line);color:var(--text-dimmer);font-family:var(--mono);font-size:.7rem;letter-spacing:.04em;text-decoration:none;text-align:center;}
.game-switcher-manage:hover{color:var(--star);}
.game-switcher-list a{display:block;padding:8px 10px;margin-bottom:0;border-radius:6px;color:var(--text-dim);text-decoration:none;font-size:.88rem;}
.game-switcher-list a:hover{background:rgba(76,141,255,.12);color:var(--text);}
@media(max-width:1400px){.game-switcher-panel{right:0;left:auto;}}
/* The header/nav-cta instance is desktop-only -- narrow viewports get
   the mobile-dropdown-menu instance instead (rendered inline, not in a
   floating panel, matching the plain nav links already in that menu),
   so the control isn't shown twice at once on a phone. */
@media(max-width:1400px){.nav-cta .game-switcher{display:none;}}
.mobile-dropdown-menu .game-switcher-trigger{width:100%;justify-content:space-between;margin-top:6px;}
.mobile-dropdown-menu .game-switcher-panel{position:static;width:100%;box-shadow:none;margin-top:6px;box-sizing:border-box;}
.user-dropdown-header{display:block;padding:6px 10px;border-radius:6px;transition:background .15s ease;}
.user-dropdown-header:hover{background:rgba(76,141,255,.1);}
.user-dropdown-label{display:block;font-family:var(--mono);font-size:.6rem;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dimmer);margin-bottom:1px;}
.user-dropdown-name{display:block;font-family:var(--body);font-weight:600;font-size:.95rem;color:var(--star);}
.user-dropdown-divider{height:1px;background:var(--line);margin:2px 0 6px;}
.user-dropdown a{display:block;padding:9px 12px;border-radius:6px;font-size:.9rem;color:var(--text-dim);transition:all .15s ease;}
.user-dropdown a:hover{background:rgba(76,141,255,.1);color:var(--text);}
.logout-link{color:var(--text) !important;margin-top:4px;border-top:1px solid var(--line);border-radius:0 0 6px 6px !important;}
.logout-link:hover{color:#ff9baa !important;background:rgba(138,45,59,.15) !important;}
.menu-toggle{display:none;background:none;border:1px solid var(--line);border-radius:6px;width:38px;height:38px;color:var(--text);font-size:18px;cursor:pointer;align-items:center;justify-content:center;}
footer{padding:48px 0 32px;border-top:1px solid var(--line);position:relative;z-index:1;}
.footer-grid{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:32px;}
.footer-brand{display:flex;align-items:center;gap:10px;font-family:var(--display);font-weight:600;font-size:14px;margin-bottom:10px;}
.footer-brand .mark{width:18px;height:18px;}
footer p.tag{color:var(--text-dimmer);font-size:.85rem;max-width:300px;margin-bottom:10px;}
.footer-guidelines-link{display:inline-block;margin-top:18px;color:var(--text-dimmer);font-family:var(--mono);font-size:.72rem;text-decoration:none;transition:color .2s ease;}
.footer-guidelines-link:hover{color:var(--star);}
.footer-links{display:flex;gap:48px;flex-wrap:wrap;}
.footer-col h5{font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--text-dimmer);margin-bottom:14px;}
.footer-col a{display:block;color:var(--text-dim);font-size:.9rem;margin-bottom:10px;transition:color .2s ease;}
.footer-col a:hover{color:var(--star);}
.footer-bottom{margin-top:48px;padding-top:24px;border-top:1px solid var(--line-soft);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;font-family:var(--mono);font-size:11px;color:var(--text-dimmer);}
/* The one empty state for a list that has no rows yet. Replaced eight
   page-local implementations on 2026-07-28 (.feed-empty, .comm-empty,
   .forum-empty, .search-empty in two separate files, .notif-empty,
   .profile-empty, .msg-empty) which had already drifted to four paddings
   and four font sizes for what is visually one element. Majority values
   won: 60px 20px and .88rem.

   Deliberately NOT named .empty-state -- that name is taken by
   catalogue-components.css, where it is a genuinely different component
   (grid-column:1/-1 plus a dashed border, for the catalogue gallery grid).
   Reusing the name would have put two different rules on one selector,
   which is the exact collision .modal-overlay already has.

   Scope note: several other "empty" classes were deliberately left alone
   because they are different components, not copies of this one --
   .mod-empty (16px padding, sits in compact admin panels),
   .submission-overview-empty (a negatively-margined subtitle),
   .empty-panel (a bordered panel with heading and call-to-action),
   .sig-empty (My Signal's gold treatment), .ax-chat-empty (already shared)
   and .manage-no-results (an inline toggled note). See
   UI_COMPONENT_INVENTORY.md entry A4. */
.empty-note{text-align:center;padding:60px 20px;color:var(--text-dimmer);font-family:var(--mono);font-size:.88rem;}
/* The richer empty state: a bordered panel with a heading, a line of
   explanation and a call-to-action. Distinct from .empty-note above, which
   is a single line of text -- this one has children and structure. Was
   byte-identical page-local CSS in events.php, guides.php and
   resources.php until 2026-07-28. */
.empty-panel{background:radial-gradient(ellipse 120% 100% at 50% 0%,var(--panel-2),var(--panel));border:1px dashed var(--line);border-radius:16px;padding:60px 40px;text-align:center;}
.empty-panel h2{font-family:var(--display);font-size:1.2rem;margin-bottom:12px;}
.empty-panel p{color:var(--text-dim);max-width:440px;margin:0 auto 24px;}
/* The ADMIN badge. Was four page-local copies with three different rule
   sets: feed.php and forum-category.php at .6rem, forum-topic.php at
   .58rem, and members.php missing display:inline-block entirely.

   margin-left is deliberately NOT included, though forum-category.php's
   copy had it. Measured live first: three of the four parents are flex
   containers with their own gap (.post-head 8px on both feed and
   forum-topic, .member-name-row 6px on members) and already set
   margin-left to 0. Only forum-category's .topic-title is display:block
   and leans on the margin. Putting it in the shared rule would widen
   those three by 4px each -- on a flex item margin ADDS to the gap -- to
   preserve 2px at one call site. forum-category's gap goes 4px to 2px,
   which still reads clearly given the pill's own 6px padding and filled
   background. Spacing is the parent's job. */
/* Tied to the username scale rather than a fixed value, so it can never
   drift the way the four username sizes just had to be un-drifted. At
   .62 it tracks the name at roughly the ratio it held before the bump
   (9.6px against 15.2px = .63) and stays clearly subordinate -- this is
   a label ON the name, not a peer of it. */
.admin-pill{display:inline-block;font-family:var(--mono);font-size:calc(var(--uname-size-primary) * .62);letter-spacing:.05em;color:#f0a868;background:rgba(240,168,104,.15);padding:2px 6px;border-radius:4px;}
/* The generic modal shell. Until 2026-07-28 these six selectors were
   defined TWICE with different rules -- once in catalogue-components.css
   and once page-locally in moderation.php -- so which one applied was
   decided purely by which stylesheet a page happened to load. That is the
   worst shape in the inventory: unlike the empty states, these collided by
   selector rather than by name.

   Note the copies never actually met, because catalogue-components.css is
   loaded on exactly one page (catalogue-browse.php). So "adopt the shared
   one and delete moderation's" was not possible as stated -- moderation.php
   does not load that file and its modal would have rendered unstyled. Both
   pages do load site.css, so the shell lives here instead and both copies
   are gone.

   Values are a real superset, not one copy winning:
     max-width 640px  (catalogue's; moderation's modal widens from 560px)
     -webkit-backdrop-filter  (catalogue's; moderation's lacked Safari support)
     padding-right on .modal-name  (MODERATION's, and load-bearing -- its
       title is the first child of .modal-body sitting directly beneath the
       absolutely-positioned close button. Dropping it would let a long
       title run underneath that button.)
     the mobile block  (catalogue's; moderation's modal had no mobile rules
       at all)

   Catalogue-specific pieces -- .modal-share, .modal-img-wrap,
   .modal-zoom-hint, .modal-gallery-count, .modal-stats, .modal-block and
   the rest -- correctly stay in catalogue-components.css, which loads
   AFTER site.css (verified: line 361 vs 368) so it can still override. */
.modal-overlay{position:fixed;inset:0;z-index:var(--z-modal);background:rgba(5,7,13,.82);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;}
.modal-overlay[hidden]{display:none;}
.modal-panel{position:relative;width:100%;max-width:640px;max-height:88vh;overflow-y:auto;background:radial-gradient(ellipse 120% 60% at 50% 0%,var(--panel-2),var(--panel));border:1px solid var(--line);border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.6);}
.modal-close{position:absolute;top:16px;right:16px;z-index:2;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:rgba(5,7,13,.65);border:1px solid var(--line);border-radius:50%;color:var(--text);font-size:22px;line-height:1;cursor:pointer;transition:all .2s ease;}
.modal-close:hover{border-color:var(--ion);color:var(--star);background:rgba(76,141,255,.15);}
.modal-body{padding:28px 32px 34px;}
.modal-name{font-family:var(--display);font-weight:600;font-size:1.6rem;letter-spacing:-.01em;margin-bottom:8px;padding-right:30px;}
@media (max-width:600px){
  .modal-overlay{padding:14px;}
  .modal-body{padding:22px 18px 26px;}
  .modal-name{font-size:1.35rem;}
  .modal-close{width:34px;height:34px;top:12px;right:12px;}
}
.form-shell{width:100%;max-width:420px;margin:0 auto;}
.form-shell .section-eyebrow{font-family:var(--mono);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dimmer);display:block;text-align:center;margin-bottom:14px;}
.form-shell h1{font-family:var(--display);font-weight:600;font-size:clamp(1.6rem,3.4vw,2.1rem);letter-spacing:-.01em;text-align:center;margin-bottom:14px;}
.form-shell .subtitle{text-align:center;color:var(--text-dim);font-size:.95rem;max-width:380px;margin:0 auto 32px;}
.form-panel{background:radial-gradient(ellipse 120% 100% at 50% 0%,var(--panel-2),var(--panel));border:1px solid var(--line);border-radius:16px;padding:44px 40px;position:relative;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.5);}
.feedback{text-align:center;margin-bottom:24px;padding:12px 16px;border-radius:10px;font-family:var(--mono);font-size:.82rem;border:1px solid;}
.feedback.error{border-color:#8a2d3b;color:#ff9baa;background:rgba(60,15,22,.35);}
.feedback.success{border-color:#1f8f4f;color:#5fe39a;background:rgba(31,143,79,.12);}
.group{margin-bottom:20px;}
.group label{display:block;font-family:var(--mono);font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;color:var(--star);margin-bottom:10px;}
.required-mark{color:#ff9baa;}
.hint{display:block;font-family:var(--mono);font-size:.7rem;color:var(--text-dimmer);margin-top:6px;}
.group input{width:100%;padding:13px 14px;background:rgba(5,7,13,.5);border:1px solid var(--line);color:var(--text);border-radius:8px;font-family:var(--body);font-size:.95rem;transition:border-color .2s,box-shadow .2s;}
.group input:focus{outline:none;border-color:var(--ion);box-shadow:0 0 0 3px rgba(76,141,255,.15);}
.group input::placeholder{color:var(--text-dimmer);}
.form-footer{text-align:center;margin-top:24px;font-size:.88rem;color:var(--text-dim);}
.form-footer a{color:var(--star);transition:color .2s;}
.form-footer a:hover{color:var(--text);}
main.auth-main{flex:1;display:flex;align-items:center;justify-content:center;padding:140px 24px 60px;position:relative;z-index:1;}
@media(max-width:1400px){.nav-links{display:none;}.menu-toggle{display:flex;}.nav-cta .nav-link-login,.nav-cta .btn-join,.nav-cta .nav-cta-admin{display:none;}main.auth-main{padding:120px 20px 50px;}.form-panel{padding:32px 20px;}}
@media(prefers-reduced-motion:reduce){*{animation-duration:.001ms !important;animation-iteration-count:1 !important;transition-duration:.001ms !important;}html{scroll-behavior:auto;}}

/* Mobile dropdown — pattern refined by Gemini for the forums page,
   adopted site-wide. Anchored to the toggle button with a hard z-index
   so it can never render behind other page content, unlike the earlier
   fixed-position full-width version this replaces. */
.mobile-dropdown-menu{
  display:none;
  position:absolute;
  top:100%;
  right:24px;
  background-color:var(--panel-2);
  border:1px solid var(--line);
  border-radius:8px;
  box-shadow:0 8px 24px rgba(0,0,0,0.7);
  padding:10px 0;
  z-index:var(--z-dropdown);
  flex-direction:column;
  min-width:200px;
  max-height:calc(100vh - 120px);
  max-height:calc(100dvh - 120px);
  overflow-y:auto;
  overscroll-behavior:contain;
}
.mobile-dropdown-menu.open{display:flex;}
.mobile-dropdown-menu a{
  padding:12px 24px;
  color:var(--text);
  text-decoration:none;
  font-family:var(--body);
  font-size:14px;
  font-weight:500;
  border-bottom:1px solid var(--line-soft);
}
.mobile-dropdown-menu a:hover{background-color:rgba(76,141,255,0.1);color:var(--ion);}
.mobile-dropdown-menu a.active-link{color:var(--star) !important;}
.mobile-dropdown-menu a:last-child{border-bottom:none;}

@media (max-width:1400px){
  .nav-cta{display:flex !important;align-items:center;gap:10px;}
  .nav-cta .user-menu,.nav-cta .nav-link-login{display:flex !important;}
  .menu-toggle{display:flex !important;}
}

/* Real touch targets in the fixed header (2026-07-26).
   Root cause, confirmed live and present since the initial commit:
   <nav class="wrap"> means .wrap{padding:0 24px} (specificity 0,1,0)
   outranks nav{padding:18px 24px} (0,0,1) on the same property, and the
   element's own inline style zeroes left/right -- so the header's
   INTENDED 18px of vertical padding has never once applied. Measured on
   the live site at 386px: header 39px tall, #menuToggle's top edge at
   y=0, hit areas 38x38 (hamburger) and 34x34 (avatar). Both are under
   the 44px minimum touch target, and both sit hard against the very top
   edge of the viewport, which is the worst place on a phone to put one.
   A mouse is precise enough to hit them; a thumb frequently is not.

   Fixed WITHOUT changing how anything looks: the hit area is expanded to
   a full 44x44 with a centered, transparent ::after, so both icons keep
   their exact current size, position and styling. The only visual change
   is 6px of vertical breathing room so nothing is flush against y=0.
   Scoped to the mobile-nav breakpoint, so the desktop header is
   untouched, pixel for pixel.

   Deliberately NOT done here: restoring the full 18px the nav rule has
   always asked for. That would take the bar from 39px to ~75px on every
   page -- a real design change, and Brandon's call to make, not a side
   effect of a tap fix. Flagged to him separately. */
@media (max-width:1400px){
  header nav.wrap{padding:6px 0;}
  .menu-toggle,.user-avatar-btn{position:relative;}
  .menu-toggle::after,.user-avatar-btn::after{
    content:'';
    position:absolute;
    top:50%;
    left:50%;
    transform:translate(-50%,-50%);
    width:44px;
    height:44px;
  }
}
.header-avatar-inner{width:34px;height:34px;font-size:.9rem;border-radius:50%;display:flex;align-items:center;justify-content:center;}
.yt-embed { position: relative; width: 100%; aspect-ratio: 16/9; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin-bottom: 10px; background: #000; display: block; }
.yt-embed img { width: 100%; height: 100%; object-fit: cover; display: block; }
.yt-play-btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); background: none; border: none; cursor: pointer; opacity: .9; transition: opacity .2s, transform .2s; }
.yt-play-btn:hover { opacity: 1; transform: translate(-50%,-50%) scale(1.08); }
.yt-embed iframe { width: 100%; height: 100%; border: none; display: block; }
@font-face {
  font-family: 'NMS Glyphs';
  src: url('/assets/fonts/NMS-Glyphs-Mono.ttf') format('truetype');
  font-display: swap;
}
.modal-glyph-char { font-family: 'NMS Glyphs', monospace; font-size: 1.6rem; color: var(--star); display: inline-block; width: 32px; text-align: center; }
.picker-glyph-char { font-family: 'NMS Glyphs', monospace; font-size: 1.3rem; }
/* Was only ever defined locally in catalogue/submit.php, bases-submit.php,
   and planet-finder-submit.php -- correct there (glyph entry/preview), but
   bases.php's and planet-finder.php's detail modals use this same class
   with no definition anywhere in scope, so it silently fell back to plain
   text instead of the glyph font. Sharing it here fixes both and prevents
   a third/fourth page from hitting the same gap. */
/* overflow-wrap/word-break: the sequence is joined tight with no
   separator (a single unbroken "word" by CSS's own definition), so
   without this a long glyph string simply overflows its container --
   default text wrapping only ever breaks at whitespace. Real mobile
   regression: confirmed via a real device screenshot on the Creatures
   catalogue (the -lg variant below, at 2.4rem, made it worse), content
   ran past the viewport edge with no visible scrollbar to hint at it
   (mobile browsers hide inactive scrollbars by default). Applies to
   the base rule so Bases and Planet Finder's own glyph fields can
   never hit the same gap, not just the one reported. */
.nms-glyph-font { font-family: 'NMS Glyphs', sans-serif; font-size: 1.6rem; color: var(--star); line-height: 1.3; overflow-wrap: anywhere; word-break: break-word; }
/* Real bug, found while verifying the Creatures-only request below, not
   scoped to Creatures itself: catalogue-browse.php's own inline
   .modal-field-value rule (0.95rem) is an equally-specific class
   selector that happens to come later in the cascade, so it silently
   won over the plain rule above for every catalogue's glyph display in
   the entry modal -- Bases and Planet Finder included, not just
   Creatures. Never actually rendered at 1.6rem despite that being the
   rule's own stated intent. Every other place this class is used
   (catalogue-submit.php, catalogue-entry-edit.php,
   catalogue-entry-request.php) already scopes its own override with a
   real ancestor selector (.sequence-display/.sequence-btn) and was
   never affected -- #modalFields is that same pattern applied here,
   restoring what was already supposed to happen. */
#modalFields .nms-glyph-font { font-size: 1.6rem; }
/* Creatures-only, real tester request -- applied via an extra class
   (see catalogue-browse.php's modal JS), not a change to the shared
   rule above, since Bases and Planet Finder are staying at the
   (now actually correct) original size. */
#modalFields .nms-glyph-font-lg { font-size: 2.4rem; letter-spacing: 1px; }
@media (max-width: 480px) {
  /* A 12-glyph Portal Glyphs sequence must read as one line -- wrapping
     it (an earlier version of this fix) solves the overflow but produces
     an 11-plus-1-orphan layout that looks broken. Single line enforced
     via nowrap, with the base rule's overflow-wrap/word-break neutralized
     back to normal so nowrap actually holds; the earlier per-character
     width measured live puts this size comfortably under the modal's
     available width for the full 12-character sequence, not just most
     of it. */
  #modalFields .nms-glyph-font-lg { font-size: 1.35rem; letter-spacing: 0; white-space: nowrap; overflow-wrap: normal; word-break: normal; }
}

/* ============================================================
   PREMIUM PERKS — accent color, avatar frame/aura, username
   effects, flair, supporter badge. Deliberately pure CSS/no
   per-instance canvas or JS: these can render dozens of times
   on one Feed page (one per post/comment author), so every
   effect here is a GPU-friendly animated gradient/shadow on the
   element itself, not a simulation that scales with instance
   count. One shared definition used by every page via
   axiom_username_html()/axiom_avatar_html() in feed-helpers.php
   — a new effect only needs a new class added here once.
   ============================================================ */
/* ============================================================
   USERNAME SIZE SCALE -- one definition, surfaces opt in.
   ------------------------------------------------------------
   Before this, the size lived in four places that had already
   drifted apart, including the SAME class name defined twice
   with two different values:

     .post-username    .95rem   assets/feed-card.css
     .post-username    .92rem   forum-topic.php (page-local)
     .comment-username .8rem    assets/feed-card.css
     .result-username  .88rem   search.php (page-local)

   Nothing distinguished .92 from .95 -- they were the same
   element on two surfaces, drifting. .uname itself, the class
   the shared component actually emits, was an EMPTY rule and
   set no size at all, so "change it once in the component" was
   not something the CSS could express.

   Two steps only, deliberately. A third would just be the next
   arbitrary value. Primary is every author line that leads its
   own block (feed post, forum topic, search result). Compact is
   for dense, secondary lists where the name is not the subject
   -- comments today, and it keeps its previous .8rem exactly so
   this consolidation changes nothing it was not meant to.

   Premium username effects paint detail INSIDE the letterforms
   (gradient sweeps, the neon glow, constellation dots), so this
   size is also the canvas those effects perform on. That is the
   real argument for primary being 1rem rather than .92-.95:
   below ~15px a gradient across a name is a smear, not a sweep.
   Kept at 1rem and not higher because Feed post content itself
   measures 15.36px -- past 16px the author's name renders larger
   than what they wrote, which inverts the card's hierarchy.
   ============================================================ */
:root {
  --uname-size-primary: 1.1875rem;  /* 19px -- see the contrast note below: 18px was
                                       0.66px short of WCAG large-text (18.66px bold),
                                       so the 3:1 floor could not honestly be claimed. */
  --uname-size-compact: .8rem;
}
/* 700, not 600: WCAG large-text at this size requires BOLD, and 600 is
   semi-bold. Both halves of the threshold have to be met for the relaxed
   contrast floor below to be legitimate. */
.post-username, .result-username { font-size: var(--uname-size-primary); font-weight: 700; }
/* The relaxed floor applies here and ONLY here -- the surfaces whose text
   is actually large enough to earn it. */
/* Primary (19px/700) is a real neon tube: near-black letterforms defined
   entirely by a luminous edge. The fill is deliberately the page void --
   dark letters with a coloured glow is literally what neon is, and it is
   the mood the user picked when he chose a deep accent.
   This is NOT the original bug. That version paired a void fill with a
   1px stroke in the RAW accent at 1.99:1, so nothing defined the letters.
   The stroke here is the accent lifted well clear of the card, and the
   size is large enough for a sub-pixel stroke to read without closing the
   counters of a/e/o -- which is exactly what went wrong when this was
   tried at 15px. */
.post-username .uname-premium, .result-username .uname-premium {
  --uname-floor: .52;
  --neon-fill: var(--void);
  --neon-stroke-w: .65px;
  --neon-glow-a: 2px;
  --neon-glow-b: 7px;
}
.comment-username { font-size: var(--uname-size-compact); font-weight: 600; }

.uname { }
.uname-premium { font-weight: 600;
  /* ============================================================
     RAW ACCENT FOR LIGHT, FLOORED ACCENT FOR TEXT.
     ------------------------------------------------------------
     A user's chosen accent is used two very different ways: as
     LIGHT (glows, rings, shadows, avatar accents) and as TEXT
     (the name itself, the flair symbols, the gradient fill).
     Light is allowed to be deep -- a dark violet glow is the
     whole point of a dark violet glow. Text is not: painted at
     full depth on this dark page it becomes unreadable.

     Measured on the live Feed before this existed: a real user's
     #4e01cd accent rendered the flair brackets at 1.99:1 against
     the card, where WCAG wants 4.5:1 at this size.

     max(l, .72) in OKLCH raises perceptual lightness ONLY when
     it is below the floor, preserving hue and chroma, so the
     colour stays recognisably the user's own. An accent that is
     already bright passes through completely untouched --
     verified: #7ff2ff comes out byte-identical.

     THE FLOOR IS PER-CONTEXT, and that is the point. .72 targets
     4.5:1 and is the conservative default every surface inherits.
     Primary author lines (19px / weight 700) genuinely meet WCAG's
     large-text definition -- 18.66px AND bold, both halves -- so
     they relax to .52, a 3:1 target, which gives a dark accent far
     more room to stay dark. Comment usernames render at 12.8px and
     are NOT large text, so they keep .72; relaxing them globally
     would have quietly pushed them under their own requirement.

     .52 is not a round number: violet is the worst-case hue at
     every lightness tested, so it sets the value. At .52 violet
     measures 3.04:1, maroon 3.24, navy 3.39, forest 3.54.

     Why it moved: the user whose accent this is said the forced
     brightness had killed the deep colour he deliberately chose.
     Legibility that costs someone their identity is the wrong
     trade when a defensible lower bound exists.

     DO NOT "simplify" these variables back into one, and do not
     point glows at the floored version. They are different jobs.
     ============================================================ */
  --uname-floor: .72;
  /* COMPACT DEFAULT (comments, chat -- 12.8px).
     The glow scales with the type, and that is the whole fix. Cap height
     here is ~9px, so the primary size's 7px halo bleeds nearly a full
     letter-height in every direction: it fills the counters, closes the
     gaps between characters and smears the name into one violet mass.
     Identical CSS, opposite result, purely because the glyph is smaller.
     1px/3px sits around a 9px letter the way 2px/7px sits around a 14px one.

     The fill is the accent raised to a real 4.5:1 -- small text by any
     measure, no stroke to help, so the fill IS the letter. An earlier pass
     made this DIMMER (3.10:1) reasoning that a comment should not read
     bright; that was backwards, because dimming the only thing carrying
     the glyph while the glow smothered it made it unreadable.

     OKLCH rather than a mix toward grey: it hits the contrast target while
     keeping hue and chroma, so the name still reads as the user's own
     colour instead of desaturating into a neutral. Violet -- the
     worst-case hue -- measures 4.77:1 at .64. */
  --neon-fill: oklch(from var(--uname-accent, var(--star)) max(l, .64) c h);
  --neon-stroke-w: 0;
  --neon-glow-a: 1px;
  --neon-glow-b: 3px;
  --uname-accent-legible: oklch(from var(--uname-accent, var(--star)) max(l, var(--uname-floor)) c h);
  --uname-accent-2-legible: oklch(from var(--uname-accent-2, var(--uname-accent, var(--star))) max(l, var(--uname-floor)) c h);
}
/* Second accent color, gradient text -- only ever applied when no
   uname-fx-* effect is chosen (see axiom_username_html()). Effects
   already own their own text coloring/animation; layering a gradient
   fill under one would fight it rather than combine cleanly, so the
   gradient is the richer alternative to "no effect," not a modifier
   stacked on top of one. */
.uname-premium.uname-gradient { background: linear-gradient(90deg, var(--uname-accent-legible, var(--star)), var(--uname-accent-2-legible, var(--uname-accent-legible, var(--star)))); -webkit-background-clip: text; background-clip: text; color: transparent; }
/* opacity 1, not .95: on the relaxed surfaces the flair now sits exactly
   ON its contrast floor, and a .95 composite would drop it back under. */
.uname-flair { opacity: 1; color: var(--uname-accent-legible, var(--star)); }
/* The flair symbols, the name, and the badge are one visual unit --
   nowrap keeps a narrow container (a community member card, a cramped
   comment column) from breaking the line between a flair symbol and
   the name it decorates, which stranded the trailing symbol on its
   own line. */
.uname-premium-group { white-space: nowrap; }
.supporter-badge { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; margin-left: 3px; color: #e8b84b; vertical-align: -1px; filter: drop-shadow(0 0 3px rgba(232,184,75,.6)); }
.supporter-badge svg { width: 100%; height: 100%; }

/* Fire — warm gradient sweeping through the letters */
.uname-fx-fire { background: linear-gradient(90deg, #ff7a3d, #ffcf5c, #ff4d4d, #ff7a3d); background-size: 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: uname-fire 3.5s linear infinite; }
@keyframes uname-fire { 0% { background-position: 0% 50%; } 100% { background-position: 300% 50%; } }

/* Electric — jittering cyan/white flicker via text-shadow */
/* Electric v2 -- the original was just a glow pulsing on solid-color
   text, which read as flat. Now a slow color-shifting gradient sweep
   (cyan/white/blue) runs underneath a faster, irregular flicker, plus a
   subtle skew jitter on the flicker frames for an unstable, crackling
   feel instead of a smooth pulse. */
.uname-fx-electric {
  display: inline-block;
  background: linear-gradient(100deg, #d8f6ff, #7ff2ff, #ffffff, #4c8dff, #7ff2ff);
  background-size: 300% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: uname-electric-sweep 2.6s linear infinite, uname-electric-flicker 0.85s steps(1) infinite;
}
@keyframes uname-electric-sweep { 0% { background-position: 0% 50%; } 100% { background-position: 300% 50%; } }
@keyframes uname-electric-flicker {
  0%, 18%, 22%, 48%, 53%, 100% { filter: drop-shadow(0 0 3px #7ff2ff) drop-shadow(0 0 8px #7ff2ff); transform: skewX(0deg); }
  19% { filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 3px #aef); transform: skewX(-2deg); }
  20% { filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 3px #aef); transform: skewX(1deg); }
  50% { filter: drop-shadow(0 0 10px #fff) drop-shadow(0 0 18px #7ff2ff) drop-shadow(0 0 2px #fff); transform: skewX(-1deg); }
  51% { filter: drop-shadow(0 0 10px #fff) drop-shadow(0 0 18px #7ff2ff) drop-shadow(0 0 2px #fff); transform: skewX(2deg); }
}

/* Ice / Frost — cool crystalline shimmer sweeping across */
.uname-fx-ice { background: linear-gradient(100deg, #bfe8ff 0%, #ffffff 20%, #8fd3ff 40%, #bfe8ff 100%); background-size: 250% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: uname-ice 4s ease-in-out infinite; }
@keyframes uname-ice { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }

/* Void — tiny moving points of light through dark letters, echoing the site's own starfield canvas */
/* Void v2 -- the original's dark starfield fill (#0a0d16 base) was
   nearly indistinguishable from the site's own near-black background
   (--void: #05070d), so the letters effectively disappeared except for
   the tiny star points. A light text-stroke keeps the letterforms
   themselves always visible via their outline, independent of how dark
   the fill inside them is -- the "void inner-lettering, visible outline"
   look, rather than relying on the fill contrast alone. */
.uname-fx-void {
  color: transparent;
  -webkit-text-stroke: 1px rgba(255,255,255,.8);
  text-stroke: 1px rgba(255,255,255,.8);
  background-image: radial-gradient(circle at 15% 40%, #fff 0 1px, transparent 1.5px), radial-gradient(circle at 45% 70%, #8fd3ff 0 1px, transparent 1.5px), radial-gradient(circle at 75% 30%, #fff 0 1px, transparent 1.5px), radial-gradient(circle at 90% 65%, #8fd3ff 0 1px, transparent 1.5px), linear-gradient(#4c1d95, #1e1b4b);
  background-size: 220% 220%; -webkit-background-clip: text; background-clip: text;
  animation: uname-void 6s linear infinite;
  filter: drop-shadow(0 0 3px rgba(255,255,255,.3));
}
@keyframes uname-void { 0% { background-position: 0% 0%; } 100% { background-position: 100% 100%; } }

/* Aurora — slow multi-color sweep */
.uname-fx-aurora { background: linear-gradient(100deg, #38d9c4, #4c8dff, #9b7cf0, #38d9c4); background-size: 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: uname-aurora 6s ease-in-out infinite; }
@keyframes uname-aurora { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }

/* Underlining — this was the original Constellation: a sliding
   underline. Renamed because it never actually looked like stars or a
   constellation; kept as its own effect since it's a good, clean look
   in its own right. */
.uname-fx-underlining { color: var(--text); background-image: linear-gradient(90deg, transparent 0%, var(--uname-accent, var(--star)) 50%, transparent 100%); background-repeat: no-repeat; background-size: 60% 1px; background-position: 0% 100%; animation: uname-constellation 3s ease-in-out infinite; padding-bottom: 2px; }

/* Constellation v2 — real twinkling star points scattered around the
   name (percentage-positioned so it scales with any username length,
   rather than fixed pixel offsets that would misalign on short/long
   names) plus a soft accent-colored glow on the letters themselves, so
   the text and the stars read as one cohesive "starlit" treatment
   instead of a plain line underneath. The four-stop opacity keyframe
   (not a simple sine ease) is deliberate -- real starlight flickers
   irregularly, not in a smooth unison pulse. */
.uname-fx-constellation { position: relative; color: var(--text); text-shadow: 0 0 4px var(--uname-accent, var(--star)); padding: 4px 3px 6px; }
.uname-fx-constellation::after {
  content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
  background-image:
    radial-gradient(circle 1.3px at 6% 20%, var(--uname-accent, var(--star)) 95%, transparent 100%),
    radial-gradient(circle 1px at 30% 85%, var(--uname-accent, var(--star)) 95%, transparent 100%),
    radial-gradient(circle 1.5px at 58% 10%, var(--uname-accent, var(--star)) 95%, transparent 100%),
    radial-gradient(circle 1px at 78% 80%, var(--uname-accent, var(--star)) 95%, transparent 100%),
    radial-gradient(circle 1.3px at 95% 25%, var(--uname-accent, var(--star)) 95%, transparent 100%);
  animation: uname-constellation-twinkle 2.6s ease-in-out infinite;
  pointer-events: none;
}
@keyframes uname-constellation-twinkle { 0%, 100% { opacity: .35; } 25% { opacity: .9; } 50% { opacity: .4; } 75% { opacity: 1; } }
@keyframes uname-constellation { 0% { background-position: -20% 100%; } 50% { background-position: 60% 100%; } 100% { background-position: 140% 100%; } }

/* Chroma — a continuous full-spectrum hue cycle on a vivid base color,
   the RGB-peripheral aesthetic (Razer Chroma, gaming keyboards) rather
   than a fixed multi-stop gradient sweep like Aurora -- genuinely
   different technique (hue-rotate filter, not a background gradient). */
.uname-fx-chroma { color: #4c8dff; filter: saturate(1.6); animation: uname-chroma-cycle 4s linear infinite; }
@keyframes uname-chroma-cycle { from { filter: saturate(1.6) hue-rotate(0deg); } to { filter: saturate(1.6) hue-rotate(360deg); } }

/* Holographic — a single bright diagonal shimmer band sweeping across
   an otherwise neutral base, the foil-trading-card look, distinct from
   every other effect here in that most of the text stays quiet between
   passes instead of being colorful the whole time. Originally used
   `color: var(--text)` (an opaque color) alongside background-clip:
   text, which is a no-op combination -- background-clip: text only
   shows through where the text color is actually transparent, so the
   solid color painted over the gradient completely and the effect
   never visibly did anything. Fixed to color: transparent with a dim,
   translucent base baked into the gradient itself (not true
   transparent) so the name stays fully legible at every point in the
   sweep instead of briefly vanishing whenever the bright band isn't
   currently over the visible text. */
.uname-fx-holographic {
  color: transparent;
  background-image: linear-gradient(115deg, rgba(237,242,250,.4) 0%, rgba(237,242,250,.4) 25%, #fff 45%, var(--uname-accent, var(--star)) 50%, #fff 55%, rgba(237,242,250,.4) 75%, rgba(237,242,250,.4) 100%);
  background-size: 300% 100%; -webkit-background-clip: text; background-clip: text;
  animation: uname-holographic-sweep 3.2s ease-in-out infinite;
}
@keyframes uname-holographic-sweep { 0% { background-position: 200% 0; } 60%, 100% { background-position: -100% 0; } }

/* Glitch — a quick RGB-split flicker (red/cyan channel offset), the
   cyberpunk-glitch look. Mostly static/clean with brief glitch bursts,
   not continuous, so it doesn't fight for attention with a fully
   animated effect running the entire time. */
/* The burst originally occupied ~6% of a 3.5s cycle (about 200ms) --
   technically correct but rare enough that it read as "does nothing"
   in normal viewing. Shortened the cycle and widened the burst window
   so it's reliably noticeable without going fully constant. */
.uname-fx-glitch { color: var(--text); position: relative; animation: uname-glitch-burst 2s steps(1) infinite; }
@keyframes uname-glitch-burst {
  0%, 70%, 100% { text-shadow: none; transform: translate(0); }
  71% { text-shadow: -2px 0 #ff3b5c, 2px 0 #4cf1ff; transform: translate(-1px, 0); }
  74% { text-shadow: 2px 0 #ff3b5c, -2px 0 #4cf1ff; transform: translate(1px, 0); }
  77% { text-shadow: -1px 0 #ff3b5c, 1px 0 #4cf1ff; transform: translate(0, 1px); }
  80%, 90% { text-shadow: none; transform: translate(0); }
  93% { text-shadow: 1px 0 #ff3b5c, -1px 0 #4cf1ff; transform: translate(-1px, 0); }
  96%, 100% { text-shadow: none; transform: translate(0); }
}

/* Neon Outline — a calmer, steady alternative to Electric: clean bright
   outline + glow, no jitter or flicker, the neon-signage look. */
/* Legibility comes from MASS AND LIGHTNESS, not from washing the colour
   out. The original rule painted the fill in var(--void) -- literally
   the page background -- leaving letterforms at 1.07:1 with the whole
   name resting on a 1.99:1 glow. The first fix overcorrected: a
   color-mix toward #fff hit 12:1 but desaturated a deep violet into
   pale lavender, so the name no longer matched the user's own avatar
   frame sitting inches away. That trade was wrong -- the accent IS the
   identity.

   So: raise OKLCH lightness while keeping chroma, which stays saturated
   (#4e01cd -> rgb(131,102,255), a real violet, not a lavender), and add
   mass instead of brightness -- weight 700 and .03em tracking. The
   tracking matters at this size: it stops the glow bleeding into the
   counters of a/e/o, which is what actually made the old version read
   as a smudge.

   No text-stroke. Tested at .4px, .5px and .75px against the fill and
   every one muddied the glyph at 15px rather than defining it.

   The floor here (.64) is deeper than --uname-accent-legible's (.72)
   ON PURPOSE: this text sits inside three glow layers that add real
   perceived brightness, so it can hold a deeper colour than the flair
   symbols, which have none. Glow-backed text can be darker than
   unglowed text and still read.

   Measured against the composited card rgb(15,17,23): violet 4.77,
   maroon 5.32, navy 5.53, forest 5.79 -- all clearing 4.5:1. Bright
   accents pass through untouched (cyan and orange both byte-identical).
   Glow layers keep the RAW accent so the halo still matches the avatar
   frame's own ring exactly. */
.uname-fx-neon {
  /* Void depth: bright at the top edge, falling to deep at the base, so
     the letterform has dimension instead of being one flat colour. The
     TOP stop carries legibility (8.71:1) which is what lets the base go
     genuinely dark (3.63:1) without the bottom of the glyphs sinking
     into the card -- L.50 measures 2.78:1 and is where that starts, so
     .56 is the floor here, not a round number.
     FILL AND STROKE ARE PER-CONTEXT, from one rule. Primary uses a
     void fill with a luminous stroke; compact uses a mid-tone fill
     and no stroke, because at 12.8px a stroke is wider than the
     letterform strokes themselves and closes the counters.

     Honest note on contrast: the primary fill is intentionally at
     ~1:1 against the card. Legibility there comes from the stroke
     and glow, not the fill, which is what neon signage actually is
     and what .uname-fx-void has always done. WCAG measures text
     colour against background and has no model for an outlined
     glyph, so the primary treatment is a deliberate design choice
     that a contrast ratio does not describe -- not a passing score.
     The compact fill IS measured, because there the fill is what
     carries the letter. */
  color: var(--neon-fill);
  -webkit-text-fill-color: var(--neon-fill);
  -webkit-text-stroke: var(--neon-stroke-w) oklch(from var(--uname-accent, var(--star)) max(l, .74) c h);
  text-stroke: var(--neon-stroke-w) oklch(from var(--uname-accent, var(--star)) max(l, .74) c h);
  font-weight: 700; letter-spacing: .03em;
  /* drop-shadow, NOT text-shadow: text-shadow does not survive
     background-clip:text -- it paints behind a fill that is now
     transparent, so the glow would show THROUGH the letters. filter
     applies to the composited result and keeps the halo outside them.
     The breathe keyframes below animate filter for the same reason. */
  filter: drop-shadow(0 0 var(--neon-glow-a) var(--uname-accent, var(--star))) drop-shadow(0 0 var(--neon-glow-b) var(--uname-accent, var(--star)));
  animation: uname-neon-breathe 2.6s ease-in-out infinite;
}
/* Transparent so the parent's gradient clips through the flair too --
   otherwise the brackets paint their own flat colour and sit visibly
   apart from the name they are wrapping. */
/* Was transparent so the clipped gradient could show through. There is no
   clipped background any more, so the flair takes the same fill as the name
   and stays one visual unit with it. */
.uname-fx-neon .uname-flair { color: var(--neon-fill); -webkit-text-fill-color: var(--neon-fill); letter-spacing: 0; }
/* Derived from the same two variables rather than hardcoded, otherwise the
   animation would blow the glow back up to full size on every cycle in a
   context that was deliberately scaled down. */
@keyframes uname-neon-breathe { 0%, 100% { filter: drop-shadow(0 0 var(--neon-glow-a) var(--uname-accent, var(--star))) drop-shadow(0 0 var(--neon-glow-b) var(--uname-accent, var(--star))); } 50% { filter: drop-shadow(0 0 calc(var(--neon-glow-a) * 1.5) var(--uname-accent, var(--star))) drop-shadow(0 0 calc(var(--neon-glow-b) * 1.55) var(--uname-accent, var(--star))); } }

/* --- Encircling effects: these ring the whole flair+name unit rather
   than styling the text fill, so they apply to .uname-premium (the
   outer span axiom_username_html() already wraps flair+text in) and
   need a real single box to trace a path/border around -- inline-block
   plus a little padding, not just inline text. --- */
.uname-fx-orbit-pulse { display: inline-block; padding: 1px 5px; border-radius: 6px; }

/* Electric Ring v2 — the original was one uniform box-shadow flashing
   on and off, which read as "one whole line" rather than anything
   electric. Kept a dim, steady base glow (so the ring itself is still
   legible at rest) but the actual "electric" character now comes from
   two small sparks arcing around the name's border independently --
   different durations, different step counts, opposite directions --
   so they're never in sync with each other, jumping between discrete
   points rather than traveling smoothly (steps() timing, not linear),
   which reads as genuine arcing/crackling instead of one thing
   rotating uniformly. */
.uname-fx-electric-ring { position: relative; display: inline-flex; align-items: center; border-radius: 6px; padding: 1px 5px; box-shadow: 0 0 3px 1px rgba(127,242,255,.35), inset 0 0 3px 0 rgba(127,242,255,.35); animation: uname-electric-ring-flicker 0.85s steps(1) infinite; }
@keyframes uname-electric-ring-flicker {
  0%, 18%, 22%, 100% { box-shadow: 0 0 3px 1px rgba(127,242,255,.35), inset 0 0 3px 0 rgba(127,242,255,.35); }
  20% { box-shadow: 0 0 6px 2px rgba(255,255,255,.6), inset 0 0 4px 1px rgba(255,255,255,.6); }
}
.uname-fx-electric-ring::before, .uname-fx-electric-ring::after {
  content: ''; position: absolute; width: 4px; height: 4px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 4px 2px var(--uname-accent, #7ff2ff), 0 0 8px 3px var(--uname-accent, #7ff2ff);
  offset-path: border-box; pointer-events: none;
}
.uname-fx-electric-ring::before { animation: uname-electric-spark-a 1.6s steps(6) infinite; }
.uname-fx-electric-ring::after { animation: uname-electric-spark-b 2.1s steps(5) infinite reverse; }
@keyframes uname-electric-spark-a { to { offset-distance: 100%; } }
@keyframes uname-electric-spark-b { to { offset-distance: 100%; } }

/* Electric Ring, comment-density variant — the full ring's steady
   box-shadow plus two independently-arcing sparks reads as too much
   next to a stack of other replies. Swapped for one small fixed spark
   that gently breathes in place: still unmistakably "electric" and
   still clearly marks this user as premium, without a glowing box or
   moving parts competing for attention in a dense comment thread. */
.uname-fx-electric-ring-mini { position: relative; display: inline-flex; align-items: center; padding: 1px 3px; }
.uname-fx-electric-ring-mini::after {
  content: ''; position: absolute; top: -1px; right: -3px; width: 4px; height: 4px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 3px 1px var(--uname-accent, #7ff2ff), 0 0 6px 2px var(--uname-accent, #7ff2ff);
  animation: uname-mini-spark-pulse 1.8s ease-in-out infinite;
  pointer-events: none;
}

/* Fire Ring — third design. The first two both tried to build the ring
   out of a rotating gradient FILL (masked, then later covered by an
   opaque chip) and both looked right on paper and wrong in a real
   browser. This version doesn't try to fill a ring shape at all: a
   small comet -- a short gradient streak, bright hot-white head fading
   to a transparent tail -- travels the name's own border-box via
   offset-path, oriented automatically along its direction of travel
   (offset-rotate: auto) so the bright end always leads and the tail
   always trails behind it, like an ember trailing off a moving flame.
   offset-distance animates straight from 0% to 100% with no easing and
   no pause -- ouroboros: one continuous lap, arriving back exactly
   where it started with no visible seam, then immediately repeating.
   A steady, low-intensity box-shadow on the outer element keeps a
   faint warm ring always visible so the shape reads as a ring between
   comet passes, not just a dot with nothing connecting it. */
.uname-fx-fire-ring { position: relative; display: inline-flex; align-items: center; border-radius: 8px; padding: 1px 6px; box-shadow: 0 0 0 1px rgba(255,122,61,.32), 0 0 4px 1px rgba(255,80,0,.22); }
.uname-fx-fire-ring::after {
  content: ''; position: absolute; width: 16px; height: 5px; border-radius: 50%;
  background: linear-gradient(90deg, transparent 0%, #ff3d00 30%, #ff9d3d 65%, #fff8e0 100%);
  filter: drop-shadow(0 0 3px #ff7a3d) drop-shadow(0 0 6px #ff4500);
  offset-path: border-box;
  offset-rotate: auto;
  animation: uname-fire-comet 2.6s linear infinite;
  pointer-events: none;
}
@keyframes uname-fire-comet { to { offset-distance: 100%; } }

/* Fire Ring, comment-density variant — same reasoning as Electric
   Ring's mini above: the steady box-shadow ring plus the continuously
   traveling comet is a lot of motion for a tight reply stack. One
   small ember that pulses in place keeps the "this user is on fire"
   read without a border or a moving streak. */
.uname-fx-fire-ring-mini { position: relative; display: inline-flex; align-items: center; padding: 1px 3px; }
.uname-fx-fire-ring-mini::after {
  content: ''; position: absolute; top: -1px; right: -3px; width: 5px; height: 5px; border-radius: 50%;
  background: radial-gradient(circle, #fff8e0 0%, #ff9d3d 55%, transparent 100%);
  filter: drop-shadow(0 0 2px #ff7a3d) drop-shadow(0 0 4px #ff4500);
  animation: uname-mini-spark-pulse 2.2s ease-in-out infinite;
  pointer-events: none;
}
@keyframes uname-mini-spark-pulse { 0%, 100% { opacity: .55; transform: scale(.85); } 50% { opacity: 1; transform: scale(1.2); } }

/* Fire Spirit — the same comet as Fire Ring, minus the steady base
   ring around it. Just the flame itself drifting around the name,
   nothing marking out a border/line at rest. */
.uname-fx-fire-spirit { position: relative; display: inline-flex; align-items: center; padding: 1px 6px; }
.uname-fx-fire-spirit::after {
  content: ''; position: absolute; width: 16px; height: 5px; border-radius: 50%;
  background: linear-gradient(90deg, transparent 0%, #ff3d00 30%, #ff9d3d 65%, #fff8e0 100%);
  filter: drop-shadow(0 0 3px #ff7a3d) drop-shadow(0 0 6px #ff4500);
  offset-path: border-box; offset-rotate: auto;
  animation: uname-fire-comet 2.6s linear infinite;
  pointer-events: none;
}

/* Orbit Pulse — a small glowing point traces the name's own border-box:
   fast for most of the lap, decelerating as it nears the start, a
   bright pulse flash right at the origin, a brief pause, then repeats.
   offset-path: border-box is the right primitive for this -- it traces
   whatever box the element actually is, so it fits a variable-width
   username the same way a fixed circle fits an avatar. */
.uname-fx-orbit-pulse { position: relative; }
.uname-fx-orbit-pulse::after {
  content: '';
  position: absolute; width: 7px; height: 7px; border-radius: 50%;
  background: var(--uname-accent, var(--star));
  box-shadow: 0 0 6px 2px var(--uname-accent, var(--star)), 0 0 12px 4px var(--uname-accent, var(--star));
  offset-path: border-box;
  animation: uname-orbit-travel 3.4s cubic-bezier(.3,.75,.25,1) infinite;
}
@keyframes uname-orbit-travel {
  0% { offset-distance: 0%; opacity: 1; transform: scale(1); }
  68% { offset-distance: 90%; opacity: 1; transform: scale(1); }
  78% { offset-distance: 100%; opacity: 1; transform: scale(1.7); }
  88% { offset-distance: 100%; opacity: .35; transform: scale(1); }
  100% { offset-distance: 100%; opacity: 0; transform: scale(.8); }
}

/* Avatar wrapper, frame styles, and particle aura */
/* align-self: flex-start is load-bearing, not decorative -- every page
   that places an avatar next to post/comment content does so inside a
   `display: flex` row with no explicit height on this wrapper, so the
   default `align-items: stretch` was pulling it to match the height of
   the message beside it, turning a circle into a tall oval. This opts
   the avatar out of that stretch regardless of what any given page's
   own flex-align settings are. */
/* z-index: 0 (not just position: relative) is load-bearing here -- it
   gives this wrapper its own local stacking context, so a frame's
   negative-z-index pseudo-element (see Fire, below) stays contained
   behind the avatar itself instead of escaping to paint behind whatever
   this wrapper's own ancestor happens to be on a given page. */
.avatar-premium-wrap { position: relative; z-index: 0; display: inline-flex; flex-shrink: 0; align-self: flex-start; }
.avatar-aura { position: absolute; inset: -22%; border-radius: 50%; background: radial-gradient(circle, var(--avatar-accent, var(--ion)) 0%, transparent 68%); opacity: .55; filter: blur(2px); animation: avatar-aura-pulse 3.2s ease-in-out infinite; pointer-events: none; z-index: 0; }
.avatar-aura::before, .avatar-aura::after { content: ''; position: absolute; width: 3px; height: 3px; border-radius: 50%; background: var(--avatar-accent, var(--ion)); box-shadow: 0 0 4px 1px var(--avatar-accent, var(--ion)); }
.avatar-aura::before { top: 6%; left: 12%; animation: avatar-twinkle 2.4s ease-in-out infinite; }
.avatar-aura::after { bottom: 10%; right: 8%; animation: avatar-twinkle 2.4s ease-in-out infinite 1.2s; }
@keyframes avatar-aura-pulse { 0%, 100% { opacity: .38; transform: scale(1); } 50% { opacity: .62; transform: scale(1.06); } }
@keyframes avatar-twinkle { 0%, 100% { opacity: .2; transform: scale(.7); } 50% { opacity: 1; transform: scale(1.2); } }
.avatar-premium-wrap > *:not(.avatar-aura) { position: relative; z-index: 1; }

/* Insets widened from the original -4px/-5px -- at the old clearance the
   avatar visually crowded its own ring, looking cut off rather than
   framed. Widening the frame's own clearance (instead of shrinking the
   avatar itself) gets the same "avatar reads smaller inside its frame"
   result without touching the avatar's box or its fallback-letter
   font-size at all. */
.avatar-frame-ring::before { content: ''; position: absolute; inset: -7px; border-radius: 50%; border: 2px solid var(--avatar-accent, var(--ion)); box-shadow: 0 0 8px 1px var(--avatar-accent, var(--ion)); z-index: 2; pointer-events: none; }
.avatar-frame-dashed::before { content: ''; position: absolute; inset: -8px; border-radius: 50%; border: 2px dashed var(--avatar-accent, var(--ion)); z-index: 2; pointer-events: none; animation: avatar-dashed-spin 18s linear infinite; }
@keyframes avatar-dashed-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Orbit radius pulled in (30px/50px default/profile -> 22px/38px) and
   the dot itself grown and brightened (flat color -> white-hot core
   fading to the accent color, bigger glow) -- it was orbiting far
   enough out, and small/dim enough, that it barely read as attached to
   the avatar at all. */
.avatar-frame-orbit::after { content: ''; position: absolute; top: 50%; left: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: radial-gradient(circle, #fff 0%, var(--avatar-accent, var(--ion)) 60%, transparent 100%); box-shadow: 0 0 8px 2px var(--avatar-accent, var(--ion)); z-index: 2; pointer-events: none; animation: avatar-orbit 3.6s linear infinite; transform-origin: center; }
@keyframes avatar-orbit { from { transform: rotate(0deg) translateX(var(--orbit-radius, 22px)) rotate(0deg); } to { transform: rotate(360deg) translateX(var(--orbit-radius, 22px)) rotate(-360deg); } }
.profile-avatar.avatar-premium-wrap, .avatar-premium-wrap:has(.profile-avatar) { --orbit-radius: 38px; }

/* Recruiter -- Tier 1 referral reward, exclusive (not a key in
   AXIOM_AVATAR_FRAMES, see axiom_avatar_html()'s special-cased render
   check), only ever awarded by axiom_award_referral_tier(), never
   pickable. A ring plus four static satellite points scattered around
   it, directly echoing the site's own logomark (one hub, four
   connected satellites) rather than reusing any other frame's motif. */
.avatar-frame-recruiter::before { content: ''; position: absolute; inset: -7px; border-radius: 50%; border: 2px solid var(--avatar-accent, var(--ion)); box-shadow: 0 0 8px 1px var(--avatar-accent, var(--ion)); z-index: 2; pointer-events: none; }
.avatar-frame-recruiter::after {
  content: '';
  position: absolute; inset: -16px;
  background-image:
    radial-gradient(circle 2.5px at 50% 3%, var(--avatar-accent, var(--ion)) 90%, transparent 100%),
    radial-gradient(circle 2.5px at 94% 36%, var(--avatar-accent, var(--ion)) 90%, transparent 100%),
    radial-gradient(circle 2.5px at 76% 92%, var(--avatar-accent, var(--ion)) 90%, transparent 100%),
    radial-gradient(circle 2.5px at 12% 76%, var(--avatar-accent, var(--ion)) 90%, transparent 100%);
  z-index: 2; pointer-events: none;
  animation: avatar-recruiter-twinkle 3s ease-in-out infinite;
}
@keyframes avatar-recruiter-twinkle { 0%, 100% { opacity: .5; } 50% { opacity: 1; } }

/* Companion game icon -- a real per-game image (the same card art used
   on the Select Your Game hub), orbiting independently of any chosen
   avatar_frame. A slightly wider radius than the plain orbit dot above
   so the two paths read as distinct rings rather than overlapping,
   for anyone who has both. */
/* .avatar-premium-wrap > *:not(.avatar-aura) (near the top of this
   section) sets position:relative on every direct child at higher
   specificity than a plain .avatar-companion rule would have -- it was
   written before this element existed and otherwise silently wins,
   breaking the absolute positioning this needs. Scoped with the parent
   class here specifically to out-specify it, not just coincidentally. */
.avatar-premium-wrap > .avatar-companion { content: ''; position: absolute; top: 50%; left: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%; overflow: hidden; border: 1.5px solid rgba(255,255,255,.55); box-shadow: 0 0 5px 1px rgba(0,0,0,.5); z-index: 3; pointer-events: none; animation: avatar-companion-orbit 5.5s linear infinite; transform-origin: center; }
.avatar-companion img { width: 100%; height: 100%; object-fit: cover; display: block; }
@keyframes avatar-companion-orbit { from { transform: rotate(0deg) translateX(var(--companion-orbit-radius, 28px)) rotate(0deg); } to { transform: rotate(360deg) translateX(var(--companion-orbit-radius, 28px)) rotate(-360deg); } }
.profile-avatar.avatar-premium-wrap, .avatar-premium-wrap:has(.profile-avatar) { --companion-orbit-radius: 46px; }

/* Profile constellation background -- four real, distinct patterns
   (see axiom_profile_constellation_html() for the SVG each one draws),
   not one template with a density knob. Sits behind the real profile
   content via negative z-index within .profile-wrap's own stacking
   context (.profile-wrap is already position:relative), so no change
   to the existing content is needed for correct layering. */
.profile-constellation { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
.profile-constellation-origin { opacity: .22; }
.origin-hub { fill: var(--constellation-accent, var(--ion)); filter: drop-shadow(0 0 10px var(--constellation-accent, var(--ion))); }
.origin-satellite { fill: var(--constellation-accent, var(--star)); opacity: .7; }
.origin-line { stroke: var(--constellation-accent, var(--ion)); stroke-width: 1; opacity: .3; animation: constellation-breathe 8s ease-in-out infinite; }

.profile-constellation-expanse { opacity: .18; }
.expanse-line { stroke: var(--constellation-accent, var(--ion)); stroke-width: .75; opacity: .4; }
.expanse-satellite { fill: var(--constellation-accent, var(--star)); opacity: .65; }
.expanse-hub { fill: var(--constellation-accent, var(--ion)); }
.expanse-2, .expanse-5 { opacity: .55; }

.profile-constellation-convergence { opacity: .3; }
.convergence-lines line { stroke: var(--constellation-accent, var(--ion)); stroke-width: .75; opacity: .16; animation: convergence-draw 6s ease-in-out infinite; }
.convergence-points circle { fill: var(--constellation-accent, var(--star)); opacity: .55; }
.convergence-hub { fill: var(--constellation-accent, var(--ion)); filter: drop-shadow(0 0 12px var(--constellation-accent, var(--ion))); animation: convergence-pulse 4s ease-in-out infinite; }
@keyframes convergence-draw { 0%, 100% { opacity: .08; } 50% { opacity: .3; } }
@keyframes convergence-pulse { 0%, 100% { opacity: .75; } 50% { opacity: 1; } }

/* Foundation -- Tier 3 referral reward, exclusive (see
   axiom_profile_constellation_html()'s special-cased render check),
   only ever awarded by axiom_award_referral_tier(). Three concentric
   rings of points radiating from the hub, each ring's line/point
   opacity set per-instance in the PHP itself (real light falloff, not
   a flat rule) -- this just supplies the shimmer animation. */
.profile-constellation-foundation { opacity: .34; }
.foundation-line { stroke: var(--constellation-accent, var(--ion)); stroke-width: .75; animation: foundation-shimmer 5s ease-in-out infinite; }
.foundation-point { fill: var(--constellation-accent, var(--star)); animation: foundation-shimmer 5s ease-in-out infinite; }
.foundation-hub { fill: var(--constellation-accent, var(--ion)); filter: drop-shadow(0 0 14px var(--constellation-accent, var(--ion))); animation: convergence-pulse 4s ease-in-out infinite; }
@keyframes foundation-shimmer { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }

.profile-constellation-drift { opacity: .32; }
.drift-point { fill: var(--constellation-accent, var(--star)); }
.drift-a { animation: drift-a 22s ease-in-out infinite; }
.drift-b { animation: drift-b 26s ease-in-out infinite; }
.drift-c { animation: drift-c 19s ease-in-out infinite; }
.drift-d { animation: drift-d 24s ease-in-out infinite; }
.drift-e { animation: drift-e 28s ease-in-out infinite; }
.drift-f { animation: drift-f 21s ease-in-out infinite; }
.drift-g { animation: drift-g 25s ease-in-out infinite; }
@keyframes constellation-breathe { 0%, 100% { opacity: .2; } 50% { opacity: .55; } }
@keyframes drift-a { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(14px,-10px); } }
@keyframes drift-b { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(-10px,12px); } }
@keyframes drift-c { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(12px,10px); } }
@keyframes drift-d { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(-14px,-8px); } }
@keyframes drift-e { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(10px,-13px); } }
@keyframes drift-f { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(-11px,9px); } }
@keyframes drift-g { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(13px,11px); } }

.personal-title { font-family: var(--mono); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--star); margin-top: 2px; }

/* Electric (wavy) -- a jagged, lightning-like ring instead of a smooth
   circle. The zigzag shape is a fixed SVG polygon used as a mask (so
   it's real geometry, not an approximation), with the actual color
   still coming from --avatar-accent via a plain background-color under
   the mask -- same "shape via mask, color via CSS" technique as the
   fire ring below. Kept close to the avatar and modest in reach on
   purpose, so it reads as a live-wire trace, not an obstruction. */
.avatar-frame-electric::before {
  content: '';
  position: absolute; inset: -8px;
  background: var(--avatar-accent, var(--ion));
  -webkit-mask-size: 100% 100%; mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  filter: drop-shadow(0 0 3px var(--avatar-accent, var(--ion)));
  animation: avatar-electric-flicker 0.85s steps(1) infinite, avatar-electric-spin 6s linear infinite, avatar-electric-morph 0.6s steps(1) infinite;
  z-index: 2; pointer-events: none;
}
@keyframes avatar-electric-flicker { 0%, 18%, 22%, 100% { opacity: 1; } 20% { opacity: .5; } }
/* Wasn't actually spinning before -- only the flicker animation was
   present, and a static jagged ring doesn't read as "electric" the way
   a live-wire trace should. Runs alongside the flicker (two separate
   animations on the same element, targeting different properties). */
@keyframes avatar-electric-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* The zigzag itself was a single fixed shape -- rigid, not "live."
   This swaps between two different jagged polygons (deeper valleys in
   the second one) via steps() timing, which jumps discretely between
   mask-image values rather than trying to interpolate between two
   images (which isn't something CSS can do) -- combined with the
   existing spin, the shape itself now visibly pulses/contracts as it
   rotates instead of being one rigid star tracing a circle. */
@keyframes avatar-electric-morph {
  0%, 100% {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpolygon points='98,50 86.96,65.3 83.9,83.9 65.3,86.96 50,98 34.7,86.96 16.1,83.9 13.04,65.3 2,50 13.04,34.7 16.1,16.1 34.7,13.04 50,2 65.3,13.04 83.9,16.1 86.96,34.7' fill='none' stroke='white' stroke-width='6' stroke-linejoin='round'/%3E%3C/svg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpolygon points='98,50 86.96,65.3 83.9,83.9 65.3,86.96 50,98 34.7,86.96 16.1,83.9 13.04,65.3 2,50 13.04,34.7 16.1,16.1 34.7,13.04 50,2 65.3,13.04 83.9,16.1 86.96,34.7' fill='none' stroke='white' stroke-width='6' stroke-linejoin='round'/%3E%3C/svg%3E");
  }
  50% {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpolygon points='98,50 79.57,62.25 83.9,83.9 62.25,79.57 50,98 37.75,79.57 16.1,83.9 20.43,62.25 2,50 20.43,37.75 16.1,16.1 37.75,20.43 50,2 62.25,20.43 83.9,16.1 79.57,37.75' fill='none' stroke='white' stroke-width='6' stroke-linejoin='round'/%3E%3C/svg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpolygon points='98,50 79.57,62.25 83.9,83.9 62.25,79.57 50,98 37.75,79.57 16.1,83.9 20.43,62.25 2,50 20.43,37.75 16.1,16.1 37.75,20.43 50,2 62.25,20.43 83.9,16.1 79.57,37.75' fill='none' stroke='white' stroke-width='6' stroke-linejoin='round'/%3E%3C/svg%3E");
  }
}

/* Pulse -- a steady base ring plus an outward-expanding, fading ring
   behind it, like a sonar ping / notification pulse. */
/* box-shadow added to the base ring -- it was a plain thin border with
   no glow at all before, noticeably weaker than Solid Glow Ring right
   next to it in the picker. Same glow intensity as Solid Glow Ring, so
   Pulse now reads as "Solid Glow Ring, plus a pulse," not a dimmer
   variant of it. */
.avatar-frame-pulse::before {
  content: ''; position: absolute; inset: -6px; border-radius: 50%;
  border: 1.5px solid var(--avatar-accent, var(--ion));
  box-shadow: 0 0 8px 1px var(--avatar-accent, var(--ion));
  z-index: 2; pointer-events: none;
}
.avatar-frame-pulse::after {
  content: ''; position: absolute; inset: -6px; border-radius: 50%;
  border: 2px solid var(--avatar-accent, var(--ion));
  z-index: 1; pointer-events: none;
  animation: avatar-pulse-ring 2.2s ease-out infinite;
}
@keyframes avatar-pulse-ring { 0% { transform: scale(0.9); opacity: .9; } 75% { opacity: .15; } 100% { transform: scale(1.4); opacity: 0; } }

/* Fire -- a rotating conic gradient sitting behind the avatar itself
   (z-index: -1, contained by .avatar-premium-wrap's own stacking
   context above), so only the outer collar is ever visible -- no
   masking involved. Originally carved into a ring via the padding +
   mask-composite: exclude trick; that doesn't reliably apply in every
   environment, and an unmasked fallback shows the full gradient
   rectangle, not a ring, which is what this rewrite avoids by
   construction rather than by relying on mask support at all. Reuses
   the same warm palette as the Fire username effect for a consistent
   "what fire looks like on this site." Rotated with transform instead
   of animating the gradient angle directly, since that needs @property
   for smooth interpolation and isn't supported everywhere yet -- this
   way works universally. */
/* inset tightened from -8px to -5px -- at -8px the visible collar was
   as much as a quarter of the avatar's own diameter, reading as a
   thick, flat halo rather than a ring. A warm drop-shadow was also
   added for some actual glow/richness, since a bare gradient collar on
   its own looked plain next to the other frames. */
.avatar-frame-fire::before {
  content: ''; position: absolute; inset: -5px; border-radius: 50%;
  background: conic-gradient(from 0deg, #ff7a3d, #ffcf5c, #ff4d4d, #ff7a3d);
  filter: drop-shadow(0 0 3px #ff5a1f);
  animation: avatar-fire-spin 3s linear infinite;
  z-index: -1; pointer-events: none;
}
@keyframes avatar-fire-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

/* Comet -- the same offset-path comet technique as the Fire Ring/Fire
   Spirit username effects, adapted to the avatar's own circular
   border-box: a small bright ember traces the avatar's edge
   continuously, oriented along its direction of travel so it reads as
   a moving flame rather than a dot sliding around a track. No base
   ring at all here, matching Fire Spirit's "just the flame" approach
   rather than Fire's "always-visible collar" approach. */
.avatar-frame-comet::after {
  content: ''; position: absolute; width: 7px; height: 7px; border-radius: 50%;
  background: radial-gradient(circle, #fff8e0 0%, #ff9d3d 55%, transparent 100%);
  filter: drop-shadow(0 0 4px #ff7a3d) drop-shadow(0 0 8px #ff4500);
  offset-path: border-box; offset-rotate: auto;
  animation: avatar-comet-travel 2.8s linear infinite;
  z-index: 2; pointer-events: none;
}
@keyframes avatar-comet-travel { to { offset-distance: 100%; } }

/* Corner Brackets -- a sci-fi viewfinder / targeting-reticle reference
   instead of a ring at all: four short corner marks. Built from one
   pseudo-element's conic-gradient carved into four small arcs via a
   repeating mask, since a literal 4-bracket SVG would need its own
   asset; this keeps it a single fixed shape like the other frames. */
.avatar-frame-brackets::before {
  content: ''; position: absolute; inset: -9px; border-radius: 8px;
  background:
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) top left / 30% 2px no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) top left / 2px 30% no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) top right / 30% 2px no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) top right / 2px 30% no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) bottom left / 30% 2px no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) bottom left / 2px 30% no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) bottom right / 30% 2px no-repeat,
    linear-gradient(var(--avatar-accent, var(--ion)), var(--avatar-accent, var(--ion))) bottom right / 2px 30% no-repeat;
  filter: drop-shadow(0 0 2px var(--avatar-accent, var(--ion)));
  animation: avatar-brackets-pulse 2.4s ease-in-out infinite;
  z-index: 2; pointer-events: none;
}
@keyframes avatar-brackets-pulse { 0%, 100% { opacity: .75; } 50% { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .uname-fx-fire, .uname-fx-electric, .uname-fx-ice, .uname-fx-void, .uname-fx-aurora, .uname-fx-constellation,
  .avatar-aura, .avatar-aura::before, .avatar-aura::after, .avatar-frame-dashed::before, .avatar-frame-orbit::after {
    animation: none;
  }
}

/* --- Screen-reader-only utility ---
   No such convention existed anywhere in this codebase before now
   (checked site.css and every PHP file), so this is deliberately a
   general sitewide utility rather than a chat-specific one. The
   clip-path + 1px box technique keeps text in the accessibility tree
   and readable by screen readers while taking no visual space --
   unlike display:none or visibility:hidden, which remove it entirely.
   To restore any label as visible text, delete the class. Nothing
   more. */
.sr-only{position:absolute !important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;}

/* --- Chat widget (includes/chat-widget.php) ---
   There is no button AND window here. There is ONE box that morphs: at
   rest it is a 56px circle showing the chat icon; opened it becomes the
   full panel. The launcher icon and the panel content live inside that
   same box and cross-fade, so nothing ever appears "next to" anything
   else.

   How the diagonal growth actually works, stated precisely because it
   is easy to mis-attribute: the box is pinned right:0/bottom:0 inside a
   zero-size anchor, so animating width and height necessarily extends
   it leftward and upward. transform-origin plays no part in a
   width/height animation -- it would only matter for a scale() approach
   (see the performance note below). Pinning the corner is what produces
   the effect.

   The panel inside is given the OPEN dimensions permanently and is
   positioned out of flow. That matters for more than tidiness: because
   the panel's own size never depends on the container's, growing the
   container does not reflow any of the panel's children. The layout
   cost of the morph is bounded to the box itself, and the content is
   revealed by the container's clip rather than being re-laid-out frame
   by frame. */
.ax-chat-root{position:fixed;right:20px;bottom:20px;z-index:var(--z-dropdown);font-family:var(--body);}

.ax-chat{
  position:absolute;right:0;bottom:0;
  width:56px;height:56px;border-radius:50%;
  background:var(--panel-2);border:1px solid var(--line);
  box-shadow:0 10px 30px rgba(0,0,0,.45);
  overflow:hidden;
  transition:width .30s cubic-bezier(.4,0,.2,1),
             transform .30s cubic-bezier(.4,0,.2,1),
             height .30s cubic-bezier(.4,0,.2,1),
             border-radius .30s cubic-bezier(.4,0,.2,1),
             box-shadow .30s ease,
             border-color .2s ease;
}
.ax-chat:hover{border-color:var(--ion);}
.ax-chat.open{
  width:var(--ax-w);height:var(--ax-h);border-radius:12px;
  border-color:var(--ion-dim);
  box-shadow:0 20px 56px rgba(0,0,0,.6);
}

/* Fills the box at rest so the whole circle is the hit target. Fades
   out as the box opens; the panel header's close control and the
   click-outside handler take over from there. */
.ax-chat-launcher{
  position:absolute;inset:0;z-index:2;
  display:flex;align-items:center;justify-content:center;
  background:none;border:none;color:var(--text);cursor:pointer;
  opacity:1;transition:opacity .14s ease;
}
.ax-chat.open .ax-chat-launcher{opacity:0;pointer-events:none;}
/* Neon red, not the ion blue the rest of the UI uses. Red is the
   universal unread signal, and since the full-width popup was removed
   this badge is now the ONLY notification surface -- so it has to carry
   that job alone and actually pull the eye back.
   Changed 2026-07-26 from a dark core (#1a0509 fill, #ff6b7d numeral) to
   a solid bright fill with a near-black numeral. The dark version was
   reported as not evident at a glance, which for the one unread signal
   the UI has is a functional failure rather than a matter of taste. The
   bright-fill / dark-numeral pairing is not invented here -- it matches
   .ax-chat-row-unread, the sibling count badge in the room list, so the
   two now read as one system instead of two unrelated treatments. The
   text-shadow is gone with it: it existed to make a glowing numeral read
   against a dark core, and on a bright fill it only blurs the digit. */
.ax-chat-badge{
  position:absolute;top:5px;right:5px;z-index:3;
  min-width:19px;height:19px;padding:0 5px;border-radius:9px;
  background:#ff4d5e;color:#2b0006;font-family:var(--mono);
  border:1px solid #ff9aa6;
  box-shadow:0 0 8px rgba(255,77,94,.95), 0 0 20px rgba(255,77,94,.5);
  font-size:.68rem;font-weight:700;
  display:inline-flex;align-items:center;justify-content:center;
  transition:opacity .14s ease;
}
.ax-chat.open .ax-chat-badge{opacity:0;pointer-events:none;}

/* Permanently sized to the OPEN dimensions and pinned to the same
   corner as the box, so the growing box uncovers it rather than
   resizing it. Content fades in slightly behind the box's growth so the
   shape leads and the content follows. */
.ax-chat-panel{
  position:absolute;right:0;bottom:0;
  width:var(--ax-w);height:var(--ax-h);
  display:flex;flex-direction:column;
  opacity:0;visibility:hidden;pointer-events:none;
  transition:opacity .16s ease, visibility 0s linear .16s;
}
.ax-chat.open .ax-chat-panel{
  opacity:1;visibility:visible;pointer-events:auto;
  transition:opacity .20s ease .10s, visibility 0s linear 0s;
}
.ax-chat-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);flex-shrink:0;}
.ax-chat-title{flex:1;font-family:var(--mono);font-size:.74rem;letter-spacing:.08em;text-transform:uppercase;color:var(--star);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ax-chat-back,.ax-chat-close{background:none;border:none;color:var(--text-dimmer);font-size:1.05rem;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px;}
.ax-chat-back:hover,.ax-chat-close:hover{color:var(--star);background:rgba(var(--ion-rgb),.12);}

.ax-chat-list{flex:1;overflow-y:auto;padding:8px;}
.ax-chat-empty{color:var(--text-dimmer);font-size:.82rem;text-align:center;padding:28px 12px;}
/* DM starter picker. Same shape as the site's combobox pattern (input
   + role="listbox" results, click to choose) but namespaced .ax-chat-*
   on purpose: that pattern's CSS is duplicated inline across seven
   pages rather than living here, so reusing its class names inside a
   sitewide widget would let the host page restyle this picker. */
.ax-chat-dmpicker{display:flex;flex-direction:column;gap:8px;margin-bottom:10px;padding:10px;background:rgba(5,7,13,.45);border:1px solid var(--line);border-radius:8px;}
.ax-chat-dmpicker input{padding:8px 10px;background:rgba(5,7,13,.6);border:1px solid var(--line);border-radius:7px;color:var(--text);font-family:var(--body);font-size:.84rem;}
.ax-chat-dmpicker input:focus{outline:none;border-color:var(--ion);}
.ax-chat-dmpicker .ax-chat-more{margin:0;align-self:flex-start;}
.ax-chat-dmresults{list-style:none;margin:0;padding:0;max-height:170px;overflow-y:auto;}
.ax-chat-dmresults li{padding:8px 10px;border-radius:6px;color:var(--text);font-size:.85rem;cursor:pointer;}
.ax-chat-dmresults li:hover{background:rgba(var(--ion-rgb),.14);}
.ax-chat-dmresults li.ax-chat-dmempty{color:var(--text-dimmer);font-family:var(--mono);font-size:.72rem;cursor:default;}
.ax-chat-dmresults li.ax-chat-dmempty:hover{background:none;}

/* Group creation + leave controls (Stage 2). Everything else about a
   group -- messages, polling, moderation, notifications -- is the DM
   path unchanged, so there is nothing else to style. */
.ax-chat-newgroup{display:block;width:100%;margin-bottom:8px;padding:9px 12px;background:none;border:1px dashed var(--line);border-radius:8px;color:var(--text-dimmer);font-family:var(--mono);font-size:.72rem;letter-spacing:.05em;cursor:pointer;}
.ax-chat-newgroup:hover{color:var(--star);border-color:var(--ion);}
.ax-chat-groupform{display:flex;flex-direction:column;gap:8px;margin-bottom:10px;padding:10px;background:rgba(5,7,13,.45);border:1px solid var(--line);border-radius:8px;}
.ax-chat-groupform input{padding:8px 10px;background:rgba(5,7,13,.6);border:1px solid var(--line);border-radius:7px;color:var(--text);font-family:var(--body);font-size:.84rem;}
.ax-chat-groupform input:focus{outline:none;border-color:var(--ion);}
.ax-chat-groupform-actions{display:flex;gap:8px;align-items:center;}
.ax-chat-groupform-actions .ax-chat-send{padding:7px 14px;}
.ax-chat-groupform-actions .ax-chat-more{margin:0;}
.ax-chat-leave{background:none;border:1px solid var(--line);border-radius:6px;color:var(--text-dimmer);font-family:var(--mono);font-size:.66rem;letter-spacing:.05em;cursor:pointer;padding:3px 8px;}
.ax-chat-leave:hover{color:#ff9baa;border-color:#8a2d3b;}
.ax-chat-row{display:flex;align-items:center;gap:10px;width:100%;padding:11px 12px;background:none;border:none;border-radius:8px;color:var(--text);font-family:var(--body);font-size:.88rem;text-align:left;cursor:pointer;}
.ax-chat-row:hover{background:rgba(var(--ion-rgb),.10);}
.ax-chat-row-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ax-chat-row-unread{min-width:19px;height:19px;padding:0 5px;border-radius:9px;background:var(--ion);color:#03060d;font-family:var(--mono);font-size:.66rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center;}
.ax-chat-section-channels .ax-chat-row{cursor:grab;user-select:none;-webkit-touch-callout:none;}

/* Shared drag-reorder (axiomDragReorder in site.js) -- used by the chat
   channel rows and the admin game-card grid. The clone follows the
   pointer via transform only (compositor work, no layout); the in-flow
   original becomes the live drop indicator, reflowing as the pointer
   moves so the landing position is always visible before release. */
.ax-drag-clone{position:fixed;left:0;top:0;margin:0;z-index:100000;pointer-events:none;will-change:transform;opacity:.92;cursor:grabbing;box-shadow:0 14px 34px rgba(0,0,0,.55),0 0 0 1px rgba(var(--ion-rgb),.35);border-radius:8px;overflow:hidden;}
.ax-drag-slot{opacity:.35;outline:2px dashed var(--ion);outline-offset:-2px;border-radius:8px;box-shadow:0 0 12px rgba(var(--ion-rgb),.35) inset;}
.ax-chat-row-hide{background:none;border:none;color:var(--text-dimmer);font-size:1rem;line-height:1;padding:2px 6px;border-radius:4px;cursor:pointer;opacity:0;transition:opacity .15s ease,color .15s ease,background .15s ease;}
.ax-chat-row:hover .ax-chat-row-hide,.ax-chat-row-hide:focus-visible{opacity:1;}
.ax-chat-row-hide:hover{color:#ff9baa;background:rgba(138,45,59,.15);}
.ax-chat-row:has(.ax-chat-row-confirm)>*:not(.ax-chat-row-confirm){display:none;}
.ax-chat-row-confirm{display:flex;align-items:center;gap:8px;width:100%;font-size:.8rem;color:var(--text-dim);}
.ax-chat-row-confirm-yes,.ax-chat-row-confirm-no{font-family:var(--body);font-size:.78rem;font-weight:600;padding:4px 10px;border-radius:6px;border:1px solid var(--line);background:rgba(255,255,255,.03);color:var(--text);cursor:pointer;}
.ax-chat-row-confirm-yes{border-color:#8a2d3b;color:#ff9baa;}
.ax-chat-row-confirm-yes:hover{background:rgba(138,45,59,.18);}
.ax-chat-row-confirm-no:hover{background:rgba(255,255,255,.08);}

.ax-chat-room{flex:1;display:flex;flex-direction:column;min-height:0;}
.ax-chat-more{margin:8px auto 0;padding:5px 12px;background:none;border:1px solid var(--line);border-radius:7px;color:var(--text-dimmer);font-family:var(--mono);font-size:.68rem;cursor:pointer;}
.ax-chat-more:hover{color:var(--star);border-color:var(--ion);}
/* overflow-anchor:none is required, not a tweak. Auto-condense adjusts
   scrollTop itself when a message above the viewport folds. Chromium
   also anchors on its own, so the two stacked and the conversation
   drifted by exactly the height lost -- measured at 19px. Doing it
   manually is still the right half to keep: Safari does not support
   overflow-anchor at all, so leaving it to the browser would mean no
   anchoring there whatsoever. */
/* overscroll-behavior:contain stops scroll chaining -- reaching the end of
   a conversation and continuing to scroll used to start scrolling the page
   behind the widget. Same property and same reasoning as the mobile
   hamburger menu's containment fix. */
.ax-chat-messages{flex:1;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:none;padding:12px;display:flex;flex-direction:column;gap:10px;}
.ax-chat-msg{max-width:82%;padding:8px 11px;border-radius:10px;background:rgba(5,7,13,.5);border:1px solid var(--line-soft);}
.ax-chat-msg.mine{align-self:flex-end;background:rgba(var(--ion-rgb),.14);border-color:var(--ion-dim);}
.ax-chat-msg.held{opacity:.72;border-style:dashed;}
.ax-chat-msg-who{display:block;font-family:var(--mono);font-size:.8rem;letter-spacing:.03em;color:var(--text-dimmer);margin-bottom:4px;}
.ax-chat-msg-header{display:flex;align-items:baseline;gap:6px;margin-bottom:4px;}
.ax-chat-msg-header .ax-chat-msg-who{margin-bottom:0;}
.ax-chat-msg-time{font-family:var(--mono);font-size:.68rem;color:var(--text-dimmer);opacity:.75;white-space:nowrap;}
.ax-chat-msg-body{font-size:.86rem;line-height:1.45;color:var(--text);word-wrap:break-word;overflow-wrap:anywhere;}
.ax-chat-msg-body a{color:var(--star);text-decoration:underline;}
/* Emoji are wrapped in their own span by renderBody() in
   includes/chat-widget.php so they can be sized independently of the text
   around them -- at the body's .86rem they were genuinely too small to
   read comfortably. Sized in em rather than rem so it scales with the
   body instead of fighting it, line-height:1 so a taller glyph does not
   stretch the line box, and the vertical-align nudge keeps it sitting on
   the text baseline rather than riding above it.
   NOT .ax-chat-emoji -- that class is already the composer's emoji-picker
   BUTTON (chat-widget.php, styled further down this file), and an earlier
   pass of this change collided with it. Message emoji are scoped to
   .ax-chat-msg-emoji so the two cannot leak into each other. */
/* position/top rather than vertical-align: a negative vertical-align
   grows the line box by ~1px, which pushed a bare-emoji message one pixel
   past the clamp budget and drew a "Show more" that revealed nothing.
   Relative offset is paint-time only and never affects layout height. */
.ax-chat-msg-emoji{font-size:1.45em;line-height:1;position:relative;top:.14em;}
.ax-chat-msg-note{display:block;margin-top:5px;font-family:var(--mono);font-size:.62rem;color:#ffcf7a;}
.ax-chat-msg-edited{font-family:var(--mono);font-size:.66rem;color:var(--text-dimmer);opacity:.65;}
.ax-chat-msg-tombstone{color:var(--text-dimmer);font-style:italic;}
.ax-chat-msg-editbox{margin-top:2px;}
.ax-chat-msg-editbox textarea{width:100%;min-height:88px;max-height:280px;resize:vertical;padding:8px 10px;background:rgba(5,7,13,.5);border:1px solid var(--line);border-radius:8px;color:var(--text);font-family:var(--body);font-size:.86rem;line-height:1.45;box-sizing:border-box;}
.ax-chat-msg-editrow{display:flex;gap:8px;margin-top:6px;}
.ax-chat-msg-delconfirm{display:flex;align-items:center;gap:8px;margin-top:6px;font-size:.8rem;color:var(--text-dim);}

/* align-items:flex-end, not the flex default of stretch. With stretch,
   the Send button had no height of its own and simply matched the tallest
   item in the row -- invisible while the textarea was a single row, and
   immediately wrong once it could grow to 150px. The button now keeps its
   own height and sits at the TOP of the row, so it holds one fixed spot
   as the textarea grows downward beneath it rather than travelling with
   the bottom edge. */
/* Two stacked rows: the input alone, then the action row. Was a single
   horizontal row with the GIF/emoji buttons absolutely positioned INSIDE
   the textarea and 124px of right padding reserved to clear them, which
   cost the input roughly half its usable width on a phone and sat the
   controls on top of the placeholder. */
/* Column, so Send becomes a full-width bar beneath the input instead of
   a fixed slot beside it. That single change is what gives the input its
   width back -- it no longer shares a horizontal row with anything. */
.ax-chat-composer{display:flex;flex-direction:column;gap:8px;padding:10px;border-top:1px solid var(--line);flex-shrink:0;}
/* Scoped to the composer rather than set on .ax-chat-send itself, so the
   group-form variant further up keeps its own smaller padding-based size.
   44px, not the previous 40px: this is the site's touch-target standard,
   and Send is now a full-width row rather than a narrow slot beside the
   input. Kept as the single source for the composer's Send height --
   the .ax-chat-composer-actions rule above deliberately does NOT set it,
   because two equal-specificity rules setting the same property is how
   the value silently depends on source order. */
.ax-chat-composer .ax-chat-send{min-height:44px;}
/* padding-right still reserves a gutter for the two in-field icons, but
   76px rather than the old 124px -- the icons shrank from 48/44 to 32px
   each. Combined with Send moving out of this row, usable text width at
   390px goes from roughly 122px to roughly 240px. Auto-expand geometry
   (58px start, 150px cap, internal scroll beyond) is deliberately
   unchanged -- the growth behaviour was always correct, only the width
   was wrong. */
.ax-chat-composer textarea{flex:1;resize:none;padding:9px 76px 9px 11px;background:rgba(5,7,13,.5);border:1px solid var(--line);border-radius:8px;color:var(--text);font-family:var(--body);font-size:.86rem;line-height:1.4;min-height:58px;max-height:150px;overflow-y:auto;scrollbar-gutter:stable;}
.ax-chat-composer textarea:focus{outline:none;border-color:var(--ion);}
/* Matches .btn-primary's own treatment (same --ion gradient, same lift
   and glow on hover) rather than being a bare blue rectangle -- it is a
   primary action and should read like every other primary action on the
   site. The letter-spacing and weight give it presence at full width
   without needing extra height. */
.ax-chat-send{padding:0 15px;background:linear-gradient(135deg,var(--ion),#6ea6ff);border:none;border-radius:8px;color:#03060d;font-family:var(--body);font-weight:600;font-size:.86rem;letter-spacing:.01em;cursor:pointer;transition:box-shadow .2s ease,transform .2s ease,filter .2s ease;}
.ax-chat-composer .ax-chat-send:hover{box-shadow:0 6px 20px rgba(var(--ion-rgb),.32);transform:translateY(-1px);}
.ax-chat-composer .ax-chat-send:active{transform:translateY(0);filter:brightness(.95);}
.ax-chat-composer .ax-chat-send:focus-visible{outline:2px solid var(--star);outline-offset:2px;}
/* The full-width Send bar is right for a phone and right for the compact
   box, where it is what makes the primary action unmissable. At 660px
   expanded it stops being a button and starts being a banner, so it is
   capped and right-aligned THERE ONLY. Scoped to min-width:721px as well
   as .expanded: the expanded class survives a desktop-to-mobile resize
   (the mobile block neutralises its geometry but does not remove it), so
   the class alone is not enough to keep this off a phone.
   align-self is what overrides the column flex container's default
   stretch; the explicit width, rather than max-width, is because
   shrink-to-fit would land near 140px on this label and the target is a
   deliberate ~200px. */
@media(min-width:721px){
  .ax-chat.expanded .ax-chat-composer .ax-chat-send{width:200px;max-width:100%;align-self:flex-end;}
}
.ax-chat-inputwrap{position:relative;flex:1;display:flex;min-width:0;}
/* right:16px, not 8px: the composer textarea scrolls now
   (overflow-y:auto), so a native scrollbar renders at its right inner
   edge -- exactly where this button used to sit, leaving the icon
   half-buried behind it. scrollbar-gutter:stable on the textarea reserves
   that strip whether or not it is currently scrolling, so the button
   holds one position instead of appearing to shift when a message grows
   long enough to overflow. The textarea's right padding widened in step
   with this so typed text never runs underneath the icon.
   24px, not 16px: the scrollbar measured 15px wide here, so 16px left a
   single pixel of clearance -- true by accident rather than by design,
   and it would overlap again on any platform with a classic 17px
   scrollbar. 24px clears the measured width with room to spare and still
   clears the wider case. */
/* Grown to match the GIF button's height (44px) rather than staying
   26 -- both now share the exact same bottom:5px/height:44px, which is
   what actually locks their vertical centers together (the previous
   bottom:10/height:26 vs GIF's bottom:5/height:44 put the two centers
   4px apart, which read as the emoji button sitting low -- correct,
   confirmed by direct measurement, not just appearance). right:24px is
   unchanged: that value clears the composer's own scrollbar and has
   nothing to do with this button's height. */
/* Pinned to the BOTTOM-right inside the field, so they travel with the
   bottom edge as the textarea auto-expands and stay under the thumb that
   is already there. 32px rather than the old 44/48: still at the site's
   minimum touch target, but small enough to stop competing with the text
   being typed. Faint at rest, full strength on hover/focus -- present
   without demanding attention. */
.ax-chat-emoji{position:absolute;right:10px;bottom:8px;width:32px;height:32px;padding:0;justify-content:center;flex-shrink:0;opacity:.5;transition:opacity .15s ease;}
.ax-chat-gif:hover,.ax-chat-emoji:hover,
.ax-chat-gif:focus-visible,.ax-chat-emoji:focus-visible{opacity:1;}
.ax-chat-inputwrap:focus-within .ax-chat-gif,
.ax-chat-inputwrap:focus-within .ax-chat-emoji{opacity:.85;}
/* Sits immediately left of the emoji button (which spans right:24-50px),
   with a 4px gap -- the GIF button was added to the DOM but never given
   this positioning rule, so it fell into the flex row's normal document
   flow instead of overlaying the textarea's corner like its sibling.
   Caught live: rect measured position:static, 58px tall (the full input
   height), pushed in beside the textarea rather than floating over it. */
/* A real, meaningfully bigger button this time, not an incremental
   bump -- the previous 34x26 (884px^2) technically exceeded the emoji
   button's 26x26 (676px^2) but did not read as legible at true, unzoomed
   page scale (confirmed live: a full-page screenshot, not a zoomed
   crop, is what actually caught this). 48x36 = 1728px^2, roughly 2.5x
   the emoji button's area -- three letters of text need real width AND
   height to read clearly, not just a slightly wider frame at the same
   cramped height. Positioned to keep the same vertical center as the
   emoji button (23px up from the container's bottom edge) despite the
   taller box, with an 8px real gap to its left edge. */
/* Gap to the emoji button tightened from 8px to 4px on request --
   8px read as uncomfortably wide between two related controls in the
   same corner cluster. */
/* Taller (36 -> 44) using real headroom confirmed still available in
   the 58px-tall textarea, and the gap to the emoji button tightened
   again (4px -> 2px) on request. */
/* right offset recalculated for the emoji button's new, wider 44px
   footprint (was 26px) -- moving in to a tight 1px gap between the two,
   down from 2px. bottom/height unchanged; these already match the
   emoji button's new values exactly, which is what keeps both centers
   locked together. */
.ax-chat-gif{position:absolute;right:46px;bottom:8px;width:32px;height:32px;padding:0;justify-content:center;flex-shrink:0;opacity:.5;transition:opacity .15s ease;}
.ax-chat-gif svg{width:30px;height:24px;}
/* The inline SVG carries its own width/height attributes, so the icon is
   scaled here instead of by editing the markup -- keeping the button's
   size a single decision made in one place. */
/* Icon graphic reduced within the SAME 44x44 button box (button size
   itself unchanged) so its drawn outline actually matches the GIF
   frame's -- measured live: at 34x34 the circle rendered 25.5px top to
   bottom against the GIF rect's 18.4px, a real 7.1px gap despite equal
   button and svg boxes, because the two icons were drawn with different
   amounts of padding inside their own viewBox from the start. 24.5px
   scales the circle to ~18.4px (24.5 * .75, the circle's fixed
   diameter-to-viewBox ratio), matching the GIF frame's measured height
   directly rather than guessing at a rounder number. */
.ax-chat-emoji svg{width:22px;height:22px;}
.ax-chat-error{margin:0;padding:8px 12px;color:#ff9baa;font-size:.78rem;border-top:1px solid var(--line);}

/* Genuinely shown/hidden bits (back button, composer, load-more, error,
   badge) still use the hidden attribute, which needs this override
   because [hidden] is only display:none in the UA sheet and any author
   display: rule beats it. The morphing box and its panel deliberately
   do not participate -- their state is width/height/opacity only. */
.ax-chat [hidden], .ax-chat[hidden]{display:none !important;}

/* The new-message bubble lives OUTSIDE the morphing box, as a sibling
   inside .ax-chat-root -- the box has overflow:hidden to clip its own
   morph, which would otherwise clip the bubble too. Keeping it in the
   root means the click-outside check still treats it as "inside the
   widget". */
.ax-chat-bubble{display:none;}
@media(max-width:720px){
  /* Full-width zero-height bar rather than a corner point. This is
     what makes the open sheet's margins symmetric: sizing it from
     100vw instead measured left:2px against right:12px, because
     100vw includes the scrollbar width while the anchored right edge
     does not. Sizing from the root sidesteps that entirely and is
     scrollbar-width independent. */
  .ax-chat-root{left:12px;right:12px;bottom:12px;}

  /* Mobile keeps the SAME morph -- one box, corner circle to sheet --
     rather than reverting to a separate panel. The bottom-sheet
     character built and verified earlier is preserved: bottom-anchored,
     near-full-width, ~460px tall, deliberately never full-screen. Two
     deliberate differences from desktop, both consequences of the box
     now being the launcher rather than sitting above it:
       - width grows to the viewport rather than a fixed 360px
       - it is anchored at bottom:12px, not 74px, because there is no
         longer a separate launcher underneath to leave room for
     Everything animated stays animatable (width/height/radius) -- the
     position property itself is never switched mid-transition, which
     would jump. */

  /* no longer used -- see the JS comment on maybeShowBubble(). Desktop's
   base rule (display:none) already matched what was actually wanted:
   a small numbered blip on the launcher icon (.ax-chat-badge), not a
   second, separate floating card. Confirmed live on a real phone that
   the full-width version read as messy rather than clean, so mobile
   now matches desktop instead of being the odd one out. */
  .ax-chat-bubble[hidden]{display:none;}
  .ax-chat-bubble-body{flex:1;min-width:0;}
  .ax-chat-bubble-from{display:block;font-family:var(--mono);font-size:.68rem;letter-spacing:.05em;color:var(--star);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .ax-chat-bubble-text{display:block;font-size:.8rem;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .ax-chat-bubble-x{background:none;border:none;color:var(--text-dimmer);font-size:1.15rem;line-height:1;cursor:pointer;padding:0 2px;}
}

/* Motion is decoration here, never information -- the widget still
   opens and closes identically with it suppressed. */
@media(prefers-reduced-motion:reduce){
  .ax-chat,.ax-chat.open,.ax-chat-panel,.ax-chat.open .ax-chat-panel,
  .ax-chat-launcher,.ax-chat-badge{transition:none;}
}

/* --- Shared emoji picker ---
   Extracted from feed.php so the Feed composer, the Feed comment
   inputs and the sitewide chat composer all use ONE picker rather
   than a second copy being written for chat. Feed's markup and
   behaviour are unchanged -- only the definition moved. */
.emoji-picker { position: fixed; z-index: var(--z-dropdown); background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 10px; box-shadow: 0 12px 36px rgba(0,0,0,.55); width: 250px; max-height: 260px; overflow-y: auto; }
.emoji-picker-label { font-family: var(--mono); font-size: .62rem; letter-spacing: .05em; text-transform: uppercase; color: var(--text-dimmer); margin: 8px 0 4px; }
.emoji-picker-label:first-child { margin-top: 0; }
.emoji-picker-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.emoji-picker-grid button { background: none; border: none; cursor: pointer; font-size: 1.25rem; padding: 4px; border-radius: 6px; line-height: 1; }
.emoji-picker-grid button:hover { background: rgba(var(--ion-rgb),.15); }

/* --- Shared Klipy GIF picker (chat + Feed composer + Feed reply) --- */
.gif-trigger-btn { background: none; border: none; color: var(--text-dimmer); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: color .2s; padding: 0; }
.gif-trigger-btn:hover { color: var(--star); }
/* max-height sized for exactly 4 FULL grid-auto-rows (89px each) to
   read clearly, not a partial 4th row implying content is about to
   scroll into view mid-row -- measured live: 430px produced only 375px
   of grid area against the 394px four full rows actually need (19px
   short), which is why row 4 was visibly cut off at the panel's own
   edge rather than fully shown. 455px gives the needed 394px plus a
   few px of real margin, confirmed against the box's own height with
   room to spare (box is 580px tall; even at this size the panel keeps
   real clearance before it, not a knife's-edge fit). */
.klipy-panel { position: fixed; z-index: var(--z-dropdown); background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 36px rgba(0,0,0,.55); width: 300px; max-height: 455px; display: flex; flex-direction: column; overflow: hidden; }
.klipy-panel-head { display: flex; align-items: center; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.klipy-search { flex: 1; min-width: 0; background: rgba(5,7,13,.5); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; color: var(--text); font-family: var(--body); font-size: .82rem; }
.klipy-search:focus { outline: none; border-color: var(--ion); }
/* Static, non-interactive, always visible while the panel is open --
   Klipy's own "Logo in picker/selector" requirement (attribution
   guidelines, page 5). Small and quiet by design: it sits in the same
   header row as the search field rather than as a separate banner. */
.klipy-credit { height: 14px; width: auto; flex-shrink: 0; opacity: .9; }
/* grid-auto-rows explicit, not left to size from aspect-ratio alone: a
   scrollable CSS Grid (flex:1 inside a max-height panel, overflow-y:auto)
   compressed its implicit rows to fit the visible area instead of sizing
   each row from its own aspect-ratio-square content and letting the
   extra rows overflow into the scrollable area -- measured live, real
   bug: 8 rows of individually-84x84 tiles packed into row tops only
   ~44px apart, meaning every row was overlapping the next by roughly
   half its height, invisible at a glance but real (scrollHeight only
   36px past clientHeight, where 8 genuine 90px rows would need ~360px
   more). Column width here is (300 panel - 20 padding - 12 gaps) / 3 =
   89.3px; grid-auto-rows fixes each row to that same figure so rows are
   genuinely square and never compress regardless of how many exist. */
.klipy-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 89px; gap: 6px; padding: 10px; }
.klipy-thumb { position: relative; aspect-ratio: 1; border: none; border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; background: rgba(5,7,13,.5); }
.klipy-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.klipy-thumb:hover { outline: 2px solid var(--ion); outline-offset: -2px; }
.klipy-loading, .klipy-empty { grid-column: 1 / -1; text-align: center; color: var(--text-dimmer); font-size: .78rem; padding: 30px 10px; }

/* Rendered GIF media -- shared by chat messages and Feed posts/comments
   (axiomRenderGif() in site.js). Fixed max-width rather than filling the
   container: a GIF should read as one piece of media at a predictable
   size, not stretch to whatever column happens to hold it. */
/* width:340px (a definite length) with max-width:100%, NOT max-width:340px
   with width:100%. The distinction is load-bearing, not stylistic.
   .ax-chat-msg.mine sets align-self:flex-end, which makes an own-message
   bubble SHRINK-TO-FIT its content rather than stretch. With width:100%
   the wrapper therefore sized against a bubble whose own width depended on
   the video's intrinsic dimensions -- i.e. the width was async too, so
   aspect-ratio dutifully reserved a height derived from a width that was
   not yet known. Measured live: the wrapper inserted ~156px wide and grew
   to 288px once the video loaded, taking its height from 88px to 162px and
   leaving the auto-scroll 74px short. A definite width resolves both
   dimensions at insert, before any media has loaded. */
.axiom-gif-wrap { position: relative; display: inline-block; width: 340px; max-width: 100%; border-radius: 10px; overflow: hidden; margin-top: 6px; }
/* .axiom-gif-media, NOT a bare "video, img" descendant selector -- that
   selector's specificity (0,1,1: one class + one type) beat the
   watermark's own .axiom-gif-watermark rule (0,1,0) regardless of source
   order, so the watermark <img> was ALSO being caught by this rule and
   forced to width:100% -- measured live: a 300x103 watermark asset
   rendered at 143x49 (100% of the card) instead of the small corner mark
   it was meant to be. Scoping to a real class this element and only this
   element carries is what actually fixes it, not a specificity arms race. */
.axiom-gif-media { display: block; width: 100%; height: auto; border-radius: inherit; }
/* Bottom-left, semi-transparent -- exactly Klipy's own "Watermark on
   Sent Card" placement (attribution guidelines, page 4). The asset
   itself is already a light, semi-transparent mark by design; the
   slight opacity reduction here is an extra margin toward "minimal
   distraction", not a substitute for the asset's own transparency. */
.axiom-gif-watermark { position: absolute; left: 8px; bottom: 8px; height: 14px; width: auto; opacity: .85; pointer-events: none; }

.emoji-trigger-btn { background: none; border: none; color: var(--text-dimmer); cursor: pointer; display: flex; align-items: center; transition: color .2s; padding: 0; }
.emoji-trigger-btn:hover { color: var(--star); }

/* --- Chat: three states (circle / compact / expanded) --- */
/* Sizes live ONLY here. .ax-chat.open and .ax-chat-panel both read these,
   so a state's dimensions are declared once rather than in two places
   that can drift apart. --ax-clamp travels with them because how many
   lines of a message should show before truncating is a property of the
   panel size, not of the message. */
/* Compact grew from 460x580 to 520x700 on 2026-07-27. Reported from real
   use: "in just 3 messages I can't see your messages, that's low
   headspace." Opening the widget is a deliberate act, so the compact
   state should have enough presence to hold a conversation rather than
   the last three lines of one. The height is capped against the real
   layout viewport so a short laptop screen (a 768px-tall display leaves
   roughly 600px of content area) cannot push the box off the top -- the
   box is anchored bottom-right, so overflow escapes upward, taking the
   header's close/expand controls with it. 40px = the 20px bottom anchor
   plus 20px of clearance above. */
.ax-chat{--ax-w:520px;--ax-h:min(700px, calc(var(--ax-vh, 100vh) - 40px));--ax-clamp:4;--ax-gif-max-w:300px;--ax-gif-max-h:240px;}

/* Expanded: 720 wide, up to 780 tall, centred. The box stays anchored
   bottom-right (that anchor is what the whole morph is built on), and a
   translate carries it to the middle instead. Safe to transform because
   the emoji picker is appended to <body>, not inside the widget -- a
   transformed ancestor would otherwise become the containing block for
   its position:fixed and throw it across the screen. Any future popover
   in here must also be body-appended. */
.ax-chat.open.expanded{
  /* Bigger than compact in BOTH dimensions (720x780 vs 520x700).
     Previously expanded was deliberately wider-but-shorter, which was
     defensible when compact was 580 tall -- but once compact grew to
     700, that rule meant clicking "expand" handed back FEWER visible
     messages than the state you left. Measured: 5 visible expanded
     against 6 compact. An affordance whose whole promise is "more room"
     must never subtract any. */
  --ax-w:720px;--ax-h:min(780px, calc(var(--ax-vh, 100vh) - 40px));--ax-clamp:8;--ax-gif-max-w:380px;--ax-gif-max-h:260px;
  /* Centred from the layout viewport, NOT from vw. 100vw includes the
     scrollbar while the right:20px anchor is measured against the
     containing block, which does not -- so a vw-based centre lands
     off by exactly the scrollbar width (measured: 10px here, and
     wider on platforms with classic scrollbars). --ax-vw/--ax-vh are
     set from documentElement.clientWidth/Height and kept current on
     resize; the vw/vh fallbacks only apply for the first frame before
     that runs. This is the same scrollbar-asymmetry trap already
     documented on the mobile sheet above.

     Derived from --ax-w/--ax-h rather than from literals. The box's
     centre sits at (vw - 20 - w/2, vh - 20 - h/2); moving that to
     (vw/2, vh/2) gives the offsets below. It used to be two hardcoded
     numbers, which silently coupled the centring to the size -- change
     one without the other and the window sits off-centre with no
     warning. Deriving it is also what makes the viewport-capped min()
     height above safe: the transform now follows whatever the height
     actually resolves to, so a short laptop screen shrinks the window
     and keeps it centred instead of pushing it off the top. */
  transform:translate(
    calc(20px + var(--ax-w) / 2 - var(--ax-vw, 100vw) / 2),
    calc(20px + var(--ax-h) / 2 - var(--ax-vh, 100vh) / 2));
}

.ax-chat-expand{background:none;border:none;color:var(--text-dimmer);cursor:pointer;padding:2px 6px;border-radius:6px;display:inline-flex;align-items:center;}
.ax-chat-expand:hover{color:var(--star);background:rgba(var(--ion-rgb),.12);}
/* One button, two icons: arrows out while compact, arrows in while
   expanded, so the control always shows what it will DO next. */
.ax-chat-expand .ax-ico-in{display:none;}
.ax-chat-expand.is-expanded .ax-ico-out{display:none;}
.ax-chat-expand.is-expanded .ax-ico-in{display:inline;}

/* Long messages: clamped by rendered LINES, never character count -- the
   same text wraps differently at 360px, at 600px and on a phone, so a
   character threshold would truncate inconsistently between states. */
.ax-chat-msg-body.ax-clamped{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:var(--ax-clamp,4);overflow:hidden;}
.ax-chat-showmore{display:block;margin-top:5px;background:none;border:none;padding:0;color:var(--star);font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;cursor:pointer;}
.ax-chat-showmore:hover{text-decoration:underline;}

@media(max-width:720px){
  /* The bottom sheet already spans the viewport, so there is nothing for
     an expanded state to add -- and 600px centred would be SMALLER than
     the sheet on most phones. The class is neutralised rather than just
     hiding its button, because a user who expands on desktop and then
     narrows the window carries .expanded across the breakpoint. */
  /* Raised from min(70vh,460px) to min(82vh,620px) on 2026-07-27. At 460
     a real phone showed three or four messages -- reported as "too
     narrow and unwelcoming" with most of the screen sitting empty above
     the sheet. 620 puts six to eight short messages on screen.
     Deliberately still not full height: 82vh leaves the site header
     visible above the sheet, which is what keeps it reading as a sheet
     over the page rather than a separate screen.
     vh here is Chrome Android's LARGE viewport (toolbar hidden), which
     is taller than what is actually visible while the toolbar shows --
     measured on the reporting device: 768 large vs 712 visible. 82vh of
     768 is 630, capped by the 620 literal, still 92px inside the visible
     area. That headroom is the reason for the cap, and why this is not
     set higher. */
  .ax-chat{--ax-w:100%;--ax-h:min(82vh,620px);--ax-clamp:5;--ax-gif-max-w:260px;--ax-gif-max-h:220px;}
  /* Explicit, concrete sizing for the open sheet, duplicating --ax-w and
     --ax-h directly above. Kept, but the reason recorded here previously
     was wrong and is corrected rather than deleted so nobody re-derives
     it: this used to say the base .ax-chat.open{width:var(--ax-w)} "does
     not take effect" at this breakpoint, evidenced by the element
     computing 56px wide with .open present. That measurement was an
     artifact. .ax-chat transitions width/height/transform, and
     getComputedStyle / getBoundingClientRect read the transition's
     CURRENT value -- under browser automation those transitions are
     routinely still sitting at their start value when read, so the box
     reports the 56px circle while the rendered frame is already the full
     sheet. Reproduced deliberately on 2026-07-27 on DESKTOP, where the
     custom-property path was never in doubt: the box read 56x56 and
     transform read identity, while a screenshot of the same moment
     showed 520x700, correctly centred. Descendants (.ax-chat-panel,
     .ax-chat-messages) are not transitioned and read true.
     So: measure this widget from a screenshot, or inject
     transition:none first -- never from a rect on .ax-chat itself. The
     literals stay because they are harmless and match the custom
     properties exactly; they are just not load-bearing in the way the
     old comment claimed. */
  .ax-chat.open{width:100%;height:min(82vh,620px);border-radius:14px 14px 0 0;}
  .ax-chat.open.expanded{--ax-w:100%;--ax-h:min(82vh,620px);transform:none;}
  .ax-chat-expand{display:none;}

  /* Open, the sheet goes edge to edge; closed, the launcher circle keeps
     its 12px inset off the screen corner. Both states are the same
     element, so the inset has to move on the ROOT rather than the box --
     :has() is the only way to reach an ancestor from the .open class,
     and it is already used elsewhere in this file. Two things come out
     of this at once, and both were asked for: the 12px side margins
     become 24px of extra message width on a 390px screen, and the 12px
     under the sheet stops being dead space between the Send button and
     the top of the keyboard. Transitioned on the same curve and duration
     as the box's own morph so the inset travels WITH the growth instead
     of snapping at frame zero. Bottom corners square because a rounded
     corner against the screen edge reads as a mistake. */
  .ax-chat-root{
    transition:left .30s cubic-bezier(.4,0,.2,1),
               right .30s cubic-bezier(.4,0,.2,1),
               bottom .30s cubic-bezier(.4,0,.2,1);
  }
  .ax-chat-root:has(.ax-chat.open){left:0;right:0;bottom:0;}

  /* Every pixel reclaimed here becomes message space, so the chrome
     around the composer is tighter on a phone than on desktop. Measured
     from the reporting device's own screenshot, the run from the last
     message down to the keyboard was 12 (root inset) + 10 (composer
     padding-bottom) below Send, and 12 (message padding-bottom) + 1
     (border) + 10 (composer padding-top) above the input. Now 5 below
     and 10 above, with the root inset gone entirely. Desktop keeps the
     roomier padding -- it has the space, and the same tightening there
     would only look cramped. */
  .ax-chat-messages{padding:10px 10px 4px;gap:8px;}
  .ax-chat-composer{padding:5px 8px;gap:6px;}

  /* Density, not decoration. A phone bubble measured 116px for a
     received message -- 20 header, 40 body (two wrapped lines), 32
     reaction control, 16 actions -- so message count is governed by
     per-bubble chrome far more than by sheet height. 90% instead of 82%
     buys 27px of line width, which is often the difference between a
     two-line and a one-line body; the trimmed padding takes 4px off
     every bubble. Both are worth having and neither touches the 32px
     reaction target, which is a deliberate touch-size standard and not
     something to claw back for space. */
  .ax-chat-msg{max-width:90%;padding:6px 10px;}
}

/* --- Chat: freely positioned and resized expanded window (desktop only) ---
   Scoped to min-width:721px, the exact mirror of the mobile breakpoint
   below. On a phone the widget is a full-width bottom sheet with nowhere
   to drag it to, so the handles are never rendered AND the pointer
   handlers bail out before doing anything -- two independent gates,
   because either one alone would leave the other reachable.

   .ax-chat-free swaps the bottom-right anchor for explicit left/top and
   drops the centring transform. That transform is only ever an INITIAL
   placement for the expanded state; once the window has been moved by
   hand its real position is wherever the user put it, and the transform
   would fight that. Dropping it is also strictly safer than adding one --
   see the note on .ax-chat.open.expanded above about a transformed
   ancestor becoming the containing block for position:fixed descendants
   (the emoji picker). Removing it can only un-break that, never break it. */
@media(min-width:721px){
  .ax-chat-root.ax-chat-free{right:auto;bottom:auto;}
  /* The box is normally anchored right:0/bottom:0 INSIDE the root, so it
     hangs up-and-left from the root's origin. That is correct for a
     bottom-right widget that grows as it opens, but once the root is
     driven by left/top it puts the box's BOTTOM-RIGHT corner at the
     drag point and the window lands entirely off screen (measured: a
     drag to 0,0 rendered the box at -600,-500). While free, the box
     anchors top-left instead, so root left/top and the box's visible
     top-left are the same point. */
  .ax-chat-root.ax-chat-free .ax-chat{right:auto;bottom:auto;left:0;top:0;}
  .ax-chat-root.ax-chat-free .ax-chat.open.expanded{transform:none;}
  /* The width/height/transform transitions exist to animate the MORPH.
     While a pointer is actually dragging, they turn every frame into a
     300ms chase and the window visibly lags the cursor. */
  .ax-chat-root.ax-chat-live .ax-chat,
  .ax-chat-root.ax-chat-live .ax-chat-panel{transition:none;}
  .ax-chat.open.expanded .ax-chat-head{cursor:grab;}
  .ax-chat-root.ax-chat-live .ax-chat-head{cursor:grabbing;}

  /* Handles sit INSIDE the box: .ax-chat has overflow:hidden, so anything
     hung past its edge would simply be clipped away. Transparent by
     design -- the cursor change is the affordance, matching how the rest
     of this widget signals interactivity. */
  .ax-chat-rsz{position:absolute;z-index:6;touch-action:none;display:none;}
  .ax-chat.open.expanded .ax-chat-rsz{display:block;}
  .ax-chat-rsz-n{top:0;left:12px;right:12px;height:5px;cursor:ns-resize;}
  .ax-chat-rsz-s{bottom:0;left:12px;right:12px;height:5px;cursor:ns-resize;}
  .ax-chat-rsz-w{left:0;top:12px;bottom:12px;width:5px;cursor:ew-resize;}
  .ax-chat-rsz-e{right:0;top:12px;bottom:12px;width:5px;cursor:ew-resize;}
  .ax-chat-rsz-nw{top:0;left:0;width:14px;height:14px;cursor:nwse-resize;}
  .ax-chat-rsz-se{bottom:0;right:0;width:14px;height:14px;cursor:nwse-resize;}
  .ax-chat-rsz-ne{top:0;right:0;width:14px;height:14px;cursor:nesw-resize;}
  .ax-chat-rsz-sw{bottom:0;left:0;width:14px;height:14px;cursor:nesw-resize;}
}
@media(max-width:720px){
  /* Belt and braces for the user who drags the window on desktop and then
     narrows the browser past the breakpoint. JS resets the geometry on
     resize too; this makes the bottom sheet correct even for the frame
     before that runs. !important is required rather than tidy: the values
     it is overriding are inline styles set by the drag handler. */
  .ax-chat-root.ax-chat-free{right:20px;bottom:20px;left:auto !important;top:auto !important;}
  .ax-chat-rsz{display:none !important;}
}

@media(prefers-reduced-motion:reduce){
  /* Already covered by .ax-chat above, since the transform transition is
     declared there -- stated explicitly so it survives anyone later
     giving the expanded state a transition of its own. A 600px slide
     across the viewport is the most motion-sensitive thing here. */
  .ax-chat.open.expanded{transition:none;}
}

/* --- Chat: blocking, reporting, moderation (Stage 4) --- */
/* Every control below removes or replaces its own DOM node when used,
   which is exactly the shape that made "Show more" close the whole
   widget: once the node is detached, the outside-click handler's
   .closest('.ax-chat-root') returns null and the click reads as outside.
   All of them stopPropagation, and the outside handler additionally
   ignores detached targets. */
.ax-chat-msg{position:relative;}
.ax-chat-msgactions{position:absolute;top:4px;right:5px;opacity:0;transition:opacity .12s ease;
  background:none;border:none;color:var(--text-dimmer);cursor:pointer;padding:0 3px;line-height:1;font-size:1rem;border-radius:4px;}
.ax-chat-msg:hover .ax-chat-msgactions,.ax-chat-msgactions:focus-visible{opacity:1;}
.ax-chat-msgactions:hover{color:var(--star);background:rgba(var(--ion-rgb),.14);}
.ax-chat-msgmenu{position:absolute;top:20px;right:5px;z-index:2;min-width:132px;
  background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:4px;
  box-shadow:0 10px 26px rgba(0,0,0,.5);display:flex;flex-direction:column;gap:2px;}
.ax-chat-msgmenu button{background:none;border:none;text-align:left;cursor:pointer;
  color:var(--text);font-family:var(--body);font-size:.78rem;padding:6px 8px;border-radius:5px;}
.ax-chat-msgmenu button:hover{background:rgba(var(--ion-rgb),.16);color:var(--star);}
.ax-chat-msgmenu button.danger:hover{background:rgba(255,110,110,.16);color:#ff9c9c;}

/* Collapsed, never removed: the message keeps its place in the
   conversation so the reader can see that something was said and open it
   if they choose. Other people in the room are unaffected. */
.ax-chat-msg.from-blocked .ax-chat-msg-body,
.ax-chat-msg.from-blocked .ax-chat-showmore{display:none;}
.ax-chat-msg.from-blocked.revealed .ax-chat-msg-body{display:block;}
.ax-chat-collapsed{display:block;font-family:var(--mono);font-size:.7rem;color:var(--text-dimmer);font-style:italic;}
.ax-chat-msg.from-blocked.revealed .ax-chat-collapsed-label{display:none;}
.ax-chat-msg.from-blocked.revealed .ax-chat-reveal{margin-left:0;}
.ax-chat-reveal{background:none;border:none;padding:0;margin-left:6px;color:var(--star);
  font-family:var(--mono);font-size:.68rem;cursor:pointer;text-decoration:underline;}

/* Read-only composer. Says plainly that nothing can be sent -- a
   composer that silently swallows messages is worse than one that
   admits it is closed. */
.ax-chat-readonly{display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);
  font-size:.78rem;color:var(--text-dim);flex-shrink:0;}
.ax-chat-readonly .ax-chat-unblock{background:none;border:1px solid var(--line);border-radius:6px;
  color:var(--star);font-family:var(--mono);font-size:.68rem;padding:4px 8px;cursor:pointer;white-space:nowrap;}
.ax-chat-readonly .ax-chat-unblock:hover{border-color:var(--ion);background:rgba(var(--ion-rgb),.12);}

/* --- Chat room list: two sections --- */
.ax-chat-section{display:flex;flex-direction:column;margin-top:10px;}
.ax-chat-section:first-of-type{margin-top:4px;}
.ax-chat-section-title{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;
  color:var(--text-dimmer);padding:0 4px 5px;border-bottom:1px solid var(--line-soft);margin-bottom:4px;}
.ax-chat-section-rows{display:flex;flex-direction:column;}
/* Open Channels can never crowd out the conversations above it. Capped
   from --ax-h rather than a percentage: the panel's height is the real
   constraint, it is already declared once per state, and a length
   sidesteps percentage resolution against a flex item entirely. Scrolls
   internally instead of pushing Conversations off-screen -- nothing is
   hidden, it just stops growing. Worst realistic case is the 5-game
   switcher cap plus however many communities someone belongs to. */
.ax-chat-section-channels .ax-chat-section-rows{max-height:calc(var(--ax-h) * 0.4);overflow-y:auto;}



/* ===================== Shared lightbox =====================
   Moved out of assets/catalogue-components.css, which is loaded by exactly
   one page, so Feed and chat could stop having their own. Unchanged rules
   apart from the media element selector, which now also covers <video> so a
   GIF can open at full size. */
.lightbox-overlay{
  position:fixed;
  inset:0;
  z-index:var(--z-modal);
  background:rgba(2,3,7,0.95);
  display:flex;
  align-items:center;
  justify-content:center;
  overflow:hidden;
}
.lightbox-overlay[hidden]{display:none;}
.lightbox-stage{
  width:100%;
  height:100%;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:14px;
  padding:0 14px;
  box-sizing:border-box;
  overflow:hidden;
  cursor:grab;
  touch-action:none;
}
.lightbox-stage.dragging{cursor:grabbing;}
/* The subtraction reserves room for both arrows, their gaps and the
   stage padding, so the image can never grow into the space the arrows
   need. Without it the image would push them off-screen at wide aspect
   ratios. */
.lightbox-stage img,.lightbox-stage video{
  max-width:min(90vw, calc(100vw - 160px));
  max-height:85vh;
  user-select:none;
  -webkit-user-drag:none;
  will-change:transform;
}
.lightbox-close{
  position:absolute;
  top:20px;right:20px;
  z-index:2;
  width:40px;height:40px;
  display:flex;
  align-items:center;
  justify-content:center;
  background:rgba(5,7,13,0.7);
  border:1px solid var(--line);
  border-radius:50%;
  color:var(--text);
  font-size:22px;
  line-height:1;
  cursor:pointer;
  transition:all .2s ease;
}
.lightbox-close:hover{border-color:var(--ion);color:var(--star);}
/* Gallery navigation. Hidden unless the lightbox was opened with more
   than one item, so a single-image lightbox looks exactly as it did
   before galleries existed. */
/* Flex siblings of the image inside the stage, NOT pinned to the
   viewport edges -- so they sit against the image's own outer edges
   whatever its size or aspect ratio, with no measuring and nothing to
   recompute on resize or when a new image loads at a different size. */
.lightbox-nav{
  flex:0 0 auto;
  display:none;
  align-items:center;
  justify-content:center;
  width:46px;height:46px;
  background:rgba(5,7,13,0.72);
  border:1px solid var(--line);
  border-radius:50%;
  color:var(--text);
  cursor:pointer;
  z-index:2;
  transition:border-color .2s ease,color .2s ease,background .2s ease;
}
.lightbox-nav svg{width:22px;height:22px;display:block;}
.lightbox-nav:hover{border-color:var(--ion);color:var(--star);background:rgba(76,141,255,0.16);}
/* At the first/last image the arrow is hidden but KEEPS its space, so
   the image does not jump sideways as you page to either end. */
.lightbox-nav.is-unavailable{visibility:hidden;pointer-events:none;}
/* flex:0 1 auto, NOT 1 1 auto -- the wrap must shrink to the image's own
   width rather than absorbing all the leftover space. With 1 1 auto the
   wrap filled the viewport and the arrows sat at ITS edges, ~450px away
   from a 900px image (measured). Sized to content, the whole
   arrow-image-arrow group centres together and the arrows land against
   the image itself, which is the point. */
.lightbox-media-wrap{
  flex:0 1 auto;
  min-width:0;
  display:flex;
  align-items:center;
  justify-content:center;
}
.lightbox-counter{
  position:absolute;
  top:18px;left:50%;
  transform:translateX(-50%);
  display:none;
  padding:5px 12px;
  background:rgba(5,7,13,0.7);
  border:1px solid var(--line);
  border-radius:20px;
  font-family:var(--mono);
  font-size:0.7rem;
  color:var(--text-dim);
  z-index:2;
}
.lightbox-overlay.has-gallery .lightbox-nav,
.lightbox-overlay.has-gallery .lightbox-counter{display:flex;}
@media (max-width:600px){
  .lightbox-nav{width:38px;height:38px;}
  .lightbox-stage{gap:8px;padding:0 8px;}
  .lightbox-stage img,.lightbox-stage video{max-width:calc(100vw - 116px);}
}
.lightbox-hint{
  position:absolute;
  bottom:20px;left:50%;
  transform:translateX(-50%);
  font-family:var(--mono);
  font-size:0.72rem;
  color:var(--text-dim);
  background:rgba(5,7,13,0.65);
  border:1px solid var(--line);
  padding:7px 16px;
  border-radius:20px;
  pointer-events:none;
  white-space:nowrap;
  /* Contained within the fixed overlay (which itself clips), so this
     never creates real page scroll -- but on a narrow phone the
     centered, nowrap text ran 14px past BOTH edges of the overlay
     itself, clipping the start and end of the hint unreadable. Capping
     width and allowing wrap keeps the whole message visible instead. */
  max-width:calc(100vw - 32px);
}
@media (max-width:600px){
  .lightbox-hint{font-size:0.65rem;padding:6px 12px;bottom:14px;white-space:normal;text-align:center;}
  .lightbox-close{width:36px;height:36px;top:14px;right:14px;}
}
/* Signals the inline render is not the full size. */
.axiom-gif-zoomable{cursor:zoom-in;}

/* ===================== Shared reactions =====================
   Moved verbatim out of feed.php's inline <style> so chat can use the
   real component rather than a lookalike. Unchanged rules -- only their
   address changed. Feed's own .action-btn.reacted trigger styling stays
   in feed.php, because that trigger is genuinely a Feed action-row
   button; chat's equivalent trigger is defined below. */
.reaction-summary { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
/* Fluent 3D reaction icons. Sized by the img width/height attributes the
   markup already carries; these rules only handle vertical rhythm and the
   fact that an inline image otherwise sits on the text baseline. The whole
   point of a 3D emoji is depth, which disappears below roughly 18px --
   hence three size steps rather than one shared value. */
.rx-emoji { display: inline-block; vertical-align: middle; flex-shrink: 0; }
.reaction-tray button .rx-emoji { transition: transform .15s ease; }
.reaction-tray button:hover .rx-emoji { transform: scale(1.06); }

.reaction-pill { display: flex; align-items: center; gap: 4px; background: rgba(12,19,34,.5); border: 1px solid var(--line); border-radius: 12px; padding: 3px 9px; font-size: .78rem; color: var(--text-dim); cursor: pointer; transition: all .15s; font-family: inherit; }
.reaction-pill:hover { border-color: var(--ion-dim); transform: translateY(-1px); }
.reaction-pill.mine { border-color: var(--ion); background: rgba(var(--ion-rgb),.2); }
.react-control { position: relative; }
/* position:fixed, not absolute. An absolutely positioned box still counts
   toward an ancestor scroller's scrollable width even while
   visibility:hidden, and this one is ~259px of nowrap content sitting
   inside chat's message list -- which is exactly what gave a real
   conversation a horizontal scrollbar. Fixed takes it out of that
   calculation at rest; on open it is portalled to <body> and positioned
   explicitly (see openTrayEl/positionTray in site.js). */
/* .reaction-tray is the outer positioned box (fixed, portalled, clamped
   to its post/bubble container by axiomPositionPopover). It is a COLUMN:
   the quick-pick row is its own child, the full panel (when expanded) is
   a second child stacked below it -- this is what lets the panel have a
   real, enforced width of its own instead of inheriting whatever a flat
   flex-wrap row happened to measure out to (the earlier version's actual
   bug: the panel rendered at its natural content width and broke out of
   the post/bubble no matter what its own CSS claimed). */
.reaction-tray { position: fixed; bottom: auto; left: 0; display: flex; flex-direction: column; background: var(--panel-2); border: 1px solid var(--line); border-radius: 14px; padding: 5px; box-shadow: 0 10px 30px rgba(0,0,0,.5); opacity: 0; visibility: hidden; transform: translateY(6px) scale(.86); transform-origin: bottom right; transition: opacity .14s ease, visibility .14s linear, transform .3s cubic-bezier(.18,.89,.32,1.28); z-index: var(--z-dropdown); }
.reaction-tray.open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
.reaction-tray-quickrow { display: flex; gap: 2px; white-space: nowrap; }
/* Press-and-hold feedback: the bubble/post/comment gives slightly under
   the finger so the hold reads as the app responding rather than not
   listening. Originally chat-only; also covers Feed posts and comments
   now that the same mechanic (site.js's touchstart handler) drives both. */
.ax-chat-msg.ax-react-holding, .feed-post.ax-react-holding, .comment-body.ax-react-holding { transform: scale(.978); transition: transform .16s ease; }
@media (prefers-reduced-motion: reduce) {
  /* No springs, no give -- appearance only, instantly. */
  .reaction-tray { transform: none; transition: opacity .1s linear, visibility .1s linear; }
  .reaction-tray.open { transform: none; }
  .ax-chat-msg.ax-react-holding, .feed-post.ax-react-holding, .comment-body.ax-react-holding { transform: none; }
}
.reaction-tray button { background: none; border: none; cursor: pointer; padding: 6px; border-radius: 10px; transition: all .15s ease; font-size: 1.25rem; line-height: 1; }
.reaction-tray button:hover { background: rgba(var(--ion-rgb),.15); transform: scale(1.25) translateY(-3px); }
.reaction-tray button.active { background: rgba(var(--ion-rgb),.25); }

/* Expand control -- last item in the quick-pick row. Two faces: "+"
   while collapsed (opens the panel), a magnifying glass once expanded
   (opens the search field). Styled like the other tray buttons so it
   reads as part of the row, not a control bolted onto it. */
.reaction-tray-expand { font-family: var(--body); font-weight: 700; font-size: 1.1rem !important; color: var(--text-dim); display: inline-flex; align-items: center; justify-content: center; }
.reaction-tray-expand:hover { color: var(--star); }
.reaction-tray-expand .rte-search { display: none; }
.reaction-tray-expand .rte-search svg { width: 17px; height: 17px; display: block; }
.reaction-tray.expanded .reaction-tray-expand .rte-plus { display: none; }
.reaction-tray.expanded .reaction-tray-expand .rte-search { display: block; }
/* Pressed-in look while the search field is showing, so the control
   reads as a toggle that is currently ON rather than a button that did
   nothing. */
.reaction-tray.search-open .reaction-tray-expand { color: var(--star); background: rgba(var(--ion-rgb),.22); }

/* Three extra reactions revealed only when expanded -- the panel is
   wider than the resting row, so that space gets used instead of left
   empty. Rendered always, revealed by CSS, so expanding never rebuilds
   or re-measures the row. */
.reaction-quick-extra { display: none; }
.reaction-tray.expanded .reaction-quick-extra { display: inline-flex; }

/* Expanding grows the SAME element the quick-pick row lives in -- the
   row opened up, not a second popup appearing elsewhere, per the
   explicit ask. A REAL fixed width, not content-derived -- this is the
   actual fix for the panel breaking out of its post/bubble: a compact,
   self-contained popup sized to its own content (Discord's picker is
   the explicit reference), not the width of the post it's anchored to.
   axiomPositionPopover's clampTo then only ever needs to slide this
   fixed box left/right to stay inside the container, never fight
   content trying to make it wider. */
.reaction-tray.expanded { align-items: stretch; width: 268px; transition: opacity .14s ease, visibility .14s linear, transform .3s cubic-bezier(.18,.89,.32,1.28), width .22s ease, height .22s ease; }
/* Expanded, the quick row becomes a 6-column GRID -- the same 6 columns
   as the emoji panel directly beneath it. Deliberate, and the fix for a
   real layout defect: as a wrapping flex row, how many buttons landed
   per line was decided by available pixels, so the same 12 items (11
   reactions + the toggle) fell as a tidy 6+6 on desktop's 268px panel
   but a ragged 5+5+2 on mobile's 252px -- an orphan row with three
   empty slots. Driven by column count instead, 12 items are always
   exactly two rows of six at ANY screen width, and they align with the
   grid below rather than running on a second, unrelated rhythm. Tiles
   stretch to fill their column, so mobile tap targets get bigger, not
   smaller. The collapsed resting strip is untouched -- still one flat
   content-width row. */
.reaction-tray.expanded .reaction-tray-quickrow { display: grid; grid-template-columns: repeat(6, 1fr); gap: 2px; white-space: normal; }
.reaction-tray.expanded .reaction-tray-quickrow > button { width: 100%; aspect-ratio: 1; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
.reaction-panel-full { display: none; flex-direction: column; gap: 6px; margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--line); min-width: 0; }
.reaction-tray.expanded .reaction-panel-full { display: flex; }

/* Hidden until the magnifying glass is pressed -- an always-visible
   search field is clutter for the common case (scroll and tap), so it
   is opt-in and animates down rather than occupying the panel by
   default. Collapsed via max-height/opacity rather than display:none so
   the reveal can actually transition. */
.reaction-panel-search-wrap { position: relative; max-height: 0; opacity: 0; overflow: hidden; transition: max-height .2s ease, opacity .15s ease, margin-bottom .2s ease; }
.reaction-tray.search-open .reaction-panel-search-wrap { max-height: 44px; opacity: 1; margin-bottom: 6px; }
.reaction-panel-search-wrap svg { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); width: 13px; height: 13px; color: var(--text-dimmer); pointer-events: none; }
.reaction-panel-search { width: 100%; box-sizing: border-box; background: rgba(5,7,13,.5); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px 7px 27px; font-family: var(--body); font-size: .8rem; color: var(--text); }
.reaction-panel-search::placeholder { color: var(--text-dimmer); }
.reaction-panel-search:focus { outline: none; border-color: var(--ion); }

/* One continuous vertical scroll, every category in sequence under its
   own inline header -- the standard shape (Slack, Discord, iOS/macOS).
   There is deliberately NO category navigation bar: it was a wide
   horizontal strip for a panel this narrow, and scrolling is the
   navigation. 6 columns, tiles sized by the grid itself (1fr, never a
   fixed px width) so the row mathematically cannot exceed the panel --
   this component must never scroll sideways, not by a pixel. */
.reaction-panel-scroll { max-height: 216px; overflow-y: auto; overflow-x: hidden; }
.reaction-panel-section-label { font-family: var(--mono); font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-dimmer); padding: 6px 2px 3px; position: sticky; top: 0; background: var(--panel-2); z-index: 1; }
.reaction-panel-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 2px; }
.reaction-panel-grid button { background: none; border: none; cursor: pointer; width: 100%; aspect-ratio: 1; padding: 0; border-radius: 8px; font-size: 1.35rem; line-height: 1; transition: background .15s ease, transform .15s ease; }
.reaction-panel-grid button:hover { background: rgba(var(--ion-rgb),.18); transform: scale(1.12); }
.reaction-panel-grid button.active { background: rgba(var(--ion-rgb),.25); }
.reaction-panel-grid button[hidden] { display: none; }
.reaction-panel-section[hidden] { display: none; }
.reaction-panel-empty { text-align: center; padding: 14px 0; font-family: var(--mono); font-size: .72rem; color: var(--text-dimmer); }

@media (max-width: 480px) {
  /* "Can take more of the available width since space is limited, but
     must still stay fully inside the post border" -- still a real fixed
     value, clamped to the container the same way. */
  .reaction-tray.expanded { width: 252px; }

  /* THE TRAY MUST FIT THE PHONE. The shrink rule for this lived at
     max-width:360px, which no common handset is -- a 390px iPhone or a
     412px Pixel never matched it. So the resting quick row has been wider
     than the viewport on essentially every real device, pushing its own
     expand button off-screen where nobody could reach it. Confirmed from a
     real phone screenshot, not an emulator.

     It also had to change shape. The old rule shrank the row with
     font-size, which worked while a reaction was a text glyph and does
     nothing now that each one is an <img width="36"> from the Fluent set.
     Size the image itself.

     8 visible buttons at 28px + 8px padding + the expand control clears
     390px with room. The overflow guard is a backstop, not the plan: if a
     future row grows again it scrolls rather than hiding its own controls. */
  .reaction-tray { max-width: calc(100vw - 16px); }
  .reaction-tray:not(.expanded) .reaction-tray-quickrow { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
  .reaction-tray:not(.expanded) .reaction-tray-quickrow::-webkit-scrollbar { display: none; }
  .reaction-tray:not(.expanded) .reaction-tray-quickrow > button { padding: 4px; }
  .reaction-tray:not(.expanded) .reaction-tray-quickrow .rx-emoji { width: 28px; height: 28px; }
  .reaction-panel-scroll { max-height: 200px; }
}
@media (max-width: 360px) {
  /* Cheap insurance on a genuinely small phone: the RESTING strip is 9
     buttons wide (8 reactions + "+") and content-sized, so at ~320px it
     starts crowding the post border. Slightly smaller buttons keep it a
     single clean line instead of wrapping to two at rest. The expanded
     grid needs no equivalent -- its 6 columns are proportional already. */
  .reaction-tray:not(.expanded) .reaction-tray-quickrow > button { padding: 4px; font-size: 1.08rem; }
}
.comment-reaction-summary { display: flex; gap: 4px; margin-top: 4px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; }
.comment-reaction-summary::-webkit-scrollbar { display: none; }
.comment-reaction-summary .reaction-pill { font-size: .7rem; padding: 2px 6px; flex-shrink: 0; }

/* Chat's own trigger and summary. The tray and the pills themselves are
   the shared rules above and are NOT restyled here -- only the things
   that genuinely differ in a dense message list.
   Quiet rather than hidden: an opacity:0-until-hover trigger would be
   unreachable on touch, where the long-press path needs a real target to
   press. .45 keeps it out of the way without ever removing it. */
/* Block-level flex (not inline-flex) so this row spans the bubble's
   full width, then justify-content pushes the trigger to its right
   edge. Three earlier fixes here only ever moved the POPUP TRAY that
   opens on click -- this is the trigger button's own position within
   the message, which had never actually been touched; it was still
   sitting at the bubble's left edge on both sent and received messages. */
.ax-chat-msg-react { display: flex; justify-content: flex-end; margin-top: 2px; }
.ax-chat-react-btn { background: none; border: none; cursor: pointer; padding: 2px 5px; border-radius: 6px; color: var(--text-dimmer); opacity: .45; line-height: 0; transition: opacity .15s ease, color .15s ease, background .15s ease; }
.ax-chat-msg:hover .ax-chat-react-btn { opacity: 1; }
.ax-chat-react-btn:hover { color: var(--star); background: rgba(12,19,34,.5); }
.ax-chat-react-btn.reacted { color: var(--star); opacity: 1; }
/* Deliberately NOT right-aligned, unlike .ax-chat-msg-react below: the
   trigger button is just a utility control and can sit wherever is
   convenient, but the reaction SUMMARY (who already reacted) belongs
   with the message content it describes, so it stays left/default. */
.ax-chat-reaction-summary { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.ax-chat-reaction-summary .reaction-pill { font-size: .7rem; padding: 2px 6px; }
/* Existing reaction pills on a message are a real tap target -- tapping
   one toggles your own reaction -- but at .7rem/2px they measured about
   24px tall on a phone, well under the 44px standard this site already
   adopted for the header (see the 4547b79 hit-area work). Raised to a
   32px minimum at the mobile breakpoint, with the emoji and count scaled
   up rather than just padded, so the pill stays legible instead of
   becoming a large box around tiny text. Desktop is unchanged: it has a
   precise pointer and the compact treatment reads better there. */
@media (max-width: 720px) {
  .ax-chat-reaction-summary .reaction-pill {
    min-height: 32px;
    padding: 4px 10px;
    font-size: .82rem;
    gap: 5px;
  }
  .ax-chat-msg-react .ax-chat-react-btn { min-width: 32px; min-height: 32px; }
}

/* ============================================================
   BREADCRUMBS
   One definition, every page (feed-helpers.php's axiom_crumbs_*).
   Replaced #returnToOrigin, which was fifteen page-local copies
   whose margins had already drifted to five different values
   (10px / 16px / 20px / 130px 20px 14px / 20px 0 -8px).
   ============================================================ */
.axiom-crumbs{font-family:var(--mono);font-size:.78rem;line-height:1.5;margin:0 0 16px;}
.axiom-crumbs ol{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;align-items:center;gap:7px;}
.axiom-crumbs li{display:flex;align-items:center;gap:7px;min-width:0;}
.axiom-crumbs li + li::before{content:"\203A";color:var(--text-dimmer);opacity:.55;flex-shrink:0;}
.axiom-crumbs a{color:var(--text-dim);text-decoration:none;transition:color .2s ease;}
.axiom-crumbs a:hover{color:var(--star);}
.axiom-crumbs span{color:var(--text-dimmer);}
/* The current page can be a forum topic title or a community name, so it
   truncates rather than wrapping the trail onto three lines. */
.axiom-crumbs [aria-current="page"]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:min(46ch,58vw);}

/* Opt-in variant for a page whose own wrapper carries no top offset for
   the fixed header (community.php's .comm-detail-wrap is padding:0 0 80px,
   which is why its old copy of this element hardcoded 130px). A shared
   variant, not a page-local override -- any page in that situation uses
   this same class. */
.axiom-crumbs.crumbs-offset{margin:130px 20px 14px;}

@media (max-width:560px){
  .axiom-crumbs{font-size:.74rem;}
  .axiom-crumbs [aria-current="page"]{max-width:min(26ch,50vw);}
  .axiom-crumbs.crumbs-offset{margin:112px 16px 12px;}
}
