/* Base rules carried over verbatim from the design's <helmet> block. */
html { scroll-behavior: smooth }
body {
  margin: 0;
  background: #0C0C0B;
  color: #F2F0EA;
  font-family: 'Space Grotesk', sans-serif;
  -webkit-font-smoothing: antialiased;
}
::selection { background: #F2F0EA; color: #0C0C0B }

/* ---------------------------------------------------------- hero + marquee fit
   The hero and the marquee band beneath it are budgeted to fill exactly one
   screen between them, so the hero's own ticker AND the marquee both land above
   the fold. The band's height is declared here instead of being whatever its
   padding adds up to, because the hero subtracts this same value from its own
   height — declaring it once is what stops the two from drifting apart the next
   time someone touches the band's type or padding.

   svh, not vh: on mobile 100vh is the toolbar-retracted height, which is not the
   height the page opens at, so a vh-budgeted hero starts life already overflowing.
   The @supports guard is doing real work here — `min-height: calc(100svh - var(…))`
   in an engine without svh is invalid at computed-value time, which resolves to
   `auto` rather than falling back to a previous declaration the way a plain
   duplicated property would. Older engines get the vh arithmetic instead. */
:root {
  --marquee-h: clamp(40px, 6.2vh, 55px);
  --hero-h: calc(100vh - var(--marquee-h));
}
@supports (height: 100svh) {
  :root { --hero-h: calc(100svh - var(--marquee-h)) }
}

/* Phones opt out of both. The hero's footer row is two pieces of ambient
   metadata — a scroll affordance for a surface that is already obviously
   scrollable, and a credit line the subhead two inches above it already gives
   in full — and on a 351px line box they cost a wrapped, gap-defended, width-
   scaled row to say nothing new. The marquee is a horizontal ticker on the one
   viewport where horizontal room is the scarce thing; at 12px/.22em a phone
   sees about three of its eight terms at a time.

   Dropping --marquee-h to 0 rather than hiding the band alone is what hands
   the space back: --hero-h is defined against this variable, so the hero
   collects the band's height and fills the screen on its own. Both display
   rules need !important because each element carries an inline display:flex.
   site.js mirrors the 520px floor at 521px to skip their two infinite tweens. */
@media (max-width: 520px) {
  :root { --marquee-h: 0px }
  [data-intro="foot"],
  [data-intro="band"] { display: none !important }
}

/* ------------------------------------------------------- work slides: phone
   A project slide is a claim on the left and the product on the right. Stacked
   into one column on a phone the two stop being peers and start competing for
   the same 685px, and the text won: on 04 and 06 it took two thirds of the
   panel and left the mockup at 23% of its authored size — a complete, unreadable
   thumbnail floating over 195px of nothing.

   The order of operations below is what fixes that. Take the text down to the
   size it should have been on a phone anyway, pin each mockup to the narrowest
   width its own layout still holds at, and let site.js scale what is left to the
   column width and crop the rest. */
@media (max-width: 820px) {
  [data-slide] { padding: 60px 6vw 52px !important }
  [data-slide] > div:first-child { flex-wrap: wrap; gap: 3px 18px; font-size: 9.5px !important }
  [data-slide] > div:nth-child(2) {
    display: flex !important;
    flex-direction: column;
    align-items: stretch !important;
    justify-content: center;
    gap: 18px !important;
    margin-top: 8px !important;
    min-height: 0;
  }
  /* Scaled off --logo-h rather than pinned to a fixed px, so a panel that sets
     a taller mark stays proportionally taller here too. */
  [data-s-logo] { height: calc(var(--logo-h) * .55) !important; margin-bottom: 11px !important }
  [data-s-title] { font-size: clamp(27px, 7.4vw, 40px) !important; line-height: 1.03 !important }
  /* Four lines, then an ellipsis. The two longest descriptions ran six and the
     sixth line was buying nothing the title and the chips had not already said.
     The full text is still in the DOM for anyone reading it aloud. */
  [data-s-desc] {
    font-size: 13.5px !important;
    line-height: 1.5 !important;
    margin-top: 11px !important;
    display: -webkit-box;
    -webkit-line-clamp: 4;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  /* Chips are a metadata strip, not body copy, and on a phone they were the
     largest tenant of the text column — panel 01's four long ones stacked four
     rows deep at ~200px, more than its title and description together, all of it
     taken straight out of the mockup. At the smallest size this mono face still
     reads at, with the tracking pulled in to match, they pair up two to a row
     and cost about a third of that. None of them are hidden. */
  [data-slide] div:has(> [data-s-chip]) { gap: 7px !important; margin-top: 15px !important }
  [data-s-chip] { font-size: 8.5px !important; letter-spacing: .09em !important; padding: 6px 11px !important }

  /* ---- the mockup window --------------------------------------------------
     --mock-w is the width each mockup is laid out AT before site.js scales it,
     and it is per panel because these are four different products: the value is
     the narrowest each one's own grid still resolves at without spilling past
     its bezel (min-contents measured at 648 / 482 / 364 / 408 px). Left to
     shrink-wrap they measure 700–1500px instead, and the scale that lands them
     in a 343px column is the difference between an interface and a smudge.

     #p2-proto and #pg-proto are excluded: those two are fixed-size device frames
     around an internally-scaled canvas, so there is no reflow to ask for — they
     scale whole, and the exclusion is why they still do.

     [data-ghost-dot] is excluded because it is not a mockup. It is the 24px
     cursor the idle director injects, and it is a direct child of this wrapper
     like the card is. The rule that used to live here pinned it with min-width,
     which an inline width:24px cannot beat, so the dot became a 640px lozenge
     with border-radius:50% lying across the middle of panels 03, 04 and 06 —
     the "broken screen" this pass was opened for. */
  #p1 { --mock-w: 660px }
  #p3 { --mock-w: 560px }
  #p4 { --mock-w: 520px }
  #p5 { --mock-w: 400px }
  [data-s-mock] {
    display: flex !important;
    justify-content: center;
    /* not stretch: site.js gives this wrapper an explicit height when the card
       overflows it, and a stretched card would grow into that height instead of
       being clipped by it. */
    align-items: flex-start;
    min-height: 0;
    position: relative;
  }
  [data-s-mock] > div:not(#p2-proto):not(#pg-proto):not([data-ghost-dot]) {
    box-sizing: border-box;
    width: var(--mock-w, 560px);
    min-width: 0;
    flex: none;
    /* Belt and braces: --mock-w is chosen above the card's min-content, so
       nothing should reach for more room than this. If a panel's copy grows
       until something does, it gets clipped at the bezel rather than spilling
       out over the panel background. */
    overflow: hidden;
  }
  [data-s-mock] > #p2-proto, [data-s-mock] > #pg-proto { flex: none }

  /* Scaled to the column width, a mockup can still be taller than the room the
     text left. Shrinking it further to make it fit is what produced the 23%
     thumbnails, so instead the wrapper becomes a window onto the top of it and
     the cut edge fades out rather than ending on a rule. A screen you are
     looking at the top of is what every app on the device doing the looking is
     too — it reads as depth, not as truncation. site.js adds the class and the
     height together; neither is any use without the other. */
  [data-s-mock].is-cropped {
    overflow: hidden;
    -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 64px), transparent);
    mask-image: linear-gradient(180deg, #000 calc(100% - 64px), transparent);
  }

  /* ---- panel 06, the Skill Builder catalog --------------------------------
     Three things this mockup was doing at 470px that no product does on a
     phone, and the first one pays for the other two.

     The nav rail goes. It was spending a quarter of the layout width on six
     labels set at 7.5px, which the mock's own scale then took another 27% off
     — 5.5px of navigation nobody reads, in the one place the panel could least
     afford the room. Dropping the rail at this width is what the real product
     would do and what every app on the device doing the looking already does.

     That room goes into the cards. The grid drops from three columns to two
     and the mock lays out at 400px instead of 470, so a card goes from 145 to
     183 layout pixels and the scale from 0.73 to 0.86 — 106px on screen
     becomes 157px. Below about 140 these stop being course cards and start
     being grey rectangles with a shadow of type in them.

     The card description goes with the rail, for the same reason the rail did:
     9px under that scale is 7.7px on screen, and it was clipping mid-word in
     all six cards. The level, duration, rating and enrolment counts around it
     survive the size and carry the same meaning. What is left is then clamped
     to two lines with both reserved, because the ragged titles were the actual
     reason the grid read as broken — six cards whose rows start at six
     different heights look like a layout bug, not like density. */
  #p5-side { display: none !important }
  #p5-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important }
  #p5-grid > div { padding: 11px !important }
  #p5-grid > div > div:nth-child(2) {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* 12.5px at line-height 1.3 — reserves the second line so a one-line title
       does not pull its neighbours up off the shared baseline. */
    min-height: 2.6em;
  }
  #p5-grid > div > div:nth-child(4) { display: none !important }
  /* Three pills and a spacer. Flex was letting all three shrink until ALL
     LEVELS and ALL DOMAINS broke across two lines inside their own borders,
     which is the one thing a pill must never do — they are labels, not copy. */
  #p5-filters > span { flex: none; white-space: nowrap }
}

