/* ==========================================================================
   Components — Ember direction
   ========================================================================== */

/* ---- Buttons ----------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.7em;
  padding: 0.8em 0.8em 0.8em 1.4em;
  font-size: var(--t-body-sm);
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  transition: background-color 0.25s var(--ease), color 0.25s var(--ease),
              border-color 0.25s var(--ease), transform 0.25s var(--ease);
}
.btn:hover { transform: translateY(-2px); }

/* The circular badge that carries the arrow. It holds two identical arrows
   stacked in the same grid cell: on hover the first exits right while the
   second enters from the left, clipped by the badge. Costs one duplicated
   <svg> per button and reads far better than nudging a single arrow. */
.btn__badge {
  display: grid;
  place-items: center;
  width: 2rem; height: 2rem;
  border-radius: 50%;
  flex: none;
  overflow: hidden;
}
.btn__badge svg {
  grid-area: 1 / 1;
  width: 0.85rem; height: 0.85rem;
  transition: transform 0.42s var(--ease-out);
}
.btn__badge svg + svg { transform: translateX(-170%); }
.btn:hover .btn__badge svg { transform: translateX(170%); }
.btn:hover .btn__badge svg + svg { transform: translateX(0); }

.btn--solid { background: var(--amber); color: var(--canvas); }
.btn--solid:hover { background: var(--ivory); }
.btn--solid .btn__badge { background: var(--canvas); color: var(--amber); }

.btn--light { background: var(--ivory); color: var(--canvas); }
.btn--light:hover { background: #fff; }
.btn--light .btn__badge { background: var(--amber); color: var(--canvas); }

.btn--ghost { color: var(--ivory); border-color: var(--rule-strong); }
.btn--ghost:hover { border-color: var(--ivory); background: rgba(245,243,240,0.06); }
.btn--ghost .btn__badge { background: rgba(245,243,240,0.12); color: var(--ivory); }

/* On the ember panel the ghost button needs a warmer rule. */
.on-ember .btn--ghost { color: var(--on-ember); border-color: var(--rule-ember); }
.on-ember .btn--ghost:hover { border-color: var(--on-ember); background: rgba(255,247,242,0.1); }
.on-ember .btn--ghost .btn__badge { background: rgba(255,247,242,0.16); color: var(--on-ember); }

/* Inline link with an underline that retracts on hover. */
.ul {
  text-decoration: none;
  background-image: linear-gradient(var(--amber), var(--amber));
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: 0 100%;
  transition: background-size 0.3s var(--ease-out);
  padding-bottom: 2px;
}
.ul:hover { background-size: 0% 1px; background-position: 100% 100%; }

/* ---- Eyebrow ----------------------------------------------------------- */

.eyebrow {
  display: block;
  color: var(--amber);
  font-size: var(--t-sm);
  font-weight: 600;
  margin-bottom: var(--s-2);
}

/* ---- Masked words ------------------------------------------------------ */
/* Used on exactly two headings - her name and the closing call to action -
   so it reads as a deliberate device rather than an effect sprayed
   everywhere. The wrapper is the mask; the inner span is what travels. */

.word {
  display: inline-block;
  overflow: hidden;
  /* The mask box has to clear descenders ("Liliya", "you're") or they get
     sliced off. Padding grows the box, the negative margin gives it back. */
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
.word > span { display: inline-block; }

/* ---- Hero -------------------------------------------------------------- */

.hero { padding-inline: var(--gutter); padding-top: var(--s-2); }

.hero__panel {
  max-width: var(--shell);
  margin-inline: auto;
  border-radius: var(--r-xl);
  /* Dark at the left, where every piece of hero type sits (white clears 10:1
     on these stops); hot only on the right, behind the portrait. The rail
     gets its own scrim below so it can sit over the bright end safely. */
  background:
    radial-gradient(80% 120% at 78% 32%, rgba(255,138,61,0.55), rgba(255,138,61,0) 60%),
    linear-gradient(104deg, #45100A 0%, #6E1A08 32%, #A32A0D 62%, #E2550F 88%, #FF7A2B 100%);
  color: var(--on-ember);
  --hero-pad: clamp(1.75rem, 4vw, 3.5rem);
  padding: var(--hero-pad);
  overflow: hidden;
  position: relative;
}

/* A second, slowly drifting heat source on top of the fixed gradient. At 26
   seconds a round trip it is never caught moving - it just stops the panel
   from reading as a flat JPEG, and softens the banding that large orange
   gradients always show on cheaper displays. */
.hero__panel::before {
  content: "";
  position: absolute;
  inset: -25%;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(38% 42% at 50% 50%,
              rgba(255, 158, 92, 0.3), rgba(255, 158, 92, 0) 70%);
  animation: ember-drift 26s var(--ease-soft) infinite alternate;
}
@keyframes ember-drift {
  from { transform: translate3d(-5%, 3%, 0) scale(1); }
  to   { transform: translate3d(6%, -4%, 0) scale(1.14); }
}
@media (prefers-reduced-motion: reduce) {
  .hero__panel::before { animation: none; }
}

.hero__grid {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: 1fr;
  align-items: center;
  /* Above the drifting glow, which is absolutely positioned and would
     otherwise paint over everything in here. */
  position: relative;
  z-index: 1;
}
/* Tablet: text beside the portrait, rail spanning underneath both. */
@media (min-width: 48rem) and (max-width: 61.99rem) {
  .hero__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 0.82fr);
    gap: var(--s-4);
    align-items: end;
  }
  .hero__grid > .creds { grid-column: 1 / -1; }
}

@media (min-width: 62rem) {
  /* The portrait is always exactly as wide as its column, so its size is set
     by this ratio - not by max-height. Widening the middle column, tightening
     the gaps and trimming the rail is what makes the photo bigger. */
  .hero__grid {
    /* The rail is a fixed 12rem rather than `auto`: left to size to its own
       max-content it claimed ~250px and starved the portrait column.
       The text column was widened (0.95fr -> 1.2fr) so "Liliya Spencer"
       can hold one line without shrinking the name to a subheading: at the
       old width it would have needed a 37% cut, at this one it needs 12%. */
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.07fr) 12rem;
    gap: var(--s-4);
  }
}

.hero__lead-in {
  font-size: var(--t-md);
  font-weight: 500;
  color: var(--on-ember-2);
  margin-bottom: var(--s-1);
}

.hero__title {
  font-size: var(--t-3xl);
  font-weight: 800;
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
  margin-bottom: var(--s-3);
  /* Opted out of the generic .enter rise: the words below carry the
     entrance instead, and running both would read as a double move. */
  opacity: 1;
  transform: none;
  animation: none;
}
.hero__title em { font-style: normal; color: #FFD9AE; }

/* The one place on the site worth extra markup. A masked rise reads as
   typesetting; a fade reads as a loading state. The split lives in the HTML
   rather than being built by JS, so it survives with scripting off and needs
   no aria-label to stay readable. */
.hero__title .word > span {
  transform: translateY(110%);
  animation: word-rise 0.9s var(--ease-out) forwards;
}
.hero__title .word:nth-child(1) > span { animation-delay: calc(var(--stagger) * 1.4); }
.hero__title .word:nth-child(2) > span { animation-delay: calc(var(--stagger) * 2.2); }
@keyframes word-rise { to { transform: none; } }

@media (prefers-reduced-motion: reduce) {
  .hero__title .word > span { transform: none; animation: none; }
}

.hero__role {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--t-md);
  font-weight: 500;
  color: var(--on-ember-2);
  margin-bottom: var(--s-3);
}
/* A line that draws itself is a designer's signature; a line that fades in
   is filler. Fires just after the role text has landed. */
.hero__role::before {
  content: "";
  width: 2.5rem; height: 2px;
  background: var(--on-ember-2);
  flex: none;
  transform: scaleX(0);
  transform-origin: left center;
  animation: rule-draw 0.55s var(--ease-out) forwards;
  animation-delay: calc(var(--stagger) * 3.7);
}
@keyframes rule-draw { to { transform: scaleX(1); } }

