/* ==========================================================================
   motion.css — the site's motion and micro-detail layer (2026-08-11)
   --------------------------------------------------------------------------
   ONE file, loaded last, so the whole "feel" of the site can be read, tuned or
   switched off in a single place. It deliberately does NOT restyle anything:
   no new colours, no new spacing, no layout. It only changes how things MOVE
   and adds the small finishes that make a page feel considered.

   Rules it follows, so it can never become the next white box:
     • nothing here paints a background on a surface that did not have one
     • every transition names its properties; no `transition: all`
     • only transform / opacity / filter / colour animate, never width, height,
       top or left, so nothing can reflow mid-animation
     • everything collapses under prefers-reduced-motion at the bottom
     • no !important except where it has to beat an inline page style

   Tuning: change the four --mo-* values and the whole site changes together.
   ========================================================================== */

:root{
  /* one easing family. --mo-out is the workhorse: fast start, soft landing.
     --mo-spring overshoots very slightly, only for things that "arrive". */
  --mo-out:cubic-bezier(.22,.61,.24,1);
  --mo-spring:cubic-bezier(.34,1.26,.52,1);
  --mo-fast:.18s;
  --mo-base:.32s;
  --mo-slow:.55s;
}

/* ══════════════════════════════════════════════════════════════════════════
   0. PAGE TRANSITIONS
   --------------------------------------------------------------------------
   Cross-document view transitions. Clicking a link no longer flashes white:
   the old page fades out as the new one fades in, and any element the two
   pages share MORPHS between them.

   This is CSS only. No framework, no build step, no JS. A browser that does
   not support it simply navigates the old way, so there is nothing to detect
   and no fallback to maintain.

   ⚠️ LIGHT MODE IS DELIBERATELY QUIETER. The user asked for light to stay
   restrained until a bigger change is approved, and for dark to get the full
   treatment. So light gets the smoothness (a clean cross-fade and the morph,
   which is continuity rather than decoration) and dark additionally gets a
   slight lift and a longer settle.
   ══════════════════════════════════════════════════════════════════════════ */
@view-transition{ navigation:auto }

::view-transition-group(root){ animation-duration:var(--mo-base) }
::view-transition-old(root),
::view-transition-new(root){
  animation-duration:var(--mo-base);
  animation-timing-function:var(--mo-out);
}
/* the outgoing page must not sit on top of the incoming one, or the fade
   reads as a flicker */
::view-transition-old(root){ z-index:0 }
::view-transition-new(root){ z-index:1 }

/* DARK gets the extra: the incoming page rises a few pixels as it arrives. */
html[data-theme="dark"]::view-transition-new(root){
  animation:moPageIn var(--mo-slow) var(--mo-out) both;
}
html[data-theme="dark"]::view-transition-old(root){
  animation:moPageOut var(--mo-base) var(--mo-out) both;
}
@keyframes moPageIn{ from{opacity:0;transform:translate3d(0,10px,0)} to{opacity:1;transform:none} }
@keyframes moPageOut{ from{opacity:1} to{opacity:0} }

/* Shared elements. A card image and the hero it opens into carry the same
   view-transition-name, so the thumbnail grows into the hero instead of the
   page blinking. The names are generated per post, never hand-written, since
   two elements sharing a name on ONE page cancels the whole transition. */
::view-transition-group(*){ animation-timing-function:var(--mo-out) }
::view-transition-old(.eco-morph),
::view-transition-new(.eco-morph){ mix-blend-mode:normal }

/* ── Typography: the cheapest premium detail there is ────────────────────
   balance stops a heading leaving one lonely word on its last line; pretty
   stops a paragraph ending in a single-word orphan. Both are ignored by
   browsers that do not know them. */
h1,h2,h3,h4,
.page-title h1,.page-title h2,
.ct-tag,.st-tag,.xg-tag,.hub-lead,.blog-lead,.ct-lead,.st-lead{
  text-wrap:balance;
}
p,li,figcaption,.cfaq__a,.bvc-row__v > div{ text-wrap:pretty }

/* ── 1. Anchor scrolling ─────────────────────────────────────────────────
   Jumping to #section used to teleport and land with the heading jammed under
   the sticky nav. scroll-margin-top uses the nav height the bar already
   measures for the hero, so the two can never disagree. */
html{scroll-behavior:smooth}
:target,
[id]{scroll-margin-top:calc(var(--eco-nav-h,84px) + 18px)}

/* ── 2. Text selection, in brand colour rather than the browser's blue ──── */
::selection{background:rgba(19,194,168,.24);color:inherit}
html[data-theme="dark"] ::selection{background:rgba(126,200,255,.30)}