/* ----------------------------------------------------------------- panel logo
   The wrapper owns the height (set inline per panel by PanelLogo.astro) and the
   mark scales to it off its own viewBox, so marks of different aspect ratios
   sit on the same optical line. Colour is inherited: the monochrome SVGs are
   drawn with currentColor, so each mark picks up the ink of the panel it lands
   on rather than carrying a fill that only suits one background. */
[data-s-logo] > svg { height: 100%; width: auto; aspect-ratio: var(--logo-ar); display: block }

/* ---------------------------------------------------------------- image slots
   The design used a drag-and-drop <image-slot> widget for the portrait and the
   three personal-project shots. None were filled, so they compile to empty
   frames — drop an <img> inside one and it takes over with no other changes. */
.img-slot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  box-sizing: border-box;
  background: rgba(242, 240, 234, .035);
  border: 1.5px dashed rgba(242, 240, 234, .22);
  text-align: center;
  padding: 16px;
}
.img-slot > span {
  font-family: 'Space Mono', monospace;
  font-size: 10px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(242, 240, 234, .38);
  max-width: 80%;
}
.img-slot > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.img-slot:has(img) { border-style: solid; border-color: transparent }
.img-slot:has(img) > span { display: none }

/* Hover video layer: sits over the still, invisible until Personal.astro's
   hover script marks it playing — so browsers that can't decode the webm (or
   are still fetching it) keep showing the screenshot underneath. */