.hero__blurb {
  max-width: 40ch;
  color: var(--on-ember-2);
  margin-bottom: var(--s-4);
}

.hero__actions { display: flex; flex-wrap: wrap; gap: var(--s-2); }

/* On phones the two pills can't share a row without wrapping raggedly, so they
   become full-width blocks - bigger tap targets and a deliberate edge. */
@media (max-width: 32rem) {
  .hero__actions .btn {
    flex: 1 1 100%;
    justify-content: space-between;
  }
}

/* Portrait column. The photo carries its own orange ground, so rather than
   float it in a box we bleed it to the panel's bottom edge (clipped by the
   panel radius) and feather the top and sides so that ground melts into the
   ember gradient. */
.hero__portrait {
  position: relative;
  display: grid;
  place-items: end center;
}
/* The light comes up on her: the glow blooms in a touch later and slower
   than the photo settles, so the portrait appears to be lit rather than
   simply revealed. */
.hero__portrait::before {
  content: "";
  position: absolute;
  inset: -6% -14% 0 -14%;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 52%, #FFB067 0%, rgba(255,150,80,0.5) 40%, rgba(255,122,43,0) 72%);
  filter: blur(22px);
  opacity: 0;
  animation: glow-bloom 1.9s var(--ease-out) 0.28s forwards;
}
@keyframes glow-bloom {
  from { opacity: 0; filter: blur(38px); }
  to   { opacity: 1; filter: blur(22px); }
}

/* Mobile: a 4:3 window on the square source, held high so the face sits in
   the middle of the crop rather than the chest. */
.hero__portrait img {
  position: relative;
  width: 100%;
  max-width: 19rem;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  object-position: 50% 22%;
  border-radius: var(--r-lg);
  /* A slow push-in as the page opens, settling about 4% larger and staying
     there. Scale only, never opacity: this photo is the LCP element, and
     fading it up from zero would defer LCP by the whole duration and cost
     real Core Web Vitals. */
  animation: portrait-push 2.4s var(--ease-out) forwards;
}
@keyframes portrait-push {
  from { transform: scale(1); }
  to   { transform: scale(1.042); }
}

@media (prefers-reduced-motion: reduce) {
  .hero__portrait::before { animation: none; opacity: 1; }
  .hero__portrait img { animation: none; }
}