/* ── 3. One focus ring for the whole site ────────────────────────────────
   :focus-visible only, so a mouse click never draws it but a Tab key always
   does. Previously each component invented its own, and several drew none. */
a:focus-visible,button:focus-visible,input:focus-visible,
select:focus-visible,textarea:focus-visible,summary:focus-visible,
[tabindex]:focus-visible{
  outline:2px solid #13c2a8;outline-offset:3px;border-radius:6px;
}
html[data-theme="dark"] a:focus-visible,
html[data-theme="dark"] button:focus-visible,
html[data-theme="dark"] input:focus-visible,
html[data-theme="dark"] select:focus-visible,
html[data-theme="dark"] textarea:focus-visible,
html[data-theme="dark"] summary:focus-visible,
html[data-theme="dark"] [tabindex]:focus-visible{outline-color:#7ec8ff}

/* ── 4. Cards: one lift, everywhere ──────────────────────────────────────
   The site had four different card hovers with four different speeds. This is
   the shared one. Individual components keep their own shadow colour; only the
   timing and the distance are unified. */
.hub-card,.fac-card,.ct-card,.st-item,.rp-card,.blog-card,
.xg-card,.ac-card,.ti-card,.cfaq__q,.benefit-item{
  transition:transform var(--mo-base) var(--mo-out),
             box-shadow var(--mo-base) var(--mo-out),
             border-color var(--mo-base) ease;
}
.hub-card:hover,.fac-card:hover,.ct-card:hover,.st-item:hover,
.rp-card:hover,.blog-card:hover,.xg-card:hover,.ac-card:hover,
.ti-card:hover{transform:translateY(-4px)}

/* ── 5. Media: a slow, quiet zoom inside its own frame ──────────────────
   Only where the frame already clips (overflow:hidden), so nothing can spill.
   scale on the IMAGE, never on the card, so text stays pin sharp. */
.blog-card__img,.rp-img,.fac-card__img,.article-figure,.ct-card__media,
.gal-item,.ti-card__img{overflow:hidden}
.blog-card__img img,.rp-img img,.fac-card__img img,.gal-item img{
  transition:transform var(--mo-slow) var(--mo-out);
}
.blog-card:hover .blog-card__img img,
.rp-card:hover .rp-img img,
.fac-card:hover .fac-card__img img,
.gal-item:hover img{transform:scale(1.045)}

/* ── 6. In-text links: an underline that wipes in from the left ─────────
   Replaces the static 1px border several blocks were drawing. Uses
   background-size, which animates on the compositor, rather than width. */
.article-body p > a,.cfaq__a a,.st-src a,.ct-wrap p > a,.crumbs__i a{
  text-decoration:none;
  background-image:linear-gradient(currentColor,currentColor);
  background-repeat:no-repeat;
  background-position:0 100%;
  background-size:0% 1px;
  transition:background-size var(--mo-base) var(--mo-out),color var(--mo-fast) ease;
}
.article-body p > a:hover,.cfaq__a a:hover,.st-src a:hover,
.ct-wrap p > a:hover,.crumbs__i a:hover{background-size:100% 1px}

/* ── 7. Buttons: a real press ───────────────────────────────────────────
   Hover lifts, ACTIVE presses back down. The missing press is why buttons on
   most sites feel like pictures of buttons. */
.btn-apply,.xg-btn,.st-btn,.ct-btn,.ac-btn,.footer-apply,.eco-fab__btn,.cf-submit{
  transition:transform var(--mo-fast) var(--mo-out),
             box-shadow var(--mo-base) var(--mo-out),filter var(--mo-fast) ease;
}
.btn-apply:active,.xg-btn:active,.st-btn:active,.ct-btn:active,
.ac-btn:active,.footer-apply:active,.eco-fab__btn:active,.cf-submit:active{
  transform:translateY(1px) scale(.985);
}

/* ── 8. Details/summary: the marker turns instead of jumping ───────────── */
details summary{transition:color var(--mo-fast) ease}
details .cfaq__plus,details .xg-plus,details .ac-plus,details .faq-chev{
  transition:transform var(--mo-base) var(--mo-spring);
}

/* ── 9. Section headings: a hairline that draws itself in ───────────────
   Only on headings that ALREADY had a rule under them, so nothing new appears.
   transform:scaleX is free; animating width is not. */
.ct-sechead h2,.st-sechead h2,.cmp-h,.cfaq__h{position:relative}
.ct-sechead h2::after,.st-sechead h2::after,.cmp-h::after,.cfaq__h::after{
  content:"";position:absolute;left:0;bottom:-8px;width:52px;height:2px;border-radius:2px;
  background:linear-gradient(90deg,#13c2a8,rgba(19,194,168,0));
  transform:scaleX(0);transform-origin:left;
  transition:transform var(--mo-slow) var(--mo-out);
}
[data-aos].aos-animate .ct-sechead h2::after,
[data-aos].aos-animate .st-sechead h2::after,
[data-aos].aos-animate .cmp-h::after,
[data-aos].aos-animate .cfaq__h::after,
.ct-sechead:hover h2::after,.st-sechead:hover h2::after{transform:scaleX(1)}
html[data-theme="dark"] .ct-sechead h2::after,
html[data-theme="dark"] .st-sechead h2::after,
html[data-theme="dark"] .cmp-h::after,
html[data-theme="dark"] .cfaq__h::after{
  background:linear-gradient(90deg,#7ec8ff,rgba(126,200,255,0));
}

/* ── 10. Scroll reveal, softened ────────────────────────────────────────
   AOS ships a 100px travel with ease-in-out, which reads as "sliding
   furniture". 14px with a decelerating curve reads as "settling". Overriding
   AOS's own variables rather than its keyframes keeps its JS in charge. */
[data-aos]{transition-timing-function:var(--mo-out)!important}
[data-aos^="fade"][data-aos^="fade"]{transform:translate3d(0,14px,0)}
[data-aos^="fade"][data-aos^="fade"].aos-animate{transform:translate3d(0,0,0)}
[data-aos="fade-up"]{transform:translate3d(0,16px,0)}
[data-aos="fade-down"]{transform:translate3d(0,-16px,0)}
[data-aos="fade-left"]{transform:translate3d(16px,0,0)}
[data-aos="fade-right"]{transform:translate3d(-16px,0,0)}
[data-aos="zoom-in"]{transform:scale(.985)}

/* ── 11. Sticky table headers on the wide comparison tables ─────────────
   A small thing that makes a long table feel built rather than dumped. */
.cmp-wrap{scroll-behavior:smooth}
table.cmp thead th{position:sticky;top:0;z-index:2}

/* ── 12. Scrollbars, only where a block scrolls on its own ─────────────── */
.cmp-wrap,.eco-nav__sub,.gal-scroll{scrollbar-width:thin;scrollbar-color:rgba(19,194,168,.5) transparent}
.cmp-wrap::-webkit-scrollbar,.gal-scroll::-webkit-scrollbar{height:8px;width:8px}
.cmp-wrap::-webkit-scrollbar-thumb,.gal-scroll::-webkit-scrollbar-thumb{
  background:rgba(19,194,168,.45);border-radius:8px}
.cmp-wrap::-webkit-scrollbar-track,.gal-scroll::-webkit-scrollbar-track{background:transparent}
html[data-theme="dark"] .cmp-wrap,html[data-theme="dark"] .gal-scroll{scrollbar-color:rgba(126,200,255,.5) transparent}
html[data-theme="dark"] .cmp-wrap::-webkit-scrollbar-thumb,
html[data-theme="dark"] .gal-scroll::-webkit-scrollbar-thumb{background:rgba(126,200,255,.45)}

/* ── 13. NOT DONE ON PURPOSE: fading lazy images in ─────────────────────
   The obvious version is
       img[loading="lazy"]{animation:fade .5s both}
   and it is a trap. `both` fill applies the 0% frame BEFORE the animation
   starts, so anything that stops the animation from ever starting leaves every
   lazy image at opacity 0. A decorative fade must never be able to hide
   content. Doing it safely needs JS to add a class on the decode event, which
   is more machinery than the effect is worth. */

/* ── EVERYTHING OFF ──────────────────────────────────────────────────────
   One block, last, so there is a single answer to "does this site respect
   reduced motion". Animation duration is not zeroed but set to 1ms, which
   keeps animationend listeners firing. */
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  /* ⚠️ 1ms, not `animation:none`. Removing the animation outright can leave
     the outgoing and incoming snapshots both painted; a 1ms duration ends the
     transition immediately AND cleanly. */
  ::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){
    animation-duration:1ms!important;
  }
  *,*::before,*::after{
    animation-duration:1ms!important;animation-iteration-count:1!important;
    transition-duration:1ms!important;scroll-behavior:auto!important;
  }
  .hub-card:hover,.fac-card:hover,.ct-card:hover,.st-item:hover,
  .rp-card:hover,.blog-card:hover,.xg-card:hover,.ac-card:hover,
  .ti-card:hover{transform:none}
  .blog-card:hover .blog-card__img img,.rp-card:hover .rp-img img,
  .fac-card:hover .fac-card__img img,.gal-item:hover img{transform:none}
  img[loading="lazy"]{animation:none}
  [data-aos]{transform:none!important;opacity:1!important}
}


/* ═══════════════════════════════════════════════════════════════════════════
   SCROLL REVEALS  ::  replaces the AOS library, 2026-08-16

   AOS was 41 KB (13 KB JS + 28 KB CSS) on all 76 pages. This does the same
   job in a few lines of CSS plus about 20 lines of JavaScript in main.js,
   so the saving stands.

   Only five effects were ever used on this site, so only five are here:
     fade-up 672 uses · fade 54 · fade-left 15 · zoom-out 3 · fade-right 2
   Timings match the old AOS config: 600ms, ease-in-out, once only.
   ═══════════════════════════════════════════════════════════════════════════ */
/* ⚠️ WAS animation-timeline: view(). It did not work on this site and the
   failure was silent: the animation attached and reported "running", but
   timeline.currentTime was NULL, meaning the ViewTimeline never activated,
   so nothing ever animated. A view timeline needs a real scroll container,
   and the root element here carries overflow-x: clip from the sticky fix in
   theme.css. Rather than unpick that fix, the reveal no longer depends on a
   scroll timeline at all.

   Now: an IntersectionObserver in main.js reveals each element as it first
   enters the viewport, using inline styles. About 30 lines against the 41 KB
   AOS cost, so the saving stands.

   ⚠️ Only elements that start BELOW the fold are ever hidden, and only by that
   script. If it never runs nothing is hidden and the page reads normally. The
   first screen is never hidden even for a frame. Content must never depend on
   JavaScript to become visible. */
/* ⚠️ The reveal is driven ENTIRELY from main.js with inline styles, not from
   here. Two CSS attempts failed on this site and both failed SILENTLY:

     1. animation-timeline: view() attached and reported "running" while
        timeline.currentTime stayed null, because a view timeline needs a
        real scroll container and the root element carries overflow-x: clip.
     2. A class-based transition was dropped because components here declare
        their own transition (.about-card is transform, background,
        border-color), and a transition REPLACES the property list rather
        than adding to it. A class-based animation then lost to an animation
        the component was already running.

   Inline styles sit above all of that, so the reveal cannot be overridden by
   whatever a component declares. It is what AOS did, minus the 41 KB. */




/* ═══════════════════════════════════════════════════════════════════════════
   "SEE MORE" COLLAPSE  ::  .eco-more , 2026-08-16

   For photo blocks on content-heavy pages. Measured before building it:
   the ESL course gallery started 21% down its page and occupied 35% of the
   whole page height, and the dormitory page carried 74 photographs starting
   11% down. On both, the pictures were between the reader and the content.

   Usage:
       <details class="eco-more">
         <summary class="eco-more__s">
           <span class="eco-more__t">See more photos</span>
           <span class="eco-more__n">34</span>
           <i class="bi bi-chevron-down" aria-hidden="true"></i>
         </summary>
         ... the photo grid ...
       </details>

   <details> rather than a scripted accordion: it opens with no JavaScript, it
   is keyboard accessible for free, and the images stay in the DOM so both the
   lightbox and Google still see them.

   ⚠️ If the block inside uses Isotope or any layout that MEASURES on load,
   pair it with the toggle handler in main.js. A closed <details> gives its
   children a zero-height container, so a masonry grid opened later would
   stack in the wrong place until something forces a re-layout.
   ═══════════════════════════════════════════════════════════════════════════ */
.eco-more { border: 1px solid #e6ebf0; border-radius: 16px; overflow: hidden; margin: 1.4rem 0; }
.eco-more__s { display: flex; align-items: center; gap: .7rem; cursor: pointer;
    padding: 1rem 1.25rem; background: #f4f8fc; font-weight: 700; color: #1c4e78;
    list-style: none; user-select: none; transition: background .2s ease; }
.eco-more__s::-webkit-details-marker { display: none; }
.eco-more__s:hover { background: #eaf2fa; }
.eco-more__t { flex: 1 1 auto; }
.eco-more__n { font-size: .8rem; font-weight: 700; padding: .15rem .55rem; border-radius: 999px;
    background: rgba(41,134,204,.12); color: #1c4e78; }
.eco-more__s i { transition: transform .25s ease; }
.eco-more[open] .eco-more__s i { transform: rotate(180deg); }
.eco-more > *:not(.eco-more__s) { padding: 1.1rem 1.25rem 1.35rem; }

html[data-theme="dark"] .eco-more { border-color: #24384a; }
html[data-theme="dark"] .eco-more__s { background: #142433; color: #9fd0f5; }
html[data-theme="dark"] .eco-more__s:hover { background: #18293a; }
html[data-theme="dark"] .eco-more__n { background: rgba(126,200,255,.15); color: #9fd0f5; }

@media (prefers-reduced-motion: reduce) {
    .eco-more__s, .eco-more__s i { transition: none; }
}