.img-slot > video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity .35s ease, transform .7s cubic-bezier(.22, 1, .36, 1);
  pointer-events: none;
}
.img-slot > video.is-playing { opacity: 1 }
.img-slot:has(video) { border-style: solid; border-color: transparent }
.img-slot:has(video) > span { display: none }

/* -------------------------------------------------------- personal cards
   Hover: the card lifts, a cursor-tracking spotlight sweeps the surface
   (--mx/--my fed by Personal.astro's pointermove handler), the shot zooms a
   touch, and the label arrow nudges out. The card is a CHILD of the stagger
   cell rather than the stagger target itself: the reveal tween leaves an
   inline transform behind on whatever it animates, which would beat the CSS
   lift here — same lesson as .btn. */
.pp-card {
  position: relative;
  isolation: isolate;
  height: 100%;
  box-sizing: border-box;
  background: #141412;
  border: 1px solid rgba(242, 240, 234, .12);
  border-radius: 14px;
  padding: 16px;
  transition: transform .5s cubic-bezier(.22, 1, .36, 1), border-color .35s ease;
}
.pp-card:hover { transform: translateY(-5px); border-color: rgba(242, 240, 234, .3) }
.pp-card::after {
  content: '';
  position: absolute;
  inset: -1px;                /* past the border so the sheen reaches the edge */
  border-radius: inherit;
  background: radial-gradient(460px circle at var(--mx, 50%) var(--my, 30%), rgba(242, 240, 234, .08), transparent 62%);
  opacity: 0;
  transition: opacity .4s ease;
  pointer-events: none;
  z-index: 2;
}
.pp-card:hover::after { opacity: 1 }

/* Loading: the shots are lazy, so a slot can sit empty for a beat when the
   section scrolls in. Until Personal.astro marks the image is-loaded, a mono
   ticker blinks over a shimmer sweep; the image then "develops" in — fades
   up while settling from a slight zoom. Hover zoom waits for is-loaded so
   the two transforms never fight. */
.pp-card .img-slot > img {
  opacity: 0;
  transform: scale(1.06);
  transition: opacity .7s ease, transform .7s cubic-bezier(.22, 1, .36, 1);
}
.pp-card .img-slot > img.is-loaded { opacity: 1; transform: none }
.pp-card:hover .img-slot > img.is-loaded,
.pp-card:hover .img-slot > video { transform: scale(1.035) }
.img-slot::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  transform: translateX(-100%);
  background: linear-gradient(105deg, transparent 30%, rgba(242, 240, 234, .07) 50%, transparent 70%);
  pointer-events: none;
}
.img-slot:has(> img:not(.is-loaded))::after { animation: ppSweep 1.5s ease-in-out infinite }
@keyframes ppSweep { to { transform: translateX(100%) } }
/* A div rather than a span: .img-slot:has(img) > span display:none would
   swallow the ticker along with the drop-here placeholder. */