@media (min-width: 62rem) {
  .hero__portrait {
    align-self: stretch;
    align-items: end;
    min-height: 26rem;
  }
  .hero__portrait img {
    /* The source is square and cropped at the chest, so it is shown close to
       1:1 and bled off the panel's bottom edge - the crop reads as deliberate
       rather than as a photo that ran out.
       width:100% (not auto) so it fills the column deterministically; with
       `auto` it settled at the intrinsic size of whichever srcset candidate
       was picked and ignored the column entirely. */
    width: 100%;
    max-width: none;
    aspect-ratio: 1 / 1;
    height: auto;
    max-height: 35rem;
    object-position: 50% 20%;
    margin-bottom: calc(-1 * var(--hero-pad));
    border-radius: 0;
    /* Two masks intersected: sides feather inwards, the top fades out, the
       bottom stays solid because the panel's own edge clips it there. */
    -webkit-mask-image:
      linear-gradient(to right, transparent 0%, #000 20%, #000 80%, transparent 100%),
      linear-gradient(to bottom, transparent 0%, #000 26%, #000 100%);
    -webkit-mask-composite: source-in;
    mask-image:
      linear-gradient(to right, transparent 0%, #000 20%, #000 80%, transparent 100%),
      linear-gradient(to bottom, transparent 0%, #000 26%, #000 100%);
    mask-composite: intersect;
  }
}

/* Credentials rail */
.creds {
  display: grid;
  gap: var(--s-3);
  align-content: center;
}
/* The rail can land over the bright end of the gradient, so it carries its own
   dark scrim — that keeps white text above 9:1 wherever it falls. */
.creds {
  background: rgba(14, 3, 0, 0.46);
  border: 1px solid rgba(255, 247, 242, 0.14);
  border-radius: var(--r-md);
  padding: var(--s-3);
}
/* The rail reads as supporting evidence, so it arrives after the claim -
   the card itself rises first, then the three facts fade in inside it. */
.creds {
  opacity: 0;
  transform: translateY(var(--rise));
  animation: rise var(--d-enter) var(--ease-out) forwards;
  animation-delay: calc(var(--stagger) * 5.2);
}
.creds li {
  opacity: 0;
  animation: fade-in 0.6s var(--ease-out) forwards;
}
.creds li:nth-child(1) { animation-delay: calc(var(--stagger) * 6);   }
.creds li:nth-child(2) { animation-delay: calc(var(--stagger) * 6.7); }
.creds li:nth-child(3) { animation-delay: calc(var(--stagger) * 7.4); }
@keyframes fade-in { to { opacity: 1; } }

@media (min-width: 62rem) {
  .creds { min-width: 13rem; }
}
@media (max-width: 61.99rem) {
  .creds { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
}
.cred__fig {
  display: block;
  font-size: clamp(1.1rem, 0.5vw + 0.95rem, 1.35rem);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  line-height: 1.1;
}
.cred__note { font-size: var(--t-body-sm); color: var(--on-ember-2); }

/* ---- Client logo strip ------------------------------------------------- */

/* The label sits above the wall rather than beside it. Holding it in a
   left-hand column cost the logos ~280px of row width, which was enough to
   push the eighth mark onto a second row on a 1440px screen and to leave a
   ragged two-item tail once the wall grows. Full width fits eight comfortably
   and wraps in even rows as more are added. */
.logos {
  display: grid;
  gap: var(--s-3);
}
.logos__label {
  font-size: var(--t-md);
  font-weight: 600;
  line-height: 1.25;
  color: var(--muted);
}
.logos__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  align-items: center;
}
/* Placeholder tile, replaced by <img> when the real logos arrive. */
.logo-slot {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.6em 1.1em;
  border: 1px dashed var(--rule-strong);
  border-radius: var(--r-pill);
  font-size: var(--t-sm);
  font-weight: 600;
  color: rgba(245, 243, 240, 0.82);
  transition: transform var(--d-base) var(--ease-out),
              border-color var(--d-base) var(--ease),
              color var(--d-base) var(--ease);
}
.logo-slot:hover {
  transform: translateY(-2px);
  border-color: var(--ivory);
  color: var(--ivory);
}

/* The dot springs open as its tile lands, so the strip reads as a row of
   lights coming on in sequence rather than a block of pills appearing. */
.logo-slot::before {
  content: "";
  width: 0.55rem; height: 0.55rem;
  border-radius: 50%;
  background: var(--amber);
  flex: none;
  transition: transform 0.5s var(--ease-out), background-color var(--d-base) var(--ease);
  transition-delay: calc(var(--reveal-i, 0) * var(--stagger));
}
.js .logo-slot[data-reveal]:not(.is-revealed)::before { transform: scale(0.15); }
.logo-slot:hover::before { background: #FF9A44; }

.logo-slot img { max-height: 1.6rem; width: auto; }

/* A slot holding a real mark carries no chrome at all: no dot, no outline.
   Six boxed-in logos read as a table of contents; bare marks on the dark
   ground read as a client list. Padding stays so they don't collide, and
   the shared min-height keeps the row's baseline steady when it wraps. */
.logo-slot--filled {
  border: 0;
  padding: 0.35em 0.75em;
  min-height: 3.25rem;
  justify-content: center;
}
.logo-slot--filled::before { display: none; }

/* The artwork is already flattened to one grey, so there is nothing to
   desaturate here - the marks simply sit back until hovered. */
.logo-slot--filled img {
  height: var(--logo-h, 1.5rem);
  width: auto;
  max-height: none;
  opacity: 0.74;
  transition: opacity var(--d-base) var(--ease);
}
.logo-slot--filled:hover img { opacity: 1; }

/* Height per mark, not one number for all six. These lockups put their
   cap-height at wildly different fractions of the artwork - JW Marriott
   stacks a griffin above its wordmark, CapitaLand hangs a swoosh below it -
   so a single max-height would leave some shouting and others whispering.
   Tuned by eye for equal optical weight across the row. */
.logo--capitaland       { --logo-h: 2.15rem; }
.logo--fwd              { --logo-h: 1.15rem; }
.logo--st-regis         { --logo-h: 1.6rem;  }
.logo--ihg              { --logo-h: 1.5rem;  }
.logo--intercontinental { --logo-h: 1.35rem; }
.logo--jw-marriott      { --logo-h: 2.1rem;  }
.logo--kempinski        { --logo-h: 1.95rem; }
.logo--w-sentosa        { --logo-h: 2.5rem;  }
.logo--remede           { --logo-h: 1.5rem;  }
.logo--elemis           { --logo-h: 1.18rem; }
.logo--sk-ii            { --logo-h: 1.25rem; }

/* ---- Marquee ----------------------------------------------------------- */

.marquee {
  position: relative;
  overflow: hidden;
  border-block: 1px solid var(--rule);
  background: var(--canvas-raised);
  padding-block: var(--s-3);
}

/* The words dissolve at both ends instead of being guillotined by the band
   edge. Done with two gradient caps rather than a mask on the band, so the
   rules above and below stay full width - masking the whole element faded
   the borders out at the ends and looked like a rendering fault. */
.marquee::before,
.marquee::after {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  width: clamp(3rem, 8vw, 7rem);
  z-index: 1;
  pointer-events: none;
}
.marquee::before {
  left: 0;
  background: linear-gradient(to right, var(--canvas-raised), rgba(21, 21, 23, 0));
}
.marquee::after {
  right: 0;
  background: linear-gradient(to left, var(--canvas-raised), rgba(21, 21, 23, 0));
}

.marquee__track {
  display: flex;
  gap: var(--s-4);
  width: max-content;
  /* Speed and direction are steered from site.js through this animation's
     playbackRate, which is why it must stay a single linear loop.
     The duration tracks the number of items: the animation travels one
     group's width, so doubling the list from six terms to twelve would
     otherwise double the speed. */
  animation: slide 76s linear infinite;
}
.marquee__group {
  display: flex;
  gap: var(--s-4);
  align-items: center;
  font-size: var(--t-md);
  font-weight: 600;
  letter-spacing: var(--track-snug);
  color: rgba(245, 243, 240, 0.7);
  padding-right: var(--s-4);
}
.marquee__group span::after {
  content: "✦";
  color: var(--amber);
  margin-left: var(--s-4);
  font-size: 0.6em;
  vertical-align: middle;
  /* Turning slowly and out of phase with the scroll. Never noticed
     directly, but it stops the strip from reading as a static image. */
  display: inline-block;
  animation: spin 24s linear infinite;
}
@keyframes slide { to { transform: translateX(-50%); } }
@keyframes spin { to { transform: rotate(360deg); } }

/* The one section that must go completely still. */
@media (prefers-reduced-motion: reduce) {
  .marquee__track,
  .marquee__group span::after { animation: none; }
}

/* ---- Section heads ----------------------------------------------------- */

.band__head {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  align-items: end;
  justify-content: space-between;
  margin-bottom: var(--s-5);
}
.band__title {
  font-size: var(--t-xl);
  font-weight: 700;
  letter-spacing: var(--track-tight);
  max-width: 20ch;
}
.band__title em { font-style: normal; color: var(--amber); }
.band__note { color: var(--muted); font-size: var(--t-base); max-width: 40ch; }

/* Pairs the section note with a call to action on the right-hand side of a
   band head. Without the wrapper they become two separate flex children and
   `space-between` pushes them to opposite ends of the row. */
.band__aside {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2) var(--s-3);
}
/* Shorter measure than a standalone note: it has to share the row. */
.band__aside .band__note { max-width: 24ch; }

/* ---- Filters ----------------------------------------------------------- */
/* Hidden until JS confirms it can actually filter — no dead controls. */

.filters { display: none; }
.js .filters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
  margin-bottom: var(--s-4);
  position: relative;
}

/* One amber pill travels between the buttons rather than each button
   repainting its own background. Position and size come from site.js as
   plain pixel values; the pill is only shown once it has been placed, so it
   never slides in from the top-left on load. */
.js .filters::before {
  content: "";
  position: absolute;
  left: 0; top: 0;
  width: var(--pill-w, 0);
  height: var(--pill-h, 0);
  transform: translate(var(--pill-x, 0), var(--pill-y, 0));
  background: var(--amber);
  border-radius: var(--r-pill);
  opacity: 0;
  z-index: 0;
}
.js .filters.is-ready::before {
  opacity: 1;
  transition: transform 0.42s var(--ease-out),
              width 0.42s var(--ease-out),
              height 0.42s var(--ease-out);
}

.filter {
  position: relative;
  z-index: 1;
  padding: 0.6em 1.25em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--muted);
  font-size: var(--t-body-sm);
  font-weight: 500;
  cursor: pointer;
  transition: color var(--d-base) var(--ease),
              border-color var(--d-base) var(--ease);
}
.filter:hover { color: var(--ivory); border-color: var(--ivory); }
/* The travelling pill supplies the fill, so the pressed button only has to
   get out of its way and flip its text colour. */
.filter[aria-pressed="true"] {
  border-color: transparent;
  color: var(--canvas);
}

@media (prefers-reduced-motion: reduce) {
  .js .filters.is-ready::before { transition: none; }
}

/* ---- Work grid --------------------------------------------------------- */

.work-grid {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 21rem), 1fr));
}
.work-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--canvas-raised);
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
  overflow: hidden;
  text-decoration: none;
  transition: transform 0.35s var(--ease-out), border-color 0.35s var(--ease);
}
.work-card:hover { transform: translateY(-6px); border-color: var(--amber); }
/* Cards that answer being pressed feel like objects; cards that don't feel
   like pictures of objects. */
.work-card:active { transform: translateY(-2px) scale(0.985); }
.work-card[hidden] { display: none; }

/* A warm light that follows the pointer across the card. --px/--py are
   written by site.js behind a rAF gate. Sits above the artwork at low alpha,
   so the whole card reads as lit rather than printed. */
.work-card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(16rem 16rem at var(--px, 50%) var(--py, 50%),
              rgba(255, 138, 61, 0.16), rgba(255, 138, 61, 0) 68%);
  opacity: 0;
  transition: opacity var(--d-base) var(--ease);
}
.work-card:hover::after { opacity: 1; }

.work-card__media {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: linear-gradient(150deg, #2A1109, #151517);
  display: grid;
  place-items: center;
}
.work-card__media img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform 0.7s var(--ease-out);
}
.work-card:hover .work-card__media img { transform: scale(1.06); }


/* A scrim rising from the foot of the artwork on hover, so the title below
   gains contrast as the image brightens and pushes in. */
.work-card__media::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(11, 11, 12, 0.5), rgba(11, 11, 12, 0) 45%);
  opacity: 0;
  transition: opacity var(--d-base) var(--ease);
}
.work-card:hover .work-card__media::after { opacity: 1; }

/* Says "this is a link" before the click, not after. */
.work-card__go {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  z-index: 1;
  display: grid;
  place-items: center;
  width: 2.1rem; height: 2.1rem;
  border-radius: 50%;
  background: var(--amber);
  color: var(--canvas);
  opacity: 0;
  transform: translate(-0.4rem, 0.4rem);
  transition: opacity var(--d-base) var(--ease),
              transform 0.42s var(--ease-out);
}
.work-card__go svg { width: 0.9rem; height: 0.9rem; }
.work-card:hover .work-card__go { opacity: 1; transform: none; }


/* ---- Filter transitions ------------------------------------------------ */
/* Two phases. Cards that are leaving fade and shrink first; only then does
   the grid reflow and the arrivals cascade in. Toggling `hidden` in one step
   is what used to make this snap.

   Scoped through .work-grid so these outrank the [data-reveal] rules, which
   also own opacity and transform on these same elements. */