.pp-loader {
  position: absolute;
  z-index: 1;
  font-family: 'Space Mono', monospace;
  font-size: 10px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(242, 240, 234, .45);
  transition: opacity .35s ease;
}
.pp-loader::after { content: '▌'; margin-left: 7px; animation: ppBlink 1s steps(1) infinite }
@keyframes ppBlink { 50% { opacity: 0 } }
.img-slot:has(> img.is-loaded) .pp-loader { opacity: 0 }
.pp-label {
  font-family: 'Space Mono', monospace;
  font-size: 9.5px;
  letter-spacing: .14em;
  color: rgba(242, 240, 234, .4);
  text-decoration: none;
  transition: color .3s ease;
}
.pp-card:hover .pp-label { color: rgba(242, 240, 234, .78) }
.pp-arrow { display: inline-block; transition: transform .35s cubic-bezier(.22, 1, .36, 1) }
.pp-card:hover .pp-arrow { transform: translate(2px, -2px) }

/* -------------------------------------------------------------- globe tooltip
   Positioned by site.js from the projected screen position of the hovered
   marker; the wrapper (#globe-wrap) is the containing block. */
.globe-tip {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -100%);
  transition: opacity .16s ease;
  padding: 8px 12px 9px;
  border: 1px solid rgba(242, 240, 234, .3);
  background: rgba(12, 12, 11, .92);
  backdrop-filter: blur(6px);
  white-space: nowrap;
  font-family: 'Space Mono', monospace;
  text-transform: uppercase;
}
.globe-tip.is-on { opacity: 1 }
.globe-tip.is-below { transform: translate(-50%, 0) }
.globe-tip > b {
  display: block;
  font-size: 10.5px;
  font-weight: 400;
  letter-spacing: .16em;
  color: #F2F0EA;
}
.globe-tip > i {
  display: block;
  margin-top: 4px;
  font-size: 9.5px;
  font-style: normal;
  letter-spacing: .12em;
  color: rgba(242, 240, 234, .6);
}

/* ---------------------------------------------------------------- notes list
   <sc-for> over `rubs` and `filteredPosts`. The chips filter client-side in
   site.js; the styling below is the design's inline styling, lifted to classes
   because the markup is now generated rather than authored per-row. */
.rub {
  font-family: 'Space Mono', monospace;
  font-size: 10.5px;
  letter-spacing: .14em;
  padding: 9px 16px;
  border-radius: 100px;
  cursor: pointer;
  user-select: none;
  border: 1px solid rgba(242, 240, 234, .25);
  background: transparent;
  color: rgba(242, 240, 234, .7);
}
.rub.is-on {
  border-color: transparent;
  background: #F2F0EA;
  color: #0C0C0B;
  font-weight: 700;
}

.note {
  display: grid;
  grid-template-columns: 170px 1fr auto;
  gap: 28px;
  align-items: baseline;
  padding: 26px 6px;
  border-top: 1px solid rgba(242, 240, 234, .12);
  cursor: pointer;
  color: inherit;
  text-decoration: none;
}
.note:hover { background: rgba(242, 240, 234, .04) }
.note[hidden] { display: none }
.note-tag {
  font-family: 'Space Mono', monospace;
  font-size: 10px;
  letter-spacing: .16em;
  color: rgba(242, 240, 234, .5);
}
.note-title {
  font-size: clamp(20px, 1.8vw, 28px);
  font-weight: 500;
  letter-spacing: -.015em;
  line-height: 1.25;
}
.note-meta {
  font-family: 'Space Mono', monospace;
  font-size: 10px;
  letter-spacing: .12em;
  color: rgba(242, 240, 234, .4);
  display: flex;
  gap: 18px;
  align-items: baseline;
}
.note-arrow { font-size: 14px; color: rgba(242, 240, 234, .7) }

@media (max-width: 820px) {
  .note { grid-template-columns: 1fr; gap: 8px }
}

/* ---------------------------------------------------------------- prototype
   Keyframes the Client 360 prototype animates against, from its own <helmet>. */
@keyframes fadeUp { from { opacity: 0; transform: translateY(14px) } to { opacity: 1; transform: none } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes draw { to { stroke-dashoffset: 0 } }
@keyframes growBar { from { width: 0 } }
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 8px) } to { opacity: 1; transform: translate(-50%, 0) } }
@keyframes pulseDot { 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, .22) } 65% { box-shadow: 0 0 0 5px rgba(0, 0, 0, 0) } }
@keyframes blink { 50% { opacity: .2 } }

#client360 input::placeholder { color: rgba(26, 25, 23, .38) }
#client360 button { font-family: 'Space Grotesk', sans-serif }
/* The phones' inner panes scroll (the idle demo pans them), but a desktop
   scrollbar track down the edge of a phone mockup breaks the illusion. */
#client360 [data-noscroll] { scrollbar-width: none; -ms-overflow-style: none }
#client360 [data-noscroll]::-webkit-scrollbar { display: none; width: 0; height: 0 }

/* ---------------------------------------------------------------- phones
   The source design shipped a media query for the project slides only, so the
   page-level grids still ran desktop-first and pushed the document wider than
   the viewport. These collapse them; nothing above 820px is affected. */
@media (max-width: 820px) {
  [data-nav] { padding: 16px 5vw !important; gap: 12px }

  [data-about-grid] { grid-template-columns: 1fr !important; gap: 48px !important }
  /* The globe is a drag-and-hover object on a device with neither, and a second
     WebGL scene on the hardware least able to carry one. Dropped, caption and
     all; the 821px floor in site.js mirrors this query. */
  [data-globe-col] { display: none !important }
  [data-stats] { grid-template-columns: 1fr 1fr !important }
  [data-personal] { grid-template-columns: 1fr !important }

  /* Role rows: title and date stack instead of colliding. */
  [data-roles] > div { flex-wrap: wrap; gap: 4px }
  [data-roles] > div > div:first-child { font-size: 16px !important }

  /* Section headers that pair a label with a right-aligned note. */
  #work > div:first-child > div:first-child { flex-wrap: wrap; gap: 8px }
  #contact > div:last-child { flex-direction: column; align-items: flex-start; gap: 10px }
}

/* ------------------------------------------------------------- slide shadows
   Each project slide enters by sliding left over the previous one, so its
   leading edge casts onto the panel underneath. Two layers — a soft contact
   edge and a very wide ambient falloff — both offset left only; the rest
   spills outside #stack-wrap's overflow:hidden and never shows. The tints come
   from site.js, keyed to the colour of the panel being covered so the shadow
   deepens that panel's own hue instead of greying it out.

   --slide-shadow-o gates the whole thing and defaults to 0, which matters:
   every slide waiting its turn is parked flush against the container's right
   edge, and a shadow there would bleed back across the panel on screen as a
   permanent dark band. The stack timeline in site.js raises it only while a
   slide is actually travelling. Slide one never casts — nothing under it. */
[data-slide] {
  --slide-shadow-o: 0;
  box-shadow:
    -8px 0 34px -18px var(--slide-shadow-contact, transparent),
    -60px 0 130px -54px var(--slide-shadow-ambient, transparent);
}

/* ---------------------------------------------------------------- blob cursor
   Custom cursor over the project slides: a gooey trail (reactbits BlobCursor in
   spirit) with a PRIVATE DEMO tag riding the lead blob. site.js injects the
   markup and toggles .blob-cursor-live on <body> while the pointer is inside a
   [data-slide]; without it none of this runs.

   The union used to be made the reactbits way — overlapping divs under an SVG
   blur + alpha-crush filter. It is drawn as a single computed <path> now; the
   long version of why is in site.js above bcPath, but the short one is that
   Safari either refused to merge the blobs or charged ~12ms a frame to do it. */
#blob-cursor {
  position: fixed;
  inset: 0;
  z-index: 100;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
}
/* No filter, and nothing to composite per blob — the union is one <path> that
   site.js recomputes each frame (see bcPath). This element is just a viewport
   sized canvas for it; painting a single filled path costs nothing, so unlike
   the blur it used to carry there is no reason to keep the box small. */
#blob-cursor .blob-goo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
#blob-label {
  position: absolute;
  top: 0;
  left: 0;
  font-family: 'Space Mono', monospace;
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: .22em;
  line-height: 1.6;
  text-transform: uppercase;
  text-align: center;
  text-indent: .22em;   /* recentre — tracking pads the right edge only */
  color: #F2F0EA;
  will-change: transform;
}
body.blob-cursor-live [data-slide] { cursor: none }

/* ------------------------------------------------------------- note tooltip
   A read-time pill that trails the pointer down the notes list. Deliberately
   NOT the blob cursor: that thing is 140px of goo, which swallows a headline.
   This is small enough to sit beside the words it's talking about, so the
   native cursor stays put and the row stays readable underneath. */