.js .work-grid .work-card.is-leaving {
  opacity: 0;
  transform: scale(0.96);
  transition: opacity 170ms var(--ease), transform 170ms var(--ease);
}
/* An animation, not a transition: transitions here would fight the card's
   own hover transform and add a delay to every hover. */
.js .work-grid .work-card.is-entering {
  animation: card-in 0.5s var(--ease-out) backwards;
  animation-delay: calc(var(--enter-i, 0) * 45ms);
}
@keyframes card-in {
  from { opacity: 0; transform: translateY(var(--rise)); }
}

.work-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3);
  flex: 1;
}
.work-card__title {
  font-size: var(--t-md);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  transition: color 0.25s var(--ease);
}
.work-card:hover .work-card__title { color: var(--amber); }
.work-card__outcome { font-size: var(--t-body-sm); color: var(--muted); flex: 1; }
.work-card__meta { font-size: var(--t-xs); color: var(--muted); }

/* ---- Tag pills --------------------------------------------------------- */

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  font-size: var(--t-xs);
  color: var(--muted);
}
.tags li {
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  padding: 0.35em 0.8em;
}

/* The toolkit lists, where the tags are the content rather than card meta.
   The border carries the amber at a third of its strength: at full weight a
   hundred pills read as a hundred buttons, and the section stops being a
   list of skills and starts looking like a filter bar. The type warms a
   step with it so the pill reads as one object. */
.tags--accent li {
  border-color: rgba(255, 122, 26, 0.38);
  color: rgba(245, 243, 240, 0.86);
}

/* ---- Services ---------------------------------------------------------- */

.services { display: grid; gap: 0; position: relative; }
/* The closing rule lives on the list, not on the last row, so each row's own
   ::after is free to carry the hover wash. */
.services::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--rule);
}

.service {
  position: relative;
  display: grid;
  gap: var(--s-1) var(--s-4);
  grid-template-columns: 1fr;
  padding-block: var(--s-4);
  transition: padding-inline 0.35s var(--ease-out);
}

/* Each row's rule draws itself left to right as the row arrives, so the list
   appears to be typeset rather than pasted in. Drawn by default and only
   retracted under `.js`, so with scripting off the rules simply exist. */
.service::before {
  content: "";
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 1px;
  background: var(--rule);
  transform: scaleX(1);
  transform-origin: left center;
  transition: transform 0.6s var(--ease-out);
  transition-delay: calc(var(--reveal-i, 0) * var(--stagger));
}
.js .service[data-reveal]:not(.is-revealed)::before { transform: scaleX(0); }

/* A faint warm wash sweeping in from the left edge on hover. */
.service::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(to right,
              rgba(255, 122, 26, 0.07), rgba(255, 122, 26, 0) 42%);
  opacity: 0;
  transition: opacity var(--d-base) var(--ease);
}
.service:hover::after { opacity: 1; }

@media (min-width: 56rem) {
  .service { grid-template-columns: 5rem minmax(0, 18rem) minmax(0, 1fr); align-items: baseline; }
  .service:hover { padding-inline: var(--s-3); }
}

/* The numeral leads its row in, sliding from the left as the row rises, so
   the eye tracks down the numeral column while the section fills. */
.service__num {
  color: var(--amber);
  font-weight: 700;
  font-size: var(--t-md);
  transition: transform 0.6s var(--ease-out), color var(--d-base) var(--ease);
  transition-delay: calc(var(--reveal-i, 0) * var(--stagger));
}
.js .service[data-reveal]:not(.is-revealed) .service__num { transform: translateX(-0.7rem); }
.service:hover .service__num { color: var(--amber-deep); transform: translateX(4px); }

.service__title {
  font-size: var(--t-lg);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  transition: transform 0.42s var(--ease-out) 40ms;
}
/* Trails the numeral by a beat, so the row unfolds instead of sliding as a
   single block. */
.service:hover .service__title { transform: translateX(6px); }
.service__body { color: var(--muted); font-size: var(--t-body-sm); max-width: 52ch; }

/* ---- Prose ------------------------------------------------------------- */

.prose { max-width: var(--measure); }
.prose p + p { margin-top: var(--s-3); }

/* ---- Split (teaching, about intro) ------------------------------------- */

.split { display: grid; gap: var(--s-5); }
@media (min-width: 56rem) {
  .split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--s-7); align-items: center; }
}
/* Picture left, words right. The swap is done with `order` inside the
   two-column breakpoint rather than by reordering the markup, so the single
   column on a phone still reads heading, then prose, then picture. */
@media (min-width: 56rem) {
  .split--flip > :first-child { order: 2; }
  .split--flip > :last-child  { order: 1; }
}

.split__title {
  font-size: var(--t-xl);
  font-weight: 700;
  letter-spacing: var(--track-tight);
  margin-bottom: var(--s-3);
  max-width: 18ch;
}
.split__title em { font-style: normal; color: var(--amber); }

/* ---- About intro ------------------------------------------------------- */
/* The portrait sits beside the heading, its top edge level with the eyebrow,
   rather than hanging below a full-width title band.
   `align-items: start` is what does it: the shared .split rule centres its
   columns, which is right for the teaching block on the homepage but would
   drop this portrait half a screen down the page. Deliberately not given the
   .split parallax drift either - a portrait that slides as you scroll cannot
   stay level with the title it is meant to line up with. */

.about-intro { display: grid; gap: var(--s-4); }

@media (min-width: 56rem) {
  .about-intro {
    grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
    column-gap: var(--s-6);
    align-items: start;
  }
}

/* Was a 3rem band between the heading and the first paragraph, which read as
   two separate blocks rather than one opening statement. */
.about-intro__lead .prose { margin-top: var(--s-3); }

/* ---- Media ------------------------------------------------------------- */

.shot {
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
  background: var(--canvas-raised);
  overflow: hidden;
}
.shot img { width: 100%; height: auto; }

/* Enough travel to register as depth, not enough to notice as an effect.
   Scroll-driven, so it costs no JavaScript and simply does not apply in
   browsers without view() timelines. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .split .shot {
      animation: shot-drift linear both;
      animation-timeline: view();
      animation-range: entry 15% exit 85%;
    }
    @keyframes shot-drift {
      from { translate: 0 16px; }
      to   { translate: 0 -16px; }
    }
  }
}


/* ---- Contact ----------------------------------------------------------- */

.contact { text-align: center; position: relative; }

/* The page opens in ember, goes dark through the work, and warms again at
   the ask. Static by default so Safari still gets the glow; where view()
   timelines exist it fades up as the section arrives. */
.contact::before {
  content: "";
  position: absolute;
  left: 50%;
  top: -18%;
  width: min(46rem, 120%);
  aspect-ratio: 2 / 1;
  translate: -50% 0;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(50% 50% at 50% 50%,
              rgba(255, 122, 26, 0.16), rgba(255, 122, 26, 0) 70%);
}
.contact > * { position: relative; z-index: 1; }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .contact::before {
      animation: glow-warm linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 45%;
    }
    @keyframes glow-warm { from { opacity: 0; } to { opacity: 1; } }
  }
}
.contact__lead {
  font-size: var(--t-2xl);
  font-weight: 800;
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
  max-width: 16ch;
  margin: 0 auto var(--s-3);
}
.contact__lead em { font-style: normal; color: var(--amber); }
.contact__note {
  max-width: 46ch;
  margin: 0 auto var(--s-4);
  color: var(--muted);
  font-size: var(--t-md);
}
.contact__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  justify-content: center;
  margin-bottom: var(--s-4);
}
.contact__rail {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-2) var(--s-4);
  font-size: var(--t-sm);
  color: var(--muted);
}
.contact__rail a { text-decoration: none; }
.contact__rail a:hover { color: var(--ivory); }

/* ---- Case study -------------------------------------------------------- */