.note-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 100;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  /* Offset off the hotspot so the pill sits beside the pointer, not under it. */
  margin: 15px 0 0 17px;
  padding: 7px 14px 8px;
  border-radius: 100px;
  background: #F2F0EA;
  color: #0C0C0B;
  font-family: 'Space Mono', monospace;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .18em;
  text-indent: .18em;    /* recentre — tracking pads the right edge only */
  text-transform: uppercase;
  white-space: nowrap;
  will-change: transform;
}
.note-tip > i { font-style: normal; font-weight: 400; color: rgba(12, 12, 11, .45) }

/* ------------------------------------------------------------- agent status
   The chip above the hero headline, shaped like the status line an agent shows
   while it works: a spinner plus a label that cycles through what it's doing.
   The activities are product-design ones and none of them are real — it reads
   as a signature, not as telemetry.

   site.js drives the label. The markup ships with the first phrase already in
   it and the track sized by its own content, so with JS off the chip renders
   as a static, correct-looking status line rather than an empty pill. */
.agent-status {
  display: inline-flex;
  align-items: center;
  gap: 11px;
  padding: 10px 20px 10px 14px;
  border: 1px solid rgba(242, 240, 234, .18);
  border-radius: 100px;
  background: rgba(242, 240, 234, .045);
  backdrop-filter: blur(6px);
}
.agent-status > svg { display: block; flex: none; width: 19px; height: 19px; color: #F2F0EA }

/* Clipping window for the label. Its width is auto until site.js takes over,
   then explicit — the pill has to grow and shrink between phrases of different
   lengths, and width only transitions from one length to another. */
/* Flex rather than block: as a block this would lay the label out on a line
   box struck by the track's own inherited 16px font, which baseline-shoves the
   label a couple of pixels down off the spinner's centre. A flex line has no
   strut, so `center` here means centred. */
.agent-status-track {
  display: flex;
  align-items: center;
  overflow: hidden;
  height: 18px;   /* full ascender-to-descender box at 12px, or it clips */
  transition: width .45s cubic-bezier(.4, 0, .2, 1);
}
/* flex:none + nowrap: keeps its natural width even when the track around it is
   mid-transition and narrower, which is what makes offsetWidth measurable. */
.agent-status-line {
  flex: none;
  white-space: nowrap;
  font-family: 'Space Mono', monospace;
  font-size: 12px;
  line-height: 18px;
  letter-spacing: .16em;
  text-transform: uppercase;
  /* A bright band travelling left to right through dimmed text. The gradient
     is symmetric and flat at both ends, so repeat-x tiles it seamlessly, and
     one keyframe cycle moves it by exactly one tile (2x the element width) —
     the loop has no visible restart. */
  background-image: linear-gradient(100deg,
    rgba(242, 240, 234, .42) 20%, #F2F0EA 50%, rgba(242, 240, 234, .42) 80%);
  background-size: 200% 100%;
  background-repeat: repeat-x;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: astShimmer 3s linear infinite;
  transition: opacity .26s ease, transform .26s ease;
}
.agent-status-line.is-out { opacity: 0; transform: translateY(-8px) }
/* Entry start state. transition:none so setting it doesn't animate — the swap
   drops the phrase into place below the window, then releases it. */
.agent-status-line.is-pre { opacity: 0; transform: translateY(8px); transition: none }

@keyframes astShimmer { from { background-position: 100% 0 } to { background-position: -100% 0 } }

/* Spinner: svg-loaders #33 (blocks-shuffle-5). Keyframes verbatim from the
   source, with the class and animation names moved into this file's namespace;
   fill:currentColor is what lets the chip colour it. */
.ast-dot { transform-origin: 50% 50%; fill: currentColor }
@keyframes astDot1 { 9.0909090909%{transform:translate(-12px, 0)} 18.1818181818%{transform:translate(0px, 0)} 27.2727272727%{transform:translate(0px, 0)} 36.3636363636%{transform:translate(12px, 0)} 45.4545454545%{transform:translate(12px, 12px)} 54.5454545455%{transform:translate(12px, 12px)} 63.6363636364%{transform:translate(12px, 12px)} 72.7272727273%{transform:translate(12px, 0px)} 81.8181818182%{transform:translate(0px, 0px)} 90.9090909091%{transform:translate(-12px, 0px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(1) { animation: astDot1 4s infinite }
@keyframes astDot2 { 9.0909090909%{transform:translate(0, 0)} 18.1818181818%{transform:translate(12px, 0)} 27.2727272727%{transform:translate(0px, 0)} 36.3636363636%{transform:translate(12px, 0)} 45.4545454545%{transform:translate(12px, 12px)} 54.5454545455%{transform:translate(12px, 12px)} 63.6363636364%{transform:translate(12px, 12px)} 72.7272727273%{transform:translate(12px, 12px)} 81.8181818182%{transform:translate(0px, 12px)} 90.9090909091%{transform:translate(0px, 12px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(2) { animation: astDot2 4s infinite }
@keyframes astDot3 { 9.0909090909%{transform:translate(-12px, 0)} 18.1818181818%{transform:translate(-12px, 0)} 27.2727272727%{transform:translate(0px, 0)} 36.3636363636%{transform:translate(-12px, 0)} 45.4545454545%{transform:translate(-12px, 0)} 54.5454545455%{transform:translate(-12px, 0)} 63.6363636364%{transform:translate(-12px, 0)} 72.7272727273%{transform:translate(-12px, 0)} 81.8181818182%{transform:translate(-12px, -12px)} 90.9090909091%{transform:translate(0px, -12px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(3) { animation: astDot3 4s infinite }
@keyframes astDot4 { 9.0909090909%{transform:translate(-12px, 0)} 18.1818181818%{transform:translate(-12px, 0)} 27.2727272727%{transform:translate(-12px, -12px)} 36.3636363636%{transform:translate(0px, -12px)} 45.4545454545%{transform:translate(0px, 0px)} 54.5454545455%{transform:translate(0px, -12px)} 63.6363636364%{transform:translate(0px, -12px)} 72.7272727273%{transform:translate(0px, -12px)} 81.8181818182%{transform:translate(-12px, -12px)} 90.9090909091%{transform:translate(-12px, 0px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(4) { animation: astDot4 4s infinite }
@keyframes astDot5 { 9.0909090909%{transform:translate(0, 0)} 18.1818181818%{transform:translate(0, 0)} 27.2727272727%{transform:translate(0, 0)} 36.3636363636%{transform:translate(12px, 0)} 45.4545454545%{transform:translate(12px, 0)} 54.5454545455%{transform:translate(12px, 0)} 63.6363636364%{transform:translate(12px, 0)} 72.7272727273%{transform:translate(12px, 0)} 81.8181818182%{transform:translate(12px, -12px)} 90.9090909091%{transform:translate(0px, -12px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(5) { animation: astDot5 4s infinite }
@keyframes astDot6 { 9.0909090909%{transform:translate(0, 0)} 18.1818181818%{transform:translate(-12px, 0)} 27.2727272727%{transform:translate(-12px, 0)} 36.3636363636%{transform:translate(0px, 0)} 45.4545454545%{transform:translate(0px, 0)} 54.5454545455%{transform:translate(0px, 0)} 63.6363636364%{transform:translate(0px, 0)} 72.7272727273%{transform:translate(0px, 12px)} 81.8181818182%{transform:translate(-12px, 12px)} 90.9090909091%{transform:translate(-12px, 0px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(6) { animation: astDot6 4s infinite }
@keyframes astDot7 { 9.0909090909%{transform:translate(12px, 0)} 18.1818181818%{transform:translate(12px, 0)} 27.2727272727%{transform:translate(12px, 0)} 36.3636363636%{transform:translate(0px, 0)} 45.4545454545%{transform:translate(0px, -12px)} 54.5454545455%{transform:translate(12px, -12px)} 63.6363636364%{transform:translate(0px, -12px)} 72.7272727273%{transform:translate(0px, -12px)} 81.8181818182%{transform:translate(0px, 0px)} 90.9090909091%{transform:translate(12px, 0px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(7) { animation: astDot7 4s infinite }
@keyframes astDot8 { 9.0909090909%{transform:translate(0, 0)} 18.1818181818%{transform:translate(-12px, 0)} 27.2727272727%{transform:translate(-12px, -12px)} 36.3636363636%{transform:translate(0px, -12px)} 45.4545454545%{transform:translate(0px, -12px)} 54.5454545455%{transform:translate(0px, -12px)} 63.6363636364%{transform:translate(0px, -12px)} 72.7272727273%{transform:translate(0px, -12px)} 81.8181818182%{transform:translate(12px, -12px)} 90.9090909091%{transform:translate(12px, 0px)} 100%{transform:translate(0px, 0px)} }
.ast-dot:nth-child(8) { animation: astDot8 4s infinite }
@keyframes astDot9 { 9.0909090909%{transform:translate(-12px, 0)} 18.1818181818%{transform:translate(-12px, 0)} 27.2727272727%{transform:translate(0px, 0)} 36.3636363636%{transform:translate(-12px, 0)} 45.4545454545%{transform:translate(0px, 0)} 54.5454545455%{transform:translate(0px, 0)} 63.6363636364%{transform:translate(-12px, 0)} 72.7272727273%{transform:translate(-12px, 0)} 81.8181818182%{transform:translate(-24px, 0)} 90.9090909091%{transform:translate(-12px, 0)} 100%{transform:translate(0px, 0)} }
.ast-dot:nth-child(9) { animation: astDot9 4s infinite }

@media (max-width: 520px) {
  /* The longest phrases would otherwise push the pill past the 5vw gutter. */
  .agent-status { gap: 9px; padding: 8px 15px 8px 11px }
  .agent-status > svg { width: 17px; height: 17px }
  .agent-status-line { font-size: 10.5px; letter-spacing: .11em }
}

/* ---------------------------------------------------------- contact pills
   The two CTAs in the contact section. These were inline-styled, which meant
   the .hv-27/.hv-28 hover rules could never win — an inline `background` beats
   any selector. Real classes so the hover states actually land. */
.btn {
  position: relative;
  isolation: isolate;         /* so ::before can sit at z-index:-1 above the fill */
  overflow: hidden;           /* clips the sweep to the pill */
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 18px 34px;
  border: 1px solid transparent;
  border-radius: 100px;
  font-family: 'Space Mono', monospace;
  font-size: 12px;
  letter-spacing: .16em;
  text-decoration: none;
  will-change: transform;
  transition:
    transform .42s cubic-bezier(.22, 1, .36, 1),
    background-color .3s ease,
    border-color .3s ease,
    color .22s ease .08s;     /* delayed: flips once the fill is most of the way up */
}
.btn:hover { transform: translateY(-2px) }
.btn:active { transform: translateY(0); transition-duration: .09s }
.btn:focus-visible { outline: 2px solid #F2F0EA; outline-offset: 3px }

/* Arrow swap: the resting glyph leaves up-and-right, its twin follows it in
   from down-and-left. One box, two glyphs, overflow hidden. */
.btn-arrow {
  position: relative;
  overflow: hidden;
  flex: none;
  width: 1.15em;
  height: 1.15em;
}
.btn-arrow > span {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform .42s cubic-bezier(.22, 1, .36, 1);
}
.btn-arrow > span:nth-child(2) { transform: translate(-130%, 130%) }
.btn:hover .btn-arrow > span:nth-child(1) { transform: translate(130%, -130%) }
.btn:hover .btn-arrow > span:nth-child(2) { transform: translate(0, 0) }

/* Solid: lights up to white. */
.btn--solid { background: #F2F0EA; color: #0C0C0B; font-weight: 700 }
.btn--solid:hover { background: #FFFFFF }

/* Ghost: cream fill wipes up from the bottom edge and the label inverts. */
.btn--ghost { border-color: rgba(242, 240, 234, .35); color: #F2F0EA }
.btn--ghost::before {
  content: '';
  position: absolute;
  inset: -1px;                /* covers the border box, not just the padding box */
  z-index: -1;
  background: #F2F0EA;
  transform: translateY(101%);
  transition: transform .45s cubic-bezier(.22, 1, .36, 1);
}
.btn--ghost:hover { border-color: #F2F0EA; color: #0C0C0B }
.btn--ghost:hover::before { transform: translateY(0) }

/* ---------------------------------------------------------------- motion
   Everything visual is GSAP-driven; honour the OS-level opt-out. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto }
  #blob-cursor { display: none }

  /* Pills keep their hover *states*, lose the travel. The arrow swap has
     nowhere to go without motion, so the twin just stays parked offscreen. */
  .btn, .btn-arrow > span, .btn--ghost::before { transition: none }
  .btn:hover { transform: none }
  .btn:hover .btn-arrow > span:nth-child(1) { transform: none }
  .btn:hover .btn-arrow > span:nth-child(2) { transform: translate(-130%, 130%) }

  /* The Client 360 prototype ships its own CSS motion — entrance fades on the
     cards and rows, and two infinite loops (the health-pill pulse and the LIVE
     blink) that would keep running long after site.js has declined to start the
     idle director. Everything in there goes quiet; client360.js separately
     skips the stroke-draw, which is the one animation whose *end* state the
     chart depends on. */
  #client360 *, #client360 *::before, #client360 *::after {
    animation: none !important;
    transition: none !important;
  }

  /* Chip goes quiet: no shuffle, no shimmer, and site.js stops cycling the
     label — so the flat fill has to replace the gradient it was clipped from. */
  .ast-dot, .agent-status-line { animation: none }
  .agent-status-line {
    background-image: none;
    color: rgba(242, 240, 234, .8);
    -webkit-text-fill-color: currentColor;
  }
}