.cs-hero { padding-block: clamp(2.5rem, 7vw, 5rem) var(--s-4); }
.cs-hero__client {
  font-size: var(--t-2xl);
  font-weight: 800;
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
  margin-bottom: var(--s-3);
  max-width: 16ch;
}
.cs-hero__outcome {
  font-size: var(--t-md);
  color: var(--muted);
  max-width: var(--measure-tight);
  margin-bottom: var(--s-4);
}

.cs-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--s-3) var(--s-4);
  padding: var(--s-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  background: var(--canvas-raised);
  font-size: var(--t-body-sm);
}
/* A value long enough that a 9rem column would break it into a stack of
   two-word lines - the scope of an engagement, typically. It takes the whole
   row and the short facts sit above it. */
.cs-facts__wide { grid-column: 1 / -1; }
.cs-facts dt { color: var(--amber); font-weight: 600; margin-bottom: 0.35em; }
.cs-facts dd { margin: 0; color: var(--ivory); }

.cs-section { max-width: var(--measure); }
.cs-section + .cs-section { margin-top: var(--s-6); }

/* Wide viewports: the heading moves into a left rail and the prose keeps its
   measure, so the column doesn't strand half the page. */
@media (min-width: 62rem) {
  .cs-section {
    display: grid;
    /* 17rem, not 13: the rail has to hold "InterContinental" unbroken at
       --t-lg, which measures about 16.5rem on a wide screen. */
    grid-template-columns: 17rem minmax(0, var(--measure));
    column-gap: var(--s-5);
    max-width: none;
    align-items: start;
  }
  .cs-section > * { grid-column: 2; }
  /* The heading holds in the rail while its prose scrolls past, then
     releases at the next section. It has to span the section's rows to have
     anywhere to travel - pinned to row 1 alone it would barely move. */
  .cs-section__title {
    grid-column: 1;
    grid-row: 1 / span 99;
    align-self: start;
    position: sticky;
    top: calc(var(--anchor-offset) + var(--s-1));
    margin-bottom: 0;
  }
}
.cs-section__title {
  font-size: var(--t-lg);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  margin-bottom: var(--s-3);
  /* base.css lets headings break mid-word so long product names can't blow
     out a 320px viewport. In this narrow rail that turned hotel names into
     "InterContinen-tal" - so here words wrap whole or not at all. */
  overflow-wrap: normal;
  hyphens: none;
}
.cs-section p { color: var(--muted); }
.cs-section p + p { margin-top: var(--s-3); }

/* Scanning aid. A recruiter skimming a case study should be able to catch
   the few phrases that say what the work actually was. Brighter as well as
   bolder: weight alone barely registers against the muted body colour. */
.cs-section strong,
.cs-list strong {
  color: var(--ivory);
  font-weight: 700;
}

/* An itemised list inside a case-study section - objectives, constraints.
   Same amber dash as the CV bullets, so the two read as one convention. */
.cs-list { color: var(--muted); margin-top: var(--s-3); }
.cs-list li { position: relative; padding-left: 1.2em; }
.cs-list li + li { margin-top: var(--s-2); }
.cs-list li::before {
  content: "";
  position: absolute;
  left: 0; top: 0.72em;
  width: 6px; height: 2px;
  background: var(--amber);
}
/* A sub-heading after a list - "Malaysia" under the Singapore hotels - had
   nothing separating it from the last bullet, because only `p + p` carried
   a margin. */
.cs-list + p { margin-top: var(--s-4); }

/* Standalone figures are held well inside the shell width. Several of these
   sources top out between 1000 and 1600px, and stretching them across a
   76rem column was showing JPEG artefacts; at 52rem even the smallest of
   them still has pixels to spare on a 2x screen. Paired figures are already
   at half width, so they opt out. */
.cs-figure {
  margin: var(--s-6) auto;
  max-width: 52rem;
}
/* For a source small enough that even 52rem would upscale it. */
.cs-figure--narrow { max-width: 30rem; }
.cs-pair .cs-figure { max-width: none; }

/* Two figures reading as one comparison - the research pair, the onboarding
   pair, the booking pair. Collapses to a single column below 56rem, where
   two half-width screenshots would be too small to read.
   `align-items: start` lets each column keep its own natural height rather
   than stretching the shorter image's frame to match its neighbour. */
.cs-pair {
  display: grid;
  gap: var(--s-5);
  margin: var(--s-6) 0;
}
.cs-pair .cs-figure { margin: 0; }

@media (min-width: 56rem) {
  .cs-pair {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: auto auto;
    gap: var(--s-4);
    align-items: start;
  }

  /* The two figures dissolve into the parent grid so that both images share
     row one and both notes share row two. Without this the shorter image
     pulls its note up level with its neighbour's picture, and the row reads
     as broken rather than as a pair.
     These images vary a lot in proportion - 1.06 to 2.09 - so forcing them
     to a common frame would mean heavy letterboxing; letting them keep their
     own heights and aligning only the notes is the better trade. */
  .cs-pair .cs-figure { display: contents; }

  .cs-pair > .cs-figure:nth-of-type(1) > .shot       { grid-area: 1 / 1; }
  .cs-pair > .cs-figure:nth-of-type(1) > figcaption  { grid-area: 2 / 1; }
  .cs-pair > .cs-figure:nth-of-type(2) > .shot       { grid-area: 1 / 2; }
  .cs-pair > .cs-figure:nth-of-type(2) > figcaption  { grid-area: 2 / 2; }
}
/* A caption here is Liliya's read on the work, not a description of what is
   already visible in the picture - so it is set as a spoken aside rather
   than a grey label under a photo: an amber opening quote, brighter type,
   and a rule holding it to the image's left edge. */
.cs-figure figcaption {
  position: relative;
  margin-top: var(--s-4);
  padding: 0.15rem 0 0.15rem var(--s-5);
  border-left: 2px solid rgba(255, 122, 26, 0.35);
  font-size: var(--t-md);
  line-height: 1.5;
  color: rgba(245, 243, 240, 0.92);
  max-width: 58ch;
}
.cs-figure figcaption::before {
  content: "\201C";
  position: absolute;
  left: var(--s-2);
  top: -0.34em;
  font-size: 3.2rem;
  line-height: 1;
  font-weight: 700;
  color: var(--amber);
}

/* The exception: a short factual label under an image, which wants to read
   as a plate caption, centred beneath the picture. */
.cs-figure--label figcaption {
  padding: 0;
  border-left: 0;
  margin-top: var(--s-4);
  text-align: center;
  max-width: none;
  font-size: var(--t-lg);
  font-weight: 600;
  letter-spacing: var(--track-snug);
  color: var(--ivory);
}
.cs-figure--label figcaption::before { content: none; }

.next {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  text-decoration: none;
  padding-block: var(--s-2);
}
/* The last thing anyone touches before deciding whether to keep reading, so
   it should feel like a door opening: a warm wash sweeps across behind it. */
.next::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(to right,
              rgba(255, 122, 26, 0.14), rgba(255, 122, 26, 0) 75%);
  opacity: 0;
  transform: translateX(-2rem);
  transition: opacity var(--d-base) var(--ease),
              transform 0.55s var(--ease-out);
}
.next:hover::before { opacity: 1; transform: none; }

.next__label {
  color: var(--muted);
  font-size: var(--t-md);
  font-weight: 500;
}
.next__client {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--t-xl);
  font-weight: 700;
  letter-spacing: var(--track-tight);
  transition: color 0.3s var(--ease);
}
/* Drawn in CSS rather than added to the markup of six case studies. */
.next__client::after {
  content: "→";
  color: var(--amber);
  transition: transform 0.42s var(--ease-out);
}
.next:hover .next__client { color: var(--amber); }
.next:hover .next__client::after { transform: translateX(7px); }

/* ---- About ------------------------------------------------------------- */

.cv { display: grid; gap: var(--s-4); position: relative; }

/* An amber rule running the length of the roles, drawn down as you read
   through them. Static full height where view() timelines are unavailable,
   which still reads as a deliberate timeline rather than a broken one. */
@media (min-width: 52rem) {
  .cv::before {
    content: "";
    position: absolute;
    left: -1.4rem;
    top: 0; bottom: 0;
    width: 1px;
    background: var(--amber);
    opacity: 0.4;
    transform-origin: top center;
  }
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      .cv::before {
        animation: cv-draw linear both;
        animation-timeline: view();
        animation-range: entry 30% cover 70%;
      }
      @keyframes cv-draw {
        from { transform: scaleY(0); }
        to   { transform: scaleY(1); }
      }
    }
  }
}

.cv__role {
  display: grid;
  gap: var(--s-1);
  padding: var(--s-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  background: var(--canvas-raised);
}
@media (min-width: 52rem) {
  .cv__role { grid-template-columns: 11rem minmax(0, 1fr); gap: var(--s-4); padding: var(--s-4); }
}
.cv__when {
  color: var(--amber);
  font-size: var(--t-sm);
  font-weight: 600;
  transition: transform 0.6s var(--ease-out);
}
/* The date arrives fractionally ahead of its card, so the eye tracks the
   chronology down the column. */
.js .cv__role[data-reveal]:not(.is-revealed) .cv__when { transform: translateX(-0.5rem); }
.cv__title {
  font-size: var(--t-md);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  margin-bottom: var(--s-2);
}
.cv__body { color: var(--muted); font-size: var(--t-body-sm); max-width: var(--measure); }
.cv__body li { position: relative; padding-left: 1.1em; }
.cv__body li + li { margin-top: var(--s-1); }
.cv__body li::before {
  content: "";
  position: absolute;
  left: 0; top: 0.7em;
  width: 5px; height: 2px;
  background: var(--amber);
  transform-origin: left center;
  transition: transform 0.45s var(--ease-out);
}
/* The dashes draw out as the card lands. Tiny, and exactly the sort of
   detail that reads as care rather than as decoration. */
.js .cv__role[data-reveal]:not(.is-revealed) .cv__body li::before { transform: scaleX(0); }
.cv__body li:nth-child(1)::before { transition-delay: 120ms; }
.cv__body li:nth-child(2)::before { transition-delay: 160ms; }
.cv__body li:nth-child(3)::before { transition-delay: 200ms; }
.cv__body li:nth-child(n+4)::before { transition-delay: 240ms; }

.stack-label {
  font-size: var(--t-md);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  margin-bottom: var(--s-3);
}
.stack-label + .tags { margin-bottom: var(--s-5); font-size: var(--t-sm); }


/* ---- One orchestrated entrance, on load only --------------------------- */
/* CSS-only and on load: above-the-fold content must never wait on
   JavaScript, so this is deliberately not part of the reveal observer below.

   Six steps, and the last one is `n+6` rather than a fixed index. .hero__lead
   has five children but only four were listed here, so the fifth - the
   buttons - fell through to the default delay of 0 and animated ahead of her
   name. The catch-all makes that class of bug impossible to reintroduce. */

.enter > * {
  opacity: 0;
  transform: translateY(var(--rise));
  animation: rise var(--d-enter) var(--ease-out) forwards;
}
.enter > *:nth-child(1)   { animation-delay: calc(var(--stagger) * 0.7); }
.enter > *:nth-child(2)   { animation-delay: calc(var(--stagger) * 1.7); }
.enter > *:nth-child(3)   { animation-delay: calc(var(--stagger) * 2.7); }
.enter > *:nth-child(4)   { animation-delay: calc(var(--stagger) * 3.7); }
.enter > *:nth-child(5)   { animation-delay: calc(var(--stagger) * 4.7); }
.enter > *:nth-child(n+6) { animation-delay: calc(var(--stagger) * 5.7); }
@keyframes rise { to { opacity: 1; transform: none; } }

/* ---- Scroll reveals ---------------------------------------------------- */
/* Below-the-fold only, and gated on `.js` exactly like the filter bar: with
   JavaScript off, none of this applies and the page renders complete and
   readable. Each element is observed once and then released - content that
   re-animates on the way back up punishes anyone re-reading the page.

   Everything travels the same distance in the same direction. The stagger
   index is set by JS as a plain number; multiplying it by --stagger here
   keeps the token the single source of truth, and means reduced-motion
   flattens every cascade to zero for free. */

.js [data-reveal] {
  opacity: 0;
  transform: translateY(var(--rise));
  transition: opacity var(--d-enter) var(--ease-out),
              transform var(--d-enter) var(--ease-out);
  transition-delay: calc(var(--reveal-i, 0) * var(--stagger));
}
.js [data-reveal].is-revealed { opacity: 1; transform: none; }

/* A long list of short chips revealed at the standard interval is tedious,
   so groups can opt into a quicker, lighter cascade. */
.js [data-reveal-group="fast"] [data-reveal] {
  transform: translateY(var(--rise)) scale(0.94);
  transition-delay: calc(var(--reveal-i, 0) * 26ms);
}
.js [data-reveal-group="fast"] [data-reveal].is-revealed { transform: none; }

/* Variant: the heading's words rise out of their masks instead of the block
   fading as a whole. Same specificity as the base rule, so it has to sit
   after it. */
.js [data-reveal="words"] { opacity: 1; transform: none; }
.js [data-reveal="words"] .word > span {
  transform: translateY(110%);
  transition: transform 0.9s var(--ease-out);
}
.js [data-reveal="words"].is-revealed .word > span { transform: none; }
.js [data-reveal="words"] .word:nth-child(1) > span { transition-delay: 0ms;   }
.js [data-reveal="words"] .word:nth-child(2) > span { transition-delay: 60ms;  }
.js [data-reveal="words"] .word:nth-child(3) > span { transition-delay: 120ms; }
.js [data-reveal="words"] .word:nth-child(4) > span { transition-delay: 180ms; }
.js [data-reveal="words"] .word:nth-child(n+5) > span { transition-delay: 240ms; }

/* Variant: artwork is wiped in from its bottom edge rather than faded. A
   wipe reads as a print being revealed; a fade reads as loading. The image
   settles out of a slight over-scale at the same time. */
.js [data-reveal="wipe"] {
  opacity: 1;
  transform: none;
  clip-path: inset(100% 0 0 0);
  transition: clip-path var(--d-slow) var(--ease-out);
}
.js [data-reveal="wipe"].is-revealed { clip-path: inset(0 0 0 0); }
.js [data-reveal="wipe"] img {
  transform: scale(1.06);
  transition: transform 1.5s var(--ease-out);
}
.js [data-reveal="wipe"].is-revealed img { transform: none; }

/* The emphasised phrase arrives rather than being pre-baked: it fades up in
   ivory and turns amber a beat later, which puts the eye exactly on the
   words carrying the positioning. */
.js .band__title[data-reveal] em,
.js .split__title[data-reveal] em {
  color: var(--ivory);
  transition: color 0.5s var(--ease) 0.45s;
}
.js .band__title[data-reveal].is-revealed em,
.js .split__title[data-reveal].is-revealed em { color: var(--amber); }

@media (prefers-reduced-motion: reduce) {
  .btn:hover, .work-card:hover { transform: none; }
}

/* ---- Page title (About, 404) ------------------------------------------- */

.page-title {
  font-size: var(--t-2xl);
  font-weight: 800;
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
  max-width: 18ch;
}
.page-title em { font-style: normal; color: var(--amber); }
/* A lead paragraph outside the centred contact block. */
.lede { max-width: 46ch; color: var(--muted); font-size: var(--t-md); margin-block: var(--s-3) var(--s-4); }

/* ---- Touch targets ----------------------------------------------------- */
/* WCAG 2.5.8 wants interactive targets at least 24px. These are short text
   links, so grow the hit box with padding rather than the type size. The .ul
   underline is pinned back to the text baseline so it doesn't drift down. */

/* Padding only, no `display` - these selectors outrank the .is-services /
   .is-compact visibility rules, so setting display here would re-show links
   that are meant to be hidden at that breakpoint. Every one of these anchors
   is already a flex item, so padding alone grows the hit box. */
.wordmark,
.masthead__nav a:not(.btn) { padding-block: 0.42rem; }

.contact__rail a,
.colophon a { padding-block: 0.32rem; }

.contact__rail .ul,
.colophon .ul { background-position: 0 calc(100% - 0.32rem); }
.contact__rail .ul:hover,
.colophon .ul:hover { background-position: 100% calc(100% - 0.32rem); }

/* ---- Linked media ------------------------------------------------------ */
/* A .shot that is itself a link - the podcast cover going out to YouTube.
   The frame already carries the border and radius, so the link only has to
   announce itself: a lift on hover and a badge in the corner, borrowed from
   .work-card so a clickable picture reads the same way everywhere. */
.shot--link {
  display: block;
  position: relative;
}
/* The reveal sits on this same element, so the second selector is here only
   to outrank `.js [data-reveal="wipe"]` - without it the wipe's own
   transition list would win and the hover lift would snap. clip-path is
   carried along for the same reason: it is the reveal. */
.shot--link,
.js .shot--link[data-reveal="wipe"] {
  transition: transform 0.42s var(--ease-out),
              border-color var(--d-base) var(--ease),
              clip-path var(--d-slow) var(--ease-out);
}
.shot--link:hover { transform: translateY(-4px); border-color: var(--rule-strong); }
.shot--link__go {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  z-index: 1;
  display: grid;
  place-items: center;
  width: 2.1rem; height: 2.1rem;
  border-radius: 50%;
  background: var(--amber);
  color: var(--canvas);
  opacity: 0;
  transform: translate(-0.4rem, 0.4rem);
  transition: opacity var(--d-base) var(--ease),
              transform 0.42s var(--ease-out);
}
.shot--link__go svg { width: 0.9rem; height: 0.9rem; }
.shot--link:hover .shot--link__go,
.shot--link:focus-visible .shot--link__go { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .shot--link:hover { transform: none; }
}

/* ---- Education timeline ------------------------------------------------ */
/* A dated list rather than a grid of equal cards: the point of this section
   is the run - art school through to a degree in machine learning - and a
   grid flattens twenty-one years into five tiles of the same size.

   Three columns: the mark on its rail, the entry, and when it happened. */

.edu { margin-top: var(--s-5); }

.edu__row {
  position: relative;
  display: grid;
  grid-template-columns: 2.75rem minmax(0, 1fr) auto;
  column-gap: var(--s-3);
  align-items: start;
  padding: var(--s-4) 0;
  border-top: 1px solid var(--rule);
}
/* The disclosure takes its own row across the entry and the date column, so
   its toggle sits at the right edge of the list, directly under the years,
   rather than trailing whatever line the lead happens to end on. */
.edu__d { grid-column: 2 / -1; }
.edu__row:first-child { border-top: 0; padding-top: 0; }

/* The rail is drawn per row and joins up into one continuous line, which
   keeps it correct however far the disclosures are opened. It stops at the
   first and last marks rather than running off both ends. */
.edu__row::before {
  content: "";
  position: absolute;
  left: 1.375rem;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--rule);
}
.edu__row:first-child::before { top: 1.375rem; }
.edu__row:last-child::before {
  bottom: auto;
  height: calc(var(--s-4) + 1.375rem);
}

.edu__mark {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--rule);
  background: var(--canvas-raised);
  color: var(--amber);
}
.edu__icon { width: 1.3rem; height: 1.3rem; }

.edu__title {
  font-size: var(--t-md);
  font-weight: 700;
  letter-spacing: var(--track-snug);
  line-height: 1.25;
}
.edu__org {
  margin-top: 0.2em;
  font-size: var(--t-body-sm);
  color: var(--muted);
}

/* When it happened, right-aligned and holding its own column so the dates
   line up down the page - the thing a reader scans this section for. */
.edu__when {
  display: grid;
  justify-items: end;
  gap: 0.15em;
  text-align: right;
  padding-top: 0.15em;
}
.edu__years {
  font-size: var(--t-body-sm);
  font-weight: 600;
  color: var(--ivory);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.edu__dur { font-size: var(--t-xs); color: var(--muted); white-space: nowrap; }

/* ---- Disclosure -------------------------------------------------------- */
/* A native <details>, so the first lines are readable with the script off
   and the toggle keeps its keyboard and screen-reader behaviour for free.
   The lead sits in the summary rather than being a two-line clamp of the
   full text: a clamp cuts mid-sentence, and an authored lead does not. */

.edu__d { margin-top: var(--s-2); }
.edu__sum {
  display: flex;
  align-items: start;
  gap: var(--s-3);
  cursor: pointer;
  list-style: none;
  border-radius: var(--r-sm);
}
.edu__sum::-webkit-details-marker { display: none; }
.edu__sum:focus-visible { outline: 2px solid var(--amber); outline-offset: 3px; }

.edu__lead {
  flex: 1;
  font-size: var(--t-body-sm);
  line-height: 1.55;
  color: rgba(245, 243, 240, 0.9);
}
.edu__sum:hover .edu__lead { color: var(--ivory); }

.edu__plus {
  flex: none;
  display: grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  margin-top: -0.1rem;
  border-radius: 50%;
  border: 1px solid var(--rule);
  color: var(--amber);
  transition: rotate 0.35s var(--ease-out),
              background var(--d-base) var(--ease),
              color var(--d-base) var(--ease);
}
.edu__plus svg { width: 0.7rem; height: 0.7rem; }
.edu__sum:hover .edu__plus { background: var(--amber); color: var(--canvas); border-color: var(--amber); }
/* The plus becomes a close mark on open - same glyph, turned. */
.edu__d[open] .edu__plus { rotate: 45deg; }

.edu__rest {
  margin-top: var(--s-2);
  padding-right: calc(1.75rem + var(--s-3));
  font-size: var(--t-body-sm);
  line-height: 1.6;
  color: var(--muted);
  max-width: 62ch;
}

@media (prefers-reduced-motion: reduce) {
  .edu__plus { transition: background var(--d-base) var(--ease), color var(--d-base) var(--ease); }
}

/* Below 44rem the date column would squeeze the title into two words a
   line, so it moves under the entry and reads as a single line instead. */
@media (max-width: 44rem) {
  .edu__row { grid-template-columns: 2.25rem minmax(0, 1fr); }
  .edu__row::before { left: 1.125rem; }
  .edu__row:first-child::before { top: 1.125rem; }
  .edu__row:last-child::before { height: calc(var(--s-4) + 1.125rem); }
  .edu__mark { width: 2.25rem; height: 2.25rem; }
  .edu__icon { width: 1.1rem; height: 1.1rem; }
  .edu__when {
    grid-column: 2;
    grid-row: 1;
    justify-items: start;
    text-align: left;
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: baseline;
    padding-top: 0;
    margin-bottom: 0.15em;
  }
  .edu__main { grid-column: 2; }
  .edu__d { grid-column: 2; }
  .edu__rest { padding-right: 0; }
}

/* ---- Education pull quote ---------------------------------------------- */
/* Her own line, not a borrowed one, closing the section that the list only
   implies: that the going-back is the skill. */
.edu__quote {
  margin: var(--s-6) 0 0;
  padding-left: var(--s-5);
  border-left: 2px solid rgba(255, 122, 26, 0.35);
  max-width: 54ch;
}
.edu__quote blockquote { margin: 0; }
.edu__quote p {
  font-size: var(--t-md);
  line-height: 1.5;
  color: var(--ivory);
  text-wrap: balance;
}

/* ---- Social media mosaic ------------------------------------------------ */
/* A channel's output shown as a wall rather than a sequence: reels at 9:16,
   posts at 1:1 and 4:5, each keeping its own proportions. CSS columns, not
   grid - true masonry is what makes the ratios readable, and the reading
   order down a column is fine for a set of unrelated posts where grid's
   equal-height rows would letterbox two thirds of them.

   The wall shares .cs-figure's centred column so it lines up with the
   figures above and below. */
.cs-wall {
  max-width: 52rem;
  margin: var(--s-6) auto;
}
/* A wall of small tiles wants more room than the figure column's reading
   measure. */
.cs-wall--wide { max-width: 64rem; }
.cs-wall__note { color: var(--muted); font-size: var(--t-body-sm); max-width: 54ch; }

/* Four columns, not three. With twelve pieces in three ratios, three columns
   divide into four identical stacks and the wall snaps back into rows -
   exactly what a mosaic is meant to avoid. Four forces the six reels to
   split unevenly, and the columns end ragged. */
.mosaic {
  margin-top: var(--s-4);
  columns: 4;
  column-gap: var(--s-3);
}
@media (max-width: 62rem) { .mosaic { columns: 3; } }
@media (max-width: 40rem) { .mosaic { columns: 2; } }
@media (max-width: 24rem) { .mosaic { columns: 1; } }

/* For a wall of landscape pieces - renders, illustration - where four
   columns would reduce each one to a strip. */
.mosaic--roomy { columns: 3; }
@media (max-width: 52rem) { .mosaic--roomy { columns: 2; } }
@media (max-width: 30rem) { .mosaic--roomy { columns: 1; } }

/* A tile is a button, not a link: it opens the piece in place rather than
   navigating anywhere. `break-inside: avoid` is what keeps a tile from being
   split across the column boundary. */
.mosaic__item {
  display: block;
  width: 100%;
  padding: 0;
  margin: 0 0 var(--s-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  background: var(--canvas-raised);
  overflow: hidden;
  cursor: pointer;
  position: relative;
  break-inside: avoid;
  transition: transform 0.42s var(--ease-out),
              border-color var(--d-base) var(--ease);
}
.mosaic__item img { width: 100%; height: auto; display: block; }
.mosaic__item:hover,
.mosaic__item:focus-visible { transform: translateY(-4px); border-color: var(--rule-strong); }

/* Video tiles carry a play mark and their running time, as one pill in the
   top-right corner. It sits at rest rather than appearing on hover, because
   it is saying what the thing is, not that it is clickable - and on a phone
   there is no hover to reveal it with.

   The corner, not the centre: these reels have their hook text burnt into
   the frame, and a centred play button lands straight on the copy that is
   the work being shown. */
.mosaic__play {
  position: absolute;
  top: var(--s-1);
  right: var(--s-1);
  display: flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.28rem 0.5rem 0.28rem 0.45rem;
  border-radius: var(--r-pill);
  background: rgba(11, 11, 12, 0.72);
  border: 1px solid rgba(245, 243, 240, 0.28);
  color: var(--ivory);
  font-size: 0.76rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  transition: background var(--d-base) var(--ease),
              color var(--d-base) var(--ease),
              border-color var(--d-base) var(--ease);
}
.mosaic__play svg { width: 0.6rem; height: 0.6rem; fill: currentColor; }
.mosaic__item:hover .mosaic__play {
  background: var(--amber);
  color: var(--canvas);
  border-color: var(--amber);
}

@media (prefers-reduced-motion: reduce) {
  .mosaic__item:hover,
  .mosaic__item:focus-visible { transform: none; }
}

/* ---- Back to top -------------------------------------------------------- */
/* Sits under the masthead's z-index so a sticky bar always wins, and inside
   the safe-area inset so it clears the home indicator on a phone. */
.to-top {
  position: fixed;
  right: max(clamp(1rem, 3vw, 2rem), env(safe-area-inset-right));
  bottom: max(clamp(1rem, 3vw, 2rem), env(safe-area-inset-bottom));
  z-index: 40;
  display: grid;
  place-items: center;
  width: 2.9rem;
  height: 2.9rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--rule-strong);
  background: rgba(11, 11, 12, 0.72);
  backdrop-filter: blur(6px);
  color: var(--ivory);
  cursor: pointer;
  /* Out of the layer until it is wanted: visibility keeps it off the tab
     order while hidden, which opacity alone would not. */
  opacity: 0;
  visibility: hidden;
  translate: 0 0.6rem;
  transition: opacity var(--d-base) var(--ease),
              translate 0.42s var(--ease-out),
              visibility 0s linear var(--d-base),
              background var(--d-base) var(--ease),
              color var(--d-base) var(--ease),
              border-color var(--d-base) var(--ease);
}
.to-top.is-shown {
  opacity: 1;
  visibility: visible;
  translate: 0;
  transition-delay: 0s;
}
.to-top svg {
  width: 1rem;
  height: 1rem;
  rotate: -90deg;
  fill: none;
  stroke: currentColor;
}
.to-top:hover,
.to-top:focus-visible {
  background: var(--amber);
  color: var(--canvas);
  border-color: var(--amber);
}

@media (prefers-reduced-motion: reduce) {
  .to-top { transition: opacity var(--d-base) linear, visibility 0s linear var(--d-base); translate: 0; }
  .to-top.is-shown { transition-delay: 0s; }
}

/* ---- Lightbox ----------------------------------------------------------- */
/* A native <dialog>, which brings the focus trap, the Escape key and the
   backdrop with it - all of which a hand-rolled overlay has to reimplement
   and usually gets wrong. With JS off the tiles are inert and the gallery is
   still a complete, readable wall of artwork, which is why the tiles are
   buttons rather than links to files nobody wants to download. */
.lightbox {
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--ivory);
  overflow: hidden;
  /* Explicit rather than leaning on the UA's dialog defaults: it zeroes the
     `margin: auto` that would otherwise fight `place-items`, and it makes
     the dialog the containing block for the absolutely positioned close and
     step buttons. */
  position: fixed;
  inset: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  max-width: 100vw;
  max-height: 100vh;
}
.lightbox::backdrop { background: rgba(6, 6, 7, 0.92); }
.lightbox[open] { display: grid; place-items: center; }

.lightbox__stage {
  display: grid;
  place-items: center;
  gap: var(--s-3);
  padding: var(--s-5) var(--s-4);
  max-width: min(100%, 70rem);
}
/* The media is capped by viewport height, not width: every piece here is
   square or taller, so height is the binding constraint and a width cap
   would leave a 9:16 reel running off the bottom of the screen. */
.lightbox__media {
  max-height: min(78vh, 46rem);
  max-width: 100%;
  width: auto;
  border-radius: var(--r-md);
  background: #000;
  display: block;
}
.lightbox__caption {
  margin: 0;
  font-size: var(--t-body-sm);
  color: var(--muted);
  text-align: center;
  max-width: 46ch;
}

.lightbox__btn {
  position: absolute;
  display: grid;
  place-items: center;
  width: 2.75rem; height: 2.75rem;
  border-radius: 50%;
  border: 1px solid var(--rule-strong);
  background: rgba(11, 11, 12, 0.7);
  color: var(--ivory);
  cursor: pointer;
  transition: background var(--d-base) var(--ease), border-color var(--d-base) var(--ease);
}
.lightbox__btn:hover { background: var(--amber); color: var(--canvas); border-color: var(--amber); }
.lightbox__btn svg { width: 1.1rem; height: 1.1rem; }
.lightbox__close { top: var(--s-3); right: var(--s-3); }
.lightbox__prev  { left: var(--s-3);  top: 50%; translate: 0 -50%; }
.lightbox__next  { right: var(--s-3); top: 50%; translate: 0 -50%; }
.lightbox__prev svg { rotate: 180deg; }

/* Below 46rem the side arrows would sit on top of the artwork, so they move
   under it and become a pair. */
@media (max-width: 46rem) {
  .lightbox__prev, .lightbox__next {
    top: auto; bottom: var(--s-3); translate: none;
  }
  .lightbox__prev { left: calc(50% - 3.5rem); }
  .lightbox__next { right: calc(50% - 3.5rem); }
  .lightbox__stage { padding-bottom: var(--s-7); }
}
