/* ---------- RESET ---------- */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

@font-face {
  font-family: "General Sans";
  src: url("fonts/GeneralSans-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "General Sans";
  src: url("fonts/GeneralSans-Medium.ttf") format("truetype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "General Sans";
  src: url("fonts/GeneralSans-Semibold.ttf") format("truetype");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
/* =====================================================================
   TOKENS — one font (system/Helvetica-style, not Cabinet Grotesk, not a
   mono face), Jet Black background, Olive kept as the one splash accent.
   ===================================================================== */

:root {
  --bg: #0d0d0d; /* Jet Black, was #060606 */
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5); /* was .4 — that read ~3.55:1 on #0d0d0d, under the 4.5:1 floor for sub-18px text; .5 is ~4.9:1 */
  --accent: #d2db76; /* Olive — was #b0e454 (Lime Green), before that #ec9a29 (orange) */
  --accent-dot: #d2db76; /* small decorative dots keep the raw hue in both schemes */
  --hairline: rgba(255, 255, 255, .08);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border: rgba(255, 255, 255, .12);
  --glass-border-soft: rgba(255, 255, 255, .18);
  --dashed-border: rgba(245, 244, 240, .18);
  --accent-wash: rgba(210, 219, 118, .1);
  --accent-wash-border: rgba(210, 219, 118, .25);
  --gutter: 10px;
  --page-pad: 20px;
  /* The height of the top bar AND of the chat sheet's header. They meet at
     the sheet's left edge, so a mismatch reads as a misalignment. Driven by
     the nav's content: name (20px) + 6px + links (17px) = 43px, centred. */
  --bar-h: 60px;
  /* The gap under a section eyebrow AND under its heading. Deliberately the
     same value: an eyebrow that sits further from its heading than the
     heading sits from its body copy reads as a separate element. */
  --stack-gap: clamp(14px, 2vw, 22px);
  --font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "General Sans", Helvetica, Arial, sans-serif;

  /* MOTION. Two curves cover everything in the system:
       --ease-out   a firm "arrives and settles" curve, for things entering the
                    screen (panels, menu items, the nav sliding back in)
       --ease-soft  gentler, for transforms on elements already in place
                    (launcher hover, image zoom)
     Plain `ease` stays the default for colour/opacity fades, where a curve is
     not doing any work. Durations: .2s for hover and focus feedback, .3s for
     small movements, .6s and up for reveals. Anything outside that set should
     be a deliberate exception with a comment saying why. */
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --ease-soft: cubic-bezier(.2, .8, .2, 1);
  --dur-quick: .2s;
  --dur-mid: .3s;
  --dur-slow: .6s;

  /* shared by the FAB and the bot launcher so the two stay aligned/sized together */
  --fab-edge: clamp(14px, 4vw, 24px);
  /* Both flat 50px rather than clamped, and deliberately equal. The blob's
     clip-path is authored in fixed 50x50 SVG user-units (userSpaceOnUse, so
     it does not rescale with the box), and the FAB was matched to it by
     request. Was clamp(40px, 10vw, 46px); that shrink-on-narrow-phones
     behaviour is given up on purpose for parity between the two. */
  --fab-size: 50px;
  --blob-size: 50px;
}

/* Light mode — Bone surface / Jet Black ink, built only from values already in
   the system. Dark (above) is the default and unchanged; this is the second
   surface. Olive darkens for text-weight contrast; --accent-dot keeps the raw
   hue for the small decorative dots. Scrims/overlays over photos never change
   here — see the .hero-v3 and [data-photo-tile] local re-pins below. */
html[data-scheme="light"] {
  --bg: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .66); /* was .4 — that read ~2.64:1 on #f5f4f0, under the 4.5:1 floor for sub-18px text; .6 is ~5:1 */
  --accent: #737841;
  --hairline: rgba(13, 13, 13, .16);
  --glass-bg: rgba(13, 13, 13, .075);
  --glass-bg-soft: rgba(13, 13, 13, .055);
  --glass-border: rgba(13, 13, 13, .16);
  --glass-border-soft: rgba(13, 13, 13, .16);
  --dashed-border: rgba(13, 13, 13, .18);
  --accent-wash: rgba(115, 120, 65, .14);
  --accent-wash-border: rgba(115, 120, 65, .3);
  color-scheme: light;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  transition: background 0s, color 0s; /* instant scheme switch — no crossfade */
}

/* ---------- CONTAINER ---------- */
.container {
  width: 90%;
  max-width: 1200px;
  margin: 0 auto;
}

/* ---------- NAV ---------- */

.site-header {
  position: sticky;           /* stick to the top while scrolling */
  top: 0;
  z-index: 100;               /* stay above everything else */
  padding: 6px 0;            /* you can tweak this */

  /* glassy / blurry background */
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);

  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.header-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.logo {
  width: 42px;
}

.header-center ul {
  display: flex;
  gap: 40px;
  list-style: none;
}

.header-center a {
  text-decoration: none;
  color: #f5f5f5;
  font-size: 0.9rem;
  letter-spacing: 0;
  text-transform: none;
  transition: color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
  opacity: 0.9;
}
.header-center a:hover {
  color: #f97316;         /* your orange */
  transform: translateY(-2px);
  opacity: 1;
}

.social-icons {
  display: flex;
  gap: 16px;
}

.social-icons a {
  color: #ffffff;
  font-size: 26px;
  transition: color 0.2s ease, transform 0.2s ease;
  opacity: 0.5;
}

.social-icons a:hover {
  color: #f97316;
  /*transform: translateY(-3px);*/
}

/* index.html's original hero / selected-works / graphic-posters / footer
   sections lived here — fully superseded by hero-v3 / works-v3 / .ftr
   below. Removed as dead weight (verified unused by index/project1/project2). */

/* ---------- PROJECT PAGE (PORTFOLIO REDESIGN) ---------- */

.project-page {
  position: relative;
}

/* big orange glow on top-left */
.project-glow-left {
  position: absolute;
  top: -300px;
  left: -240px;
  width: 780px;          /* tweak until it matches your Figma */
  opacity: 1;
  pointer-events: none;
  z-index: 0;
}

/* OVERVIEW block */
.project-overview {
  padding: 80px 0 110px;
  position: relative;
  z-index: 1;            /* sits above the glow */
}

.project-overview-inner {
  text-align: center;
}

.project-overview-heading {
  text-transform: none;
  letter-spacing: 0;
  font-size: 18px;
  margin-bottom: 18px;
}

.project-overview-text {
  max-width: 760px;
  margin: 0 auto 54px;
  font-size: 14px;
  line-height: 1.7;
  opacity: 0.9;
}

.project-overview-hero img {
  width: 100%;
  max-width: 400px;
  display: block;
  margin: 0 auto;
  border-radius: 26px;
  box-shadow: 0 32px 90px rgba(0, 0, 0, 0.9);
}

@media (max-width: 768px) {
  .project-overview {
    padding: 60px 0 80px;
  }

  .project-overview-heading {
    font-size: 14px;
    letter-spacing: 0;
  }

  .project-overview-text {
    font-size: 13px;
    margin-bottom: 32px;
    padding: 0 8px;
  }

  .project-overview-hero img {
    max-width: 320px;
    border-radius: 20px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.9);
  }
}

/* ---------- CASE SECTION: PROBLEM / REVIEWING ---------- */

.case-section--problem {
  padding: 80px 0 40px;
}

.case-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
  column-gap: 80px;
  align-items: flex-start;
}

/* ROW 1: PROBLEM + BULLETS ACROSS FULL WIDTH */

.problem-row {
  grid-column: 1 / 3;             /* span both columns */
  display: flex;
  align-items: flex-start;
  gap: 40px;
  margin-bottom: 70px;
  padding-left: 0px;
}

.problem-label {
  font-size: 18px;
  text-transform: none;
  letter-spacing: 0;
  white-space: nowrap;
}

/* bullets take the rest of the width */
.problem-list {
  list-style: disc;
  padding-left: 130px;
  flex: 1;
  max-width: none;
  font-size: 14px;
  line-height: 1.7;
  opacity: 0.9;
}

/* ROW 2 LEFT: IMAGE */

.problem-figure {
  grid-column: 1 / 2;
  margin: 0;
  text-align: center;
}

.problem-figure img {
  width: 80%;                /* tweak 70–85 if needed */
  max-width: 520px;
  display: block;
  margin-left: -20px;
  
}

.figure-caption {
  font-size: 12.5px;
  text-transform: none;
  letter-spacing: .01em;
  opacity: 0.7;
  margin-left: -160px;
}

/* ROW 2 RIGHT: APPROACH BLOCK */

.approach-block {
  grid-column: 2 / 3;
  display: flex;
  flex-direction: column;
  gap: 18px;
  margin-top: 80px;   
  margin-left: -70px;       /* move down to line up with image */
}

.approach-label {
  font-size: 14px;
  text-transform: none;
  letter-spacing: 0;
  opacity: 0.8;
  margin-top: -50px;
}

.approach-title {
  font-size: 36px;
  text-transform: lowercase;
  color: #f97316;
  margin-top: -25px;
}

.approach-body {
  font-size: 14px;
  line-height: 1.7;
  max-width: 420px;
  opacity: 0.9;
}

.approach-list {
  list-style: disc;
  padding-left: 18px;
  font-size: 14px;
  line-height: 1.7;
  opacity: 0.9;
  margin-top: 50px;
}

/* ---------- RESPONSIVE ---------- */

@media (max-width: 900px) {
  .case-grid {
    grid-template-columns: 1fr;
    row-gap: 40px;
  }

  .problem-row {
    grid-column: 1 / 2;
    flex-direction: column;
    gap: 16px;
  }

  .problem-figure {
    grid-column: 1 / 2;
  }

  .approach-block {
    grid-column: 1 / 2;
    margin-top: 20px;
  }

  .problem-figure img {
    width: 100%;
  }
}

@media (max-width: 768px) {
  .case-section--problem {
    padding: 60px 0 40px;
  }

  .problem-row {
    gap: 12px;
    margin-bottom: 48px;
  }

  .problem-label {
    font-size: 14px;
    letter-spacing: 0;
    margin-left: 20px;
  }

  .problem-list {
    padding-left: 22px;
    font-size: 13px;
  }

  .problem-figure img {
    width: 100%;
    max-width: 360px;
    margin: 50px auto;
  }

  .figure-caption {
    margin-left: 0;
    text-align: center;
    font-size: 12.5px;
    margin-bottom: 50px;
    margin-top: -50px;
    
  }

  .approach-block {
    margin-top: 24px;
    margin-left: 0;
  }

  .approach-title {
    font-size: 28px;
  }

  .approach-body,
  .approach-list {
    font-size: 13px;
  }
}

/* ---------- CASE SECTION: RESEARCHING / REDESIGN ---------- */

.case-section--research {
  padding: 120px 0 80px;
}

.research-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.1fr);
  grid-template-rows: auto auto;
  grid-template-areas:
    "research inspiration"
    "workflow redesign";
  column-gap: 80px;
  row-gap: 80px;
  align-items: flex-start;
}

/* place each block in the right cell */
.approach-block--research { 
  grid-area: research;
}

.inspiration-figure {
  grid-area: inspiration;
}

.workflow-figure {
  grid-area: workflow;
}

.approach-block--redesign {
  grid-area: redesign;
}

/* shared text block container (reuses existing approach-* text styles) */
.approach-block {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* small vertical nudges – tweak to match Figma perfectly */
.approach-block--research {
  margin-top: 75px;      /* adjust if you want it lower/higher */
  margin-left: 5px;
}

.approach-block--redesign {
  margin-top: 100px;   /* tweak until it lines with workflow img */
  margin-left: 50px;
}

/* IMAGES + CAPTIONS */

.inspiration-figure,
.workflow-figure {
  margin: 0;
  text-align: center;
}

.inspiration-figure img,
.workflow-figure img {
  display: block;
  width: 80%;
  max-width: 540px;   /* adjust to match exports */
  margin-top: -40px;
  margin-left: -25px;
  
}

.workflow-figure img {
  width: 70%;
}

.figure-caption--label {
  margin-top: 12px;
  font-size: 14px;
  text-transform: none;
  letter-spacing: 0;
  margin-left: -150px;
}

.figure-caption--label1 {
  margin-top: -15px;
  font-size: 14px;
  text-transform: none;
  letter-spacing: 0;
  margin-left: -220px;
}
/* ---------- RESPONSIVE ---------- */
@media (max-width: 900px) {
  .research-grid {
    grid-template-columns: 1fr;
    grid-template-areas:
      "research"
      "inspiration"
      "workflow"
      "redesign";
    row-gap: 48px;
  }

  .approach-block--research,
  .approach-block--redesign {
    margin-top: 0;
  }

  .inspiration-figure img,
  .workflow-figure img {
    max-width: 100%;
  }
}

@media (max-width: 768px) {
  .case-section--research {
    padding: 80px 0 60px;
  }

  .research-grid {
    grid-template-columns: 1fr;
    grid-template-areas:
      "inspiration"
      "research"
      "workflow"
      "redesign";
    row-gap: 40px;
  }

  .approach-block--research,
  .approach-block--redesign {
    margin-top: 12px;
    margin-left: 0;
  }

  .inspiration-figure img,
  .workflow-figure img {
    width: 100%;
    max-width: 360px;
    margin: 0 auto;
  }

  .figure-caption--label,
  .figure-caption--label1 {
    margin-left: 0;
    text-align: center;
    font-size: 8px;
  }

  .figure-caption--label1 {
    margin-top: 2px;
  }

  .approach-block--research .approach-title,
  .approach-block--redesign .approach-title {
    font-size: 28px;
  }

  .approach-block--research .approach-body,
  .approach-block--redesign .approach-body {
    font-size: 13px;
  }
}

/* ---------- ACHIEVEMENT SECTION ---------- */

.case-section--achievement {
  padding: 120px 0 140px;
  position: relative;
}

/* main big gradient card */
.achievement-card {
  /* NEW: CSS gradient instead of image */
  background:
    radial-gradient(circle at 0% 0%, rgba(249, 115, 22, 0.35) 0, transparent 45%),
    radial-gradient(circle at 100% 100%, rgba(249, 115, 22, 0.25) 0, transparent 55%),
    linear-gradient(135deg, #1b0800, #0a0300);
  border-radius: 48px;
  padding: 80px 120px 120px;
  position: relative;
  max-width: 1100px;
  margin: 0 auto;
  text-align: center;
  transform: translateX(-45px);
  margin-top: -20px;
  box-shadow: 0 32px 80px rgba(0, 0, 0, 0.85);
}

/* heading group */
.achievement-label {
  text-transform: none;
  letter-spacing: 0;
  opacity: .8;
  font-size: 14px;
}

.achievement-title {
  font-size: 42px;
  color: #f97316;
  margin: -5px 0 18px;
}

.achievement-body {
  max-width: 760px;
  margin: 0 auto;
  font-size: 14px;
  line-height: 1.7;
  opacity: .9;
}

/* full-width AI card under the gradient rectangle */
.ai-card {
  max-width: 1100px;                  /* matches achievement-card */
  margin: 28px auto 0;                /* center + small gap under gradient */
  padding: 22px 26px;
  border-radius: 24px;

  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.16);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);

  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.85);
  text-align: left;
}

.ai-card .ai-tag {
  display: inline-block;
  background: rgba(0, 0, 0, 0.55);
  padding: 6px 14px;
  border-radius: 999px;
  text-transform: none;
  font-size: 12.5px;
  letter-spacing: .01em;
  margin-bottom: 14px;
  opacity: 0.9;
}

.ai-card .ai-text {
  font-size: 14px;
  line-height: 1.7;
  opacity: 0.9;
}

/* small AI tag */
.ai-tag {
  background: rgba(0, 0, 0, 0.5);
  padding: 6px 14px;
  border-radius: 999px;
  text-transform: none;
  font-size: 14px;
  letter-spacing: 0;
  margin-bottom: 20px;
  margin-top: -20px;
}

/* AI bubble text */
.ai-text {
  font-size: 14px;
  line-height: 1.7;
  opacity: .95;
}

.case-footer {
  padding: 60px 0 0;
  text-align: center;
}

/* icons row */
.footer-icons {
  display: flex;
  justify-content: center;
  gap: 45px;
  margin-bottom: 16px;
}

.footer-icons a {
  color: #3d3d3d;
  font-size: 26px;
  transition: color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
  opacity: 0.9;
}

.footer-icons a:hover {
  color: #f97316;          /* brighter orange on hover */
  transform: translateY(-3px);
  opacity: 1;
}

/* copyright text */
.footer-copy {
  font-size: 13px;
  opacity: 0.8;
  margin-bottom: 18px;
}

.footer-heart {
  color: #ff4b4b;
}

/* gradient strip – same vibe as your main page */
.footer-gradient {
  width: 100%;
  height: 8px;
  background: linear-gradient(90deg, #f97316 0%, #2c1c07 40%, #f97316 100%);
}

/* case-footer-v2 (glass icon-button variant) and achievement-ai-inline were
   from earlier index.html footer iterations, both fully superseded by .ftr —
   removed as dead weight. */

@media (max-width: 768px) {
  .case-section--achievement {
    padding: 80px 0 100px;
  }

  .achievement-card {
    max-width: 100%;
    border-radius: 32px;
    padding: 32px 20px 40px;
    transform: none;        /* no sideways shift on mobile */
    margin-top: 0;
  }

  .achievement-label {
    font-size: 12px;
    letter-spacing: 0;
  }

  .achievement-title {
    font-size: 30px;
    margin-bottom: 12px;
  }

  .achievement-body {
    font-size: 13px;
  }

  .ai-tag {
    font-size: 11px;
  }

  .ai-text {
    font-size: 13px;
  }
}

/* hamburger base (hidden on desktop) */
.nav-toggle {
  display: none;
  background: none;
  border: none;
  cursor: pointer;
  position: relative;
  width: 25px;
  height: 24px;
  padding: 0;
  opacity: .8;
}

/* two lines */
.nav-toggle span {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  border-radius: 999px;
  background: #ffffff;
  transition: transform 0.2s ease, opacity 0.2s ease, top 0.2s ease, bottom 0.2s ease;
}

.nav-toggle span:first-child {
  top: 5px;
}

.nav-toggle span:last-child {
  bottom: 5px;
}

/* X state when open */
.site-header.nav-open .nav-toggle span:first-child {
  top: 11px;
  transform: rotate(45deg);
}

.site-header.nav-open .nav-toggle span:last-child {
  bottom: 11px;
  transform: rotate(-45deg);
}

/* ---------- MOBILE LAYOUT ---------- */
@media (max-width: 768px) {

  /* NAV: hide links, show hamburger */
  .header-inner {
    align-items: center;
  }

  .header-center {
    display: none;
  }

  .nav-toggle {
    display: block;
  }

  /* when open, show nav as dropdown under header */
  .site-header.nav-open .header-center {
    display: block;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    padding: 16px 24px 20px;
    background: rgba(0, 0, 0, 0.9);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  }

  .site-header.nav-open .header-center ul {
    flex-direction: column;
    gap: 12px;
  }

.site-header.nav-open .header-center a {
    font-size: 0.8rem;
  }

/* smaller header height on mobile */
  .site-header {
    padding: 6px 0;
  }

  /* smaller logo only on mobile */
  .site-header .logo {
    width: 24px;   /* tweak: 24–32 */
  }

  /* header row spacing on mobile */

/* narrow the header container edges on mobile
     (only in header, not all containers on page) */
  .site-header .container {
    padding: 0 12px;
  }

}

/* default: social icons visible on desktop */
.nav-social-mobile {
  display: none;     /* mobile-only items hidden on desktop */
}

/* MOBILE (project1 only — its header/nav still runs on the pre-redesign system)
   This was two identical @media (max-width: 768px) blocks, one nested inside
   the other, declaring .nav-social-mobile twice with different margin-top.
   Same condition and the inner came later, so the inner values were the ones
   in effect; those are what survive here. */
@media (max-width: 768px) {
  /* hide the icon row in the header, keep the wrapper + burger */
  .social-icons a {
    display: none;
  }

  /* show the text versions inside the open menu instead */
  .nav-social-mobile {
    display: block;
    margin-top: 0;
  }

  .nav-social-mobile a {
    font-size: 0.9rem;
    letter-spacing: 0;
    text-transform: none;
    opacity: 0.8;
  }
}

html {
  scroll-behavior: smooth;
}

/* `scroll-behavior: smooth` above eases anchor jumps and keyboard scrolling.
   Wheel scrolling is eased separately, in `smooth-scroll.js`.

   That script must not inherit `smooth` for its own frames — a scrollTo would
   then start a native animation per frame and they would fight each other — so
   it overrides this with an inline style for the length of a glide and clears
   it afterwards. Note `scrollTo({ behavior: 'auto' })` does NOT sidestep this:
   per spec `auto` means "use this property", i.e. smooth. See the notes in
   smooth-scroll.js before changing either one.

   Do NOT add `scroll-snap-type` here. `y proximity` was tried and trapped the
   page: these sections are far taller than the viewport, so every wheel notch
   came to rest still inside the current target's range and got dragged back —
   0 of 12 test nudges advanced, and the page bottom became unreachable on all
   three pages. `mandatory` is stricter and worse. */

/* =====================================================================
   HERO V3 — centered, transparent nav (no bar), full-bleed color photo,
   flat 25% black overlay. One font, thin weight, Joe-Kennedy-simple.
   ===================================================================== */

.hero-v3 {
  position: relative;
  height: 100vh;
  height: 100svh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  /* Dark-scheme scrim. The hero used to stay dark-scrimmed in BOTH schemes;
     it now flips with the toggle (see the html[data-scheme="light"] block
     below), so the photo gets a bone scrim and dark ink in light mode
     instead of staying an island of dark on an otherwise light page. */
  --bg: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --accent: #d2db76;
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-border: rgba(255, 255, 255, .12);
  --accent-wash: rgba(210, 219, 118, .1);
  --accent-wash-border: rgba(210, 219, 118, .25);
}

.hero-v3-photo {
  position: absolute;
  inset: 0;
  background-color: #1c1a17;
  background-size: cover;
  /* subject sits ~25% from the left in the source photo, not centered —
     keep that in frame instead of cropping it out on narrow viewports */
  background-position: 25% 30%;
}

.hero-v3-overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, .65);
  transition: background 500ms ease, backdrop-filter 500ms ease, -webkit-backdrop-filter 500ms ease;
}
.hero-v3-feather {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 26vh;
  min-height: 160px;
  z-index: 1;
  background: linear-gradient(to bottom, transparent, var(--bg) 92%);
  pointer-events: none;
}

/* Fixed rather than in-flow so it can persist past the hero and run the
   hide-on-scroll-down / reveal-on-scroll-up behaviour (see the #siteNav
   script in each page). On index and project3 it already overlaid its
   section, so nothing shifts; project2 had it in flow, and gets a
   compensating padding rule further down. */
.hero-v3-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 70; /* above the launcher (60) */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: clamp(12px, 3vw, 24px);
  /* Symmetric top/bottom — was 28px/16px, which pushed the name, links and
     icons visibly below the centre line of the bar. With align-items:center
     above, equal padding is what actually centres them. */
  /* min-height rather than padding, so the bar matches the chat header
     exactly instead of being however tall two lines of text happen to be.
     align-items: center above does the centring. */
  min-height: var(--bar-h);
  padding: 0 var(--page-pad);
  transition: transform .38s var(--ease-soft),
              background-color .28s ease,
              backdrop-filter .28s ease;
}
.hero-v3-nav-main {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
/* Slid out of view while scrolling down; the .is-stuck box only appears once
   the nav is no longer sitting on the hero, so at the top of the page it
   stays exactly as before — bare text over the photo, no panel. */
.hero-v3-nav.is-hidden {
  transform: translateY(-100%);
}
/* Deliberately light on opacity — the blur is doing most of the separation
   work, so the tint only needs to keep text off a busy backdrop. Was .45/.55,
   which read as a solid bar rather than a pane of glass. */
.hero-v3-nav.is-stuck {
  background: rgba(13, 13, 13, .28);
  backdrop-filter: blur(18px) saturate(120%);
  -webkit-backdrop-filter: blur(18px) saturate(120%);
}
html[data-scheme="light"] .hero-v3-nav.is-stuck {
  background: rgba(245, 244, 240, .38);
}
/* On index the nav lives inside .hero-v3, which re-pins --text to bone for
   the photo. Once the nav detaches from the hero it can be over ordinary
   page content, so the stuck state re-pins those tokens back to the active
   scheme — otherwise bone text would land on a bone panel in light mode. */
html[data-scheme="dark"] .hero-v3-nav.is-stuck {
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
}
html[data-scheme="light"] .hero-v3-nav.is-stuck {
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
}
/* A themed page needs its own stuck panel. The rules above hardcode the site's
   jet-black / bone tints, which on the navy Spex page read as a black bar
   sliding over navy in dark mode, and a bone bar in light — neither belongs to
   the page. These override with the project's surface and ink instead. */
body.spx .hero-v3-nav.is-stuck {
  background: rgba(0, 34, 61, .58);
  --text: var(--bone);
  --text-muted: rgba(245, 244, 240, .68);
}
html[data-scheme="light"] body.spx .hero-v3-nav.is-stuck {
  background: rgba(227, 238, 246, .62);
  --text: var(--navy);
  --text-muted: rgba(0, 48, 87, .72);
}
@media (prefers-reduced-motion: reduce) {
  .hero-v3-nav { transition: none; }
}
/* project2 is the one page where the nav was a normal in-flow block, so
   taking it out of flow would pull the hero up under it */
.hero-v3-nav + .cs-hero {
  padding-top: clamp(112px, 13vw, 156px);
}

.hero-v3-name {
  font-size: clamp(13px, 3.2vw, 15px);
  font-weight: 500;
  color: var(--text);
}
.hero-v3-links {
  display: flex;
  gap: clamp(10px, 3vw, 16px);
}
.hero-v3-links a {
  font-size: clamp(11px, 2.8vw, 13px);
  color: var(--text-muted);
  text-decoration: none;
  transition: color .2s ease;
}
.hero-v3-links a:hover {
  color: var(--text);
}
.hero-v3-nav a:focus-visible,
.hero-v3-links a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Sound + theme, top-right, vertically centred against the nav block.
   Bare glyphs, no chip: same treatment as the hero scroll chevron — the icon
   is currentColor held at .6 opacity, so it reads as present-but-quiet rather
   than a bright white control, and it follows --text through both the scheme
   toggle and the nav's own .is-stuck re-pin. */
.nav-tools {
  display: flex;
  align-items: center;
  gap: clamp(12px, 2.5vw, 16px);
  flex-shrink: 0;
}
.nav-tool {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px;
  border: none;
  background: none;
  color: var(--text);
  opacity: .6;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: opacity .2s ease;
}
.nav-tool:hover {
  opacity: .95;
}
.nav-tool:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
  opacity: .95;
}

.hero-v3-center {
  position: relative;
  z-index: 2;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 40px var(--page-pad);
}

.hero-v3-headline {
  margin: 0 0 clamp(10px, 3.5vw, 18px);
  font-weight: 300;
  font-size: clamp(22px, 4.2vw, 58px);
  line-height: 1.15;
  letter-spacing: -.01em;
  white-space: nowrap;
  transition: opacity 450ms ease, filter 450ms ease;
}
@media (max-width: 700px) {
  .hero-v3-headline {
    white-space: normal;
    max-width: 16ch;
    font-size: clamp(28px, 8vw, 40px);
  }
}

.hero-v3-headline .word {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
  animation: word-in .6s var(--ease-out) forwards;
}
.hero-v3-headline .word:nth-child(1) { animation-delay: .1s; }
.hero-v3-headline .word:nth-child(2) { animation-delay: .18s; }
.hero-v3-headline .word:nth-child(3) { animation-delay: .26s; }
.hero-v3-headline .word:nth-child(4) { animation-delay: .34s; }
.hero-v3-headline .word:nth-child(5) { animation-delay: .42s; }
.hero-v3-headline .word:nth-child(6) { animation-delay: .5s; }
.hero-v3-headline .word:nth-child(7) { animation-delay: .58s; }

@keyframes word-in {
  to { opacity: 1; transform: translateY(0); }
}

.period-mark {
  color: inherit;
}

.hero-v3-subtitle {
  margin: 0;
  font-size: clamp(11px, 2.2vw, 15px);
  font-weight: 400;
  color: var(--text-faint);
  opacity: 0;
  animation: fade-in .6s ease forwards;
  animation-delay: 1.05s;
}

@keyframes fade-in {
  to { opacity: 1; }
}

/* A minimal scroll cue: icon only, no pill or label, with a subtle vertical
   bounce to invite scrolling. On scroll it travels to the corner and becomes
   the back-to-top control — see the chevron-dock script in index.html. */
/* Home position for the chevron. A 0x0 marker rather than coordinates on the
   button, because the button is viewport-fixed and its transform is driven
   frame by frame by the chevron-dock script — it has no CSS position of its
   own to read. Keeping the resting spot here means it stays a CSS decision,
   defined once, and the script measures it instead of hardcoding "86%". */
.hero-v3-scroll-home {
  position: absolute;
  left: 50%;
  top: 86%;
  width: 0;
  height: 0;
  pointer-events: none;
}

/* `fixed`, not `absolute`: this travels out of the hero to dock beside the AI
   blob, and .hero-v3 is `overflow: hidden`. A fixed element is not clipped by
   an ancestor's overflow (no transformed ancestor here to make it a containing
   block — checked), while an absolute one would simply vanish at the hero's
   edge.

   left/top stay at 0 and the script supplies the full offset, so there is one
   source of truth for where this thing is at any scroll position.

   No `will-change` on purpose — it can promote an element into a backdrop
   root, and .hero-v3-scroll-blur below depends on sampling the real page
   behind it. Not worth risking a repeat of that bug for a hint. */
.hero-v3-scroll {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 59; /* under the blob (60), so the blob always wins a stray overlap */
  transform-origin: 50% 50%;
  display: flex;
  background: none;
  border: none;
  padding: 10px;
  margin: 0;
  color: var(--text);
  cursor: pointer;
  transition: color .2s ease;
}
/* The bounce lives here, not on the button, so the keyframes and the driven
   transform can't fight over the same property. */
.hero-v3-scroll-inner {
  position: relative;
  display: flex;
  animation: scroll-cue-nudge 2.2s ease-in-out infinite;
}
/* Once it starts travelling the bounce is off — an idle "look down here" hint
   makes no sense mid-flight, and it stays off once docked. */
.hero-v3-scroll.is-travelling .hero-v3-scroll-inner {
  animation: none;
}
.hero-v3-scroll svg {
  display: block;
  /* Back to the original 60px cap. The hero chevron is wide again — only the
     DOCKED one tightens (see the angle morph in the chevron-dock script), so
     there is no reason to shrink the hero footprint. At the wide angle the ink
     works out ~60x16, matching the glyph this replaced almost exactly. */
  width: clamp(34px, 8vw, 60px);
  height: auto;
  /* positioned so it paints above .hero-v3-scroll-blur regardless of DOM
     order — a non-positioned element always paints below a positioned
     sibling no matter which comes first in markup */
  position: relative;
}
/* A stroked path, not a hand-built filled wedge. That makes both of the things
   the brief asked for one-value changes rather than re-authoring path data:
   thickness is stroke-width, and the V's angle is the geometry of the two line
   segments. Round caps and joins keep it soft at small sizes.

   Still translucent, so the warped/blurred backdrop behind
   .hero-v3-scroll-blur reads through the glyph instead of hiding under it —
   this was fill-opacity when the glyph was a filled shape. */
.hero-v3-scroll svg path {
  stroke-opacity: .8;
}
/* Same liquid-glass technique as the FAB (turbulence+displacement backdrop
   warp via the shared #fab-liquid-glass filter def), confined to this
   chevron's own silhouette via a mask built from the exact same path data
   — no separate asset file needed since the source is already inline SVG,
   unlike the bot-launcher's bitmap art. Sized/positioned to exactly
   match the visible <svg> above it (10px = .hero-v3-scroll's own padding,
   same clamp() width, same 60:16 intrinsic aspect ratio it gets from
   height:auto), so the mask lines up pixel-for-pixel with the real glyph. */
.hero-v3-scroll-blur {
  position: absolute;
  /* 0, not 10px. This used to offset by the button's 10px padding because it
     was positioned against the button itself. It now sits inside
     .hero-v3-scroll-inner, whose content box already starts at the padding
     edge, so the old 10px stacked on top of the padding and pushed the glass
     a full 10px down-right of the glyph — two visibly separate chevrons. It
     showed up on the phone hero photo first, but it was wrong everywhere. */
  top: 0;
  left: 0;
  width: clamp(34px, 8vw, 60px);
  /* Constant, and sized for the TIGHTEST angle the glyph reaches. The V angle
     morphs from wide to tight as the chevron docks; pinning the box to the
     tallest case means the box, this aspect-ratio and the layout around it all
     stay put while only the path inside changes. The wide angle just leaves the
     lower part of the box empty. */
  aspect-ratio: 50 / 24.46;
  /* Same path, same viewBox, same stroke settings as the visible <svg> — the
     mask has to trace the glyph exactly or the glass sits off the mark. This is
     the WIDE resting shape; the chevron-dock script overwrites both this and
     the visible path in step as the angle morphs, so the two never drift apart.
     Kept as a data URI rather than a file since the source is already inline
     SVG, and the script builds its variants the same way. */
  -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 24.46'><path d='M3 3 L25 11.45 L47 3' fill='none' stroke='%23fff' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
  mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 24.46'><path d='M3 3 L25 11.45 L47 3' fill='none' stroke='%23fff' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  mask-mode: alpha;
  pointer-events: none;
  /* Mask and backdrop-filter belong on the SAME element. This previously
     split them — mask here, blur on an inner .hero-v3-scroll-blur-fx child —
     on the mistaken theory that pairing them broke the effect. The opposite
     is true: a masked ANCESTOR becomes a backdrop root, so the child was
     filtering an empty backdrop and this chevron rendered with no blur at
     all. Same root cause as the bot launcher; see the BOT LAUNCHER section
     for the full write-up and the measurement method. */
  backdrop-filter: blur(6px) saturate(120%);
  -webkit-backdrop-filter: blur(6px) saturate(120%);
}
.hero-v3-scroll:hover {
  color: var(--text);
}
.hero-v3-scroll:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}
/* translateY only now. This used to also carry translateX(-50%) because the
   button centred itself that way; centring is the script's job since the
   button went fixed, and leaving it here would drag the glyph half its width
   off-centre inside the button. */
@keyframes scroll-cue-nudge {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(7px); }
}
@media (prefers-reduced-motion: reduce) {
  .hero-v3-scroll-inner { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-v3-headline .word,
  .hero-v3-subtitle {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

/* =====================================================================
   WORKS V3 — no heading, just the grid. Image only by default; hover
   blurs/darkens and reveals title + description. Each row fades up
   independently, and re-triggers every time it re-enters view.
   ===================================================================== */

.works-v3 {
  background: var(--bg);
  padding: var(--gutter) var(--page-pad) 40px;
}

.reveal {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity .8s ease, transform .8s ease;
}
.reveal.in-view {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .reveal { transition: none; opacity: 1; transform: none; }
}

.wgrid--ux {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--gutter);
}

.wgrid--graphic {
  margin-top: var(--gutter);
  display: flex;
  flex-direction: column;
  gap: var(--gutter);
}

.wrow {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--gutter);
}

@media (max-width: 900px) {
  .wgrid--ux { grid-template-columns: 1fr; }
  .wrow { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
  .wrow { grid-template-columns: 1fr; }
}

.wtile {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  display: block;
  background: var(--bg);
}

.wtile-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) ease;
}
.wtile:hover .wtile-img,
.wtile:focus-visible .wtile-img {
  transform: scale(1.05);
}
@media (prefers-reduced-motion: reduce) {
  .wtile-img { transition: none; }
}

.wtile:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px; /* tiles are edge-to-edge in the grid — inset so it isn't clipped by the next tile */
}

.wtile-title {
  margin: 0;
  max-width: 22ch;
  font-weight: 300;
  font-size: clamp(24px, 4vw, 40px);
  letter-spacing: -.01em;
  line-height: 1.2; /* 48px at 40px */
  color: var(--text);
}
.wtile-desc {
  margin: 0;
  max-width: 34ch;
  font-weight: 400;
  font-size: clamp(11px, 2.2vw, 15px);
  color: var(--text-faint);
}

/* Tiles with a real photo stay dark-scrimmed in both schemes: the cursor
   label sits directly on the image, so it can't follow the light-mode flip.
   Tiles with no photo aren't matched and read normally. */
.wtile:has(.wtile-img) {
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border: rgba(255, 255, 255, .12);
  --glass-border-soft: rgba(255, 255, 255, .18);
}

/* =====================================================================
   FAB — vertical liquid-glass quick nav, bottom-right, opens upward.
   Currently hidden (see the display:none below) but kept whole — it may come
   back.

   Wrapper is .quicknav, not .fab, for a historical reason worth keeping:
   Font Awesome's stylesheet defined its own unrelated ".fab" class (its
   "Brands" icon prefix) and loaded after this file, so on a specificity tie
   it silently won and forced this element to block layout instead of flex.
   That looked identical for a static stacked column, so it went unnoticed
   until something needed real flex behaviour (`order`, for the phone-only
   top/bottom swap below). Font Awesome has since been removed from the site
   in favour of a local sprite, so the collision is gone — but the name stays,
   because a generic .fab is exactly the kind of class an icon library will
   claim again. #fab (the id) never collided, nor did .fab-item/.fab-menu/
   .fab-toggle/.fab--on-photo.
   ===================================================================== */

.quicknav {
  position: fixed;
  right: var(--fab-edge);
  bottom: var(--fab-edge);
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* Tried pinning .quicknav to the light-scheme palette always (regardless of
   the global toggle) to fix the FAB going invisible over the Spex mockup
   card's light background in dark mode — reverted. Wrong fix: in dark mode
   most of the page (ordinary sections, the footer, anything on plain
   --bg) is actually dark too, not light — only a couple of specific mockup
   sections like Spex are light islands within it. Pinning to "always light"
   just moved the invisibility problem: dark FAB text now disappeared
   against the (actually dark) footer and everywhere else in dark mode
   instead. The real fix for the original Spex-card problem needs to detect
   that specific light section the same way .fab--on-photo already detects
   the dark hero/cs-cover (extend the existing scroll-aware script to also
   flag light islands), not a blanket assumption — not implemented yet. */

/* Hidden by request — the FAB felt unnecessary once theme/sound moved up to
   .nav-tools in the navbar. All of its markup and styling is left intact, so
   restoring it is just deleting this one rule. Two things to know if you do:
   the theme/sound buttons now live in the nav (moving them back would create
   duplicate ids), and the launcher has taken over the bottom-right corner
   this used to occupy. Declared AFTER .quicknav's own `display: flex` on
   purpose — same specificity, so source order decides. */
.quicknav {
  display: none;
}

.fab-menu {
  display: flex;
  flex-direction: column-reverse;
  align-items: center;
  gap: clamp(8px, 2.5vw, 12px);
  margin-bottom: clamp(10px, 3vw, 14px);
}

/* the FAB is fixed chrome, not a descendant of .hero-v3/.cs-cover, so it
   doesn't inherit their local dark re-pin — this class is toggled by a small
   scroll-aware script whenever the FAB is actually sitting over a dark photo,
   so its glass/icon stay legible there regardless of the global color scheme */
.quicknav.fab--on-photo {
  --text: #f5f4f0;
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border-soft: rgba(255, 255, 255, .18);
  --accent-wash: rgba(210, 219, 118, .1);
  --accent-wash-border: rgba(210, 219, 118, .25);
}

.fab-item,
.fab-toggle {
  width: var(--fab-size);
  height: var(--fab-size);
  border-radius: 50%;
  border: 1px solid var(--glass-border-soft);
  background: var(--glass-bg-soft);
  /* saturate was 160% — over a busy photo (green foliage in spots), each
     icon samples its own backdrop, so a heavy boost made items at different
     vertical positions in the same stack look inconsistently tinted (some
     visibly green, others not) even though they share the exact same
     background/border color. Toned down so the glass still pops without
     amplifying whatever happens to be directly behind each individual icon. */
  backdrop-filter: blur(16px) saturate(120%);
  -webkit-backdrop-filter: blur(16px) saturate(120%);
  /* Liquid-glass upgrade: a turbulence+displacement SVG filter (defined once
     per page, right before the FAB markup) warps the backdrop instead of
     just blurring it flat — that warp is what actually reads as "glass"
     rather than frosted plastic. Declared as a SECOND backdrop-filter line,
     not the only one: a browser that can't resolve a url() filter inside
     backdrop-filter (older WebKit) treats this whole declaration as invalid
     and drops it, silently keeping the plain blur+saturate above instead of
     losing the blur entirely. */
  /* was 0 8px 24px .35 — an 8px vertical offset makes a shadow this dark
     read as a second, uneven ring around the circle: heavier at the bottom,
     thin-to-invisible at the top, especially obvious against a light page
     background where the real border/stroke is faint but this shadow is not.
     Smaller offset + lower opacity keeps a soft lift without that ring look.
     The inset pairs below are new: opposite-corner highlight/shadow to fake
     a beveled glass edge, tuned for this site's dark-by-default scheme —
     light mode gets the inverted (dark-edge) version right below. */
  box-shadow:
    0 2px 8px rgba(0, 0, 0, .22),
    inset 1px 1px .5px -.5px rgba(255, 255, 255, .55),
    inset -1px -1px .5px -.5px rgba(255, 255, 255, .3),
    inset 0 0 3px 2px rgba(255, 255, 255, .07);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  /* without this, flexbox centers the icon's full line-box (inherited
     body line-height), not just the glyph itself — the invisible leading
     above/below the glyph can shift the visible "+" off-center within the
     circle even though the box itself is perfectly centered */
  font-size: clamp(14px, 3.5vw, 16px);
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  transition: background .2s ease, border-color .2s ease, transform .2s ease;
}
.fab-item:hover,
.fab-toggle:hover {
  background: var(--accent-wash);
  border-color: var(--accent-wash-border);
}
/* Light mode inverts the bevel: dark-edge insets read as "carved glass" on
   a bright surface the way white-edge insets do on the dark default. */
html[data-scheme="light"] .fab-item,
html[data-scheme="light"] .fab-toggle {
  box-shadow:
    0 2px 8px rgba(0, 0, 0, .14),
    inset 1px 1px .5px -.5px rgba(0, 0, 0, .4),
    inset -1px -1px .5px -.5px rgba(0, 0, 0, .28),
    inset 0 0 3px 2px rgba(0, 0, 0, .05);
}

/* =====================================================================
   BOT LAUNCHER — "glass blob"  (VISUAL ONLY)
   ---------------------------------------------------------------------
   Everything that makes the launcher LOOK the way it does lives in this
   one block. Nothing about the chat panel/conversation UI is here — that
   is further down in the BOT WIDGET section.

   Structure, ported from the 21st.dev LiquidButton reference that the FAB
   was also built from. That component is three stacked layers inside one
   button, and this is the same three, adapted from a border-radius pill to
   an arbitrary silhouette:

     .blob-glass   backdrop-filter only  -> the refraction/frost
     .blob-fill    flat tint only        -> the body colour
     .blob-bevel   SVG strokes           -> the reference's box-shadow soup
     .bot-launcher-face / .bot-eye       -> the content layer (the eyes)
     .blob-shadow  separate sibling      -> the outer drop shadow

   Why an SVG for the bevel instead of the reference's `box-shadow`: a
   box-shadow can only follow a border-radius, never a clip-path silhouette.
   The reference gets its depth entirely from inset box-shadows, so on this
   shape each of those insets is redrawn as a stroke on the same path. The
   viewBox is 50 units over a 50px box, so 1 unit == 1px and the values map
   across from .fab-item/.fab-toggle 1:1 (each is commented with its source).

   TWO RULES THAT MUST NOT BE BROKEN — both cost several days of debugging:

   1. `backdrop-filter` is killed by a "backdrop root". Any ANCESTOR with
      clip-path / mask / filter / opacity becomes one, and the descendant
      then filters an empty backdrop and shows NO blur at all, silently.
      So .blob-glass carries its own clip-path and must never be wrapped in
      a clipped/filtered/masked parent, and .bot-launcher must never get a
      `filter` (that is exactly why the drop shadow lives on the separate
      .blob-shadow sibling rather than on the button).

   2. Verify blur against REAL page content behind the widget. Injecting a
      test element inside the button is a false positive — same-element
      content still gets sampled when the real page does not.

   Reference measurement (sharpness inside the shape / sharpness of the raw
   background; 1.00 = no blur): this blob 0.09–0.10, the FAB 0.10.
   ===================================================================== */

/* --- the button: a transparent hit target that positions everything ---
   No filter/opacity/mask here, ever — see rule 1 above. */
.bot-launcher {
  position: fixed;
  /* the corner itself now — it used to be offset left of the FAB, which is
     hidden as of the nav-tools change */
  right: var(--fab-edge);
  bottom: var(--fab-edge);
  z-index: 60;
  width: var(--blob-size);
  height: var(--blob-size);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  transition: transform 260ms var(--ease-soft);
}
.bot-launcher:hover { transform: scale(1.04); }
.bot-launcher:active { transform: scale(.94); }

/* --- shared geometry for the three visual layers --- */
.blob-glass,
.blob-fill,
.blob-bevel {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
/* clip-path belongs ON these elements, never on a wrapper (rule 1) */
.blob-glass,
.blob-fill {
  clip-path: url(#blobClip);
}

/* 1. Refraction + frost. The url() filter is the "liquid" part (turbulence
   + displacement, defined per page next to the markup); blur/saturate match
   .fab-item exactly. Declared twice so a browser that can't resolve a url()
   filter inside backdrop-filter drops only the second line and keeps a
   plain blur rather than losing the effect entirely. */
.blob-glass {
  -webkit-backdrop-filter: blur(5px) saturate(120%);
  backdrop-filter: blur(5px) saturate(120%);
}

/* 2. Body colour — the FAB's own token, so it tracks the light/dark scheme
   and the on-photo re-pin identically. The launcher's olive identity lives
   on the eyes instead (see .bot-eye). */
.blob-fill {
  background: var(--glass-bg-soft);
}

/* 3. Bevel — the reference's inset box-shadows, redrawn as strokes.
   Colours flip white -> black between schemes exactly as the FAB's insets
   do, which is why they're set here rather than as inline SVG attributes. */
.blob-bevel {
  pointer-events: none;
}
.blob-bevel use { stroke: #fff; }
.blob-bevel .rim-glow { stroke-opacity: .10; stroke-width: 2.5; } /* inset 0 0 3px 2px rgba(255,255,255,.07) */
.blob-bevel .rim-hi   { stroke-opacity: .50; stroke-width: .9; }  /* inset  1px  1px .5px -.5px rgba(255,255,255,.55) */
.blob-bevel .rim-lo   { stroke-opacity: .26; stroke-width: .8; }  /* inset -1px -1px .5px -.5px rgba(255,255,255,.3) */
.blob-bevel .rim-edge { stroke-opacity: .18; stroke-width: .7; }  /* border: 1px solid rgba(255,255,255,.18) */

html[data-scheme="light"] .blob-bevel use { stroke: #000; }
html[data-scheme="light"] .blob-bevel .rim-glow { stroke-opacity: .05; }
html[data-scheme="light"] .blob-bevel .rim-hi   { stroke-opacity: .40; }
html[data-scheme="light"] .blob-bevel .rim-lo   { stroke-opacity: .28; }
html[data-scheme="light"] .blob-bevel .rim-edge { stroke-opacity: .12; }

/* 4. Outer drop shadow — spill only, no fill behind the glass.
   A SEPARATE SIBLING of the button, never a child and never the button
   itself, because `filter` on an ancestor becomes a backdrop root and kills
   .blob-glass (rule 1 at the top of this section).

   The hard part, and why earlier attempts tinted the blob dark: a CSS outer
   `box-shadow` is defined to NEVER paint underneath the element's own box —
   that is precisely why the FAB's shadow doesn't discolour it. CSS
   `filter: drop-shadow()` has no such rule; it paints the whole blurred
   silhouette behind the shape, and since this sits directly behind
   .blob-glass, that silhouette was exactly what the backdrop-filter sampled
   instead of the page. Any fill opaque enough to cast a shadow was also
   opaque enough to dirty the glass — the two requirements were in direct
   conflict.
   The #blobDropShadow filter (defined per page in <defs>) resolves it by
   doing what box-shadow does: blur and offset the silhouette, then
   `feComposite operator="out"` against the original alpha to knock the shape
   itself back out, leaving only the spill outside the outline. The interior
   ends up fully transparent, so the glass samples the real page.
   Geometry/opacity match the FAB's `0 2px 8px rgba(0,0,0,.22)`:
   stdDeviation 4 (CSS blur radius ≈ 2 x stdDeviation), dy 2, opacity .22. */
.blob-shadow {
  position: fixed;
  right: var(--fab-edge); /* must track .bot-launcher exactly */
  bottom: var(--fab-edge);
  z-index: 59;
  width: var(--blob-size);
  height: var(--blob-size);
  overflow: visible; /* the spill has to escape the 50x50 viewport */
  transition: transform 260ms var(--ease-soft);
  pointer-events: none;
}
/* Opaque fill so the knock-out is clean (a semi-transparent source would
   only subtract part of the interior and leave shadow showing through it);
   the shadow's real strength comes from `opacity`, which SVG applies AFTER
   the filter. CSS beats the fill="none" presentation attribute on #blobPath,
   which is why this works via a stylesheet rule rather than an attribute. */
.blob-shadow use {
  fill: #000;
  /* Higher than the FAB's own .22 on purpose: this silhouette is concave, so
     it has less mass hugging the outline than a solid 50px circle and casts a
     visibly lighter shadow at the same alpha. Measured against the FAB on a
     flat field (mean luminance drop just below each shape, lower = stronger):
     at .22 the blob read 6.5 vs the FAB's 11.3; .34 brings them level. */
  opacity: .34;
}
html[data-scheme="light"] .blob-shadow use {
  opacity: .22; /* same ratio applied to the FAB's light value (.14) */
}
/* siblings can't see each other's :hover in CSS, so the shadow is matched
   to the button's scale through the common ancestor */
body:has(#botLauncher:hover) .blob-shadow { transform: scale(1.04); }
body:has(#botLauncher:active) .blob-shadow { transform: scale(.94); }

/* 5. Eyes — the content layer, and the launcher's colour identity now that
   the body is neutral glass. `position: relative` is required: a positioned
   sibling paints above non-positioned in-flow content regardless of DOM
   order, so without it the eyes render behind the glass layers. */
.bot-launcher-face {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(4px, 1.2vw, 6px);
  transform: translateY(-4px);
}
.bot-eye {
  --eye-x: 0px;
  --eye-y: 0px;
  --eye-scale: 1;
  width: clamp(4px, 1.3vw, 5px);
  height: clamp(10px, 2.9vw, 13px);
  border-radius: 999px;
  background: var(--accent);
  transform: translate(var(--eye-x), var(--eye-y)) scaleY(var(--eye-scale));
  transition: transform .1s ease-out;
}
/* --eye-x/--eye-y are driven by bot-widget.js (cursor tracking), --eye-scale
   by these states plus its blink timer — composing through custom properties
   keeps the two from fighting over the same `transform`. */
.bot-eye.is-blinking { --eye-scale: .12; }
.bot-launcher:hover .bot-eye { --eye-scale: .55; }
.bot-launcher:active .bot-eye { --eye-scale: .15; transition-duration: .05s; }
.bot-launcher.is-thinking .bot-eye { --eye-scale: .45; }

/* Toggled by each page's scroll-aware script when the launcher is over a
   dark photo — same re-pin the FAB uses, and live for the same reason now
   that the body reads these tokens instead of a hardcoded colour. */
.bot-launcher.bot-launcher--on-photo {
  --text: #f5f4f0;
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border-soft: rgba(255, 255, 255, .18);
  --accent: #d2db76;
}

@media (prefers-reduced-transparency: reduce) {
  .blob-glass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
  .blob-fill {
    background: var(--bg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .bot-launcher,
  .blob-shadow,
  .bot-eye {
    transition: none;
  }
}

/* Phone: the FAB moves to the top-right and the launcher takes the
   bottom-right corner alone. .blob-shadow is a separate fixed element, so it
   has to be repositioned in lockstep or it visibly detaches. */
@media (max-width: 480px) {
  .bot-launcher,
  .blob-shadow {
    top: auto;
    bottom: var(--fab-edge);
    right: var(--fab-edge);
  }
}
/* ===================== end bot launcher visual ===================== */

.fab-item {
  opacity: 0;
  transform: translateY(10px) scale(.85);
  pointer-events: none;
  transition: opacity .25s ease, transform .3s var(--ease-out), background .2s ease;
}
.quicknav.is-open .fab-item {
  opacity: 1;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}
.quicknav.is-open .fab-item:nth-child(4) { transition-delay: .02s; }
.quicknav.is-open .fab-item:nth-child(3) { transition-delay: .06s; }
.quicknav.is-open .fab-item:nth-child(2) { transition-delay: .1s; }
.quicknav.is-open .fab-item:nth-child(1) { transition-delay: .14s; }

.fab-toggle {
  font-size: 18px;
  transition: background .2s ease, transform .4s var(--ease-out);
}
.quicknav.is-open .fab-toggle {
  transform: rotate(45deg);
}
/* Font Awesome glyphs are not always centred inside their own em-box, so
   flex-centring the box is not the same as centring the visible ink. Both of
   these were measured (real pixel bounds at 8x scale), not guessed: the
   "+"/"x" toggle glyph sat ~0.81px high, the LinkedIn mark ~1px high. The
   nudge on .fab-toggle i is inside the rotated element so it rotates with it,
   which is correct — it centres the glyph in its own box whatever that box's
   current rotation. The other four fab-item icons measured within ~0.06px of
   centre (noise) and are untouched. If another icon reads off-centre, measure
   it the same way before nudging. */
.fab-toggle i {
  transform: translateY(0.81px);
}
.fa-linkedin-in {
  transform: translateY(1px);
}

.fab-item:focus-visible,
.fab-toggle:focus-visible,
.bot-launcher:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .fab-item, .fab-toggle { transition: opacity .15s linear; }
}

/* .bot-launcher is deliberately not in this list — the launcher button is a
   transparent hit target with no glass of its own, and its reduced-transparency
   handling lives with the rest of its visual in the BOT LAUNCHER section. */
@media (prefers-reduced-transparency: reduce) {
  .fab-item,
  .fab-toggle {
    background: var(--bg);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* =====================================================================
   BOT WIDGET — "Ask about me" chat launcher + panel. Launcher sits immediately
   left of the FAB (shares its size/edge tokens and on-photo re-pin pattern —
   see .fab--on-photo above). Panel is regular page chrome (not fixed over
   the photo), so it just follows the normal --bg/--text tokens per scheme.
   Corner radius is a deliberate third case beyond the brief's sharp-card /
   round-chrome split: an overlay panel reads as chrome but isn't circular,
   so it gets a moderate rounded corner instead of either extreme.
   ===================================================================== */

/* The chat widget is site chrome, not page content, so it keeps the site's own
   dark palette everywhere. Without this it inherited whatever the page re-pinned
   — on the Spex case study that meant a navy panel on a navy page, with no
   contrast at all. Pinned here rather than per-page so any future themed page
   gets it for free. */
.spx .bot-panel,
.spx .bot-launcher {
  --bg: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(255, 255, 255, .08);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border: rgba(255, 255, 255, .12);
  --glass-border-soft: rgba(255, 255, 255, .18);
}
html[data-scheme="light"] .spx .bot-panel,
html[data-scheme="light"] .spx .bot-launcher {
  --bg: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .66);
  --hairline: rgba(13, 13, 13, .16);
  --glass-bg: rgba(13, 13, 13, .075);
  --glass-bg-soft: rgba(13, 13, 13, .055);
  --glass-border: rgba(13, 13, 13, .16);
  --glass-border-soft: rgba(13, 13, 13, .16);
}

.bot-panel {
  position: fixed;
  /* aligned to the launcher's corner, sitting one launcher-height above it */
  right: var(--fab-edge);
  bottom: calc(var(--fab-edge) + var(--blob-size) + 14px);
  z-index: 61;
  width: min(360px, calc(100vw - 2 * var(--page-pad)));
  max-height: min(560px, calc(100vh - 120px));
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border: 1px solid var(--hairline);
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 24px 70px rgba(0, 0, 0, .35);
  transform-origin: bottom right;
  transform: translateY(16px) scale(.96);
  opacity: 0;
  pointer-events: none;
  transition: transform .3s var(--ease-out), opacity .2s ease;
}
.bot-panel.is-open {
  transform: translateY(0) scale(1);
  opacity: 1;
  pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
  .bot-panel { transition: opacity .15s linear; transform: none; }
}

.bot-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 16px;
  border-bottom: 1px solid var(--hairline);
  flex-shrink: 0;
}
.bot-panel-title {
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
}
.bot-panel-close {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: none;
  background: none;
  color: var(--text-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* 15px, matching .nav-tool and the info mark beside it. */
  font-size: 15px;
  transition: background .2s ease, color .2s ease;
}
.bot-panel-close:hover {
  background: var(--icon-wash);
  color: var(--text);
}
.bot-panel-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.bot-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  /* the OS default scrollbar is light/white and clashes hard on the dark
     panel — thin, token-based bar so it matches whichever scheme is active */
  scrollbar-width: thin;
  scrollbar-color: var(--glass-border-soft) transparent;
}
.bot-messages::-webkit-scrollbar {
  width: 8px;
}
.bot-messages::-webkit-scrollbar-track {
  background: transparent;
}
.bot-messages::-webkit-scrollbar-thumb {
  background: var(--glass-border-soft);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}
.bot-messages::-webkit-scrollbar-thumb:hover {
  background: var(--glass-border);
  background-clip: padding-box;
}

.bot-chips {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.bot-chip {
  text-align: left;
  padding: 10px 14px;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: none;
  color: var(--text-muted);
  font-family: var(--font);
  font-size: 13px;
  cursor: pointer;
  transition: border-color .2s ease, color .2s ease, background .2s ease;
}
.bot-chip:hover {
  border-color: var(--accent-wash-border);
  background: var(--accent-wash);
  color: var(--text);
}
.bot-chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.bot-msg {
  max-width: 85%;
  padding: 10px 14px;
  border-radius: 14px;
  font-size: 13.5px;
  line-height: 1.5;
  white-space: pre-wrap;
}
.bot-msg--user {
  align-self: flex-end;
  background: var(--accent-wash);
  color: var(--text);
  border-bottom-right-radius: 4px;
}
.bot-msg--bot {
  align-self: flex-start;
  background: var(--glass-bg-soft);
  color: var(--text);
  border-bottom-left-radius: 4px;
}
.bot-msg--error {
  align-self: flex-start;
  background: none;
  border: 1px dashed var(--dashed-border);
  color: var(--text-faint);
}
.bot-msg a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.bot-msg a:hover {
  opacity: .75;
}

.bot-typing {
  display: inline-flex;
  gap: 4px;
  align-self: flex-start;
  padding: 12px 14px;
  border-radius: 14px;
  border-bottom-left-radius: 4px;
  background: var(--glass-bg-soft);
}
.bot-typing span {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--text-faint);
  animation: bot-typing-pulse 1.1s ease-in-out infinite;
}
.bot-typing span:nth-child(2) { animation-delay: .15s; }
.bot-typing span:nth-child(3) { animation-delay: .3s; }
@keyframes bot-typing-pulse {
  0%, 60%, 100% { transform: translateY(0); opacity: .4; }
  30% { transform: translateY(-4px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .bot-typing span { animation: none; opacity: .7; }
}

.bot-input-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 14px;
  border-top: 1px solid var(--hairline);
  flex-shrink: 0;
}
.bot-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--hairline);
  transition: border-color .2s ease;
  background: var(--glass-bg-soft);
  border-radius: 999px;
  padding: 9px 14px;
  font-size: 13.5px;
  font-family: var(--font);
  color: var(--text);
}
.bot-input::placeholder { color: var(--text-faint); }
.bot-input:focus-visible {
  /* the border itself changes color instead of adding a separate outline on
     top of it — stacking outline+border read as "a stroke on a stroke," an
     odd double-ring around a small pill. One stroke, gray to subtle Olive. */
  outline: none;
  border-color: var(--accent-wash-border);
}
.bot-send {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1px solid var(--glass-border-soft);
  background: var(--glass-bg-soft);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  font-size: 13px;
  transition: background .2s ease, border-color .2s ease, opacity .2s ease;
}
.bot-send:hover:not(:disabled) {
  background: var(--accent-wash);
  border-color: var(--accent-wash-border);
}
.bot-send:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.bot-send:disabled {
  opacity: .4;
  cursor: default;
}

@media (max-width: 480px) {
  .bot-panel {
    left: var(--page-pad);
    right: var(--page-pad);
    bottom: calc(var(--fab-edge) + var(--fab-size) + 14px);
    width: auto;
    max-height: min(70vh, 520px);
  }

  /* Phone only: FAB moves to top-right (out of thumb range but still
     reachable), the chat launcher takes its old bottom-right spot alone —
     easier one-thumb reach for the thing visitors actually engage with.
     The FAB's menu normally opens upward from a bottom-anchored toggle;
     anchored at the top instead, it has to open downward or the items
     render off the top of the viewport — .fab-toggle gets order:-1 so it
     renders first (visually on top) with .fab-menu revealed below it,
     without touching DOM order or .fab-menu's own item order. */
  .quicknav {
    /* bottom-aligned with the nav section itself (same edge as the Résumé/
       LinkedIn/Contact links row) — not on --fab-edge, which is shared with
       .bot-launcher (still bottom-anchored) and isn't tied to the nav's own
       rhythm. This is a fallback for no-JS/pre-load only; the real alignment
       (each page's script right after this file loads) measures .hero-v3-nav's
       actual rendered bottom edge and overrides this, since the exact value
       here depends on which font in the stack loads. */
    top: calc(66px - var(--fab-size));
    bottom: auto;
  }
  .fab-toggle {
    order: -1;
  }
  .fab-menu {
    flex-direction: column;
    margin-bottom: 0;
    margin-top: clamp(10px, 3vw, 14px);
  }
  .fab-item {
    transform: translateY(-10px) scale(.85);
  }

  /* the launcher's own phone repositioning lives in the BOT LAUNCHER
     section, so its shadow sibling can't drift out of sync with it */
}

/* =====================================================================
   FOOTER — three columns sharing one bottom edge: copyright bottom-left, the
   contact block centred (label / email / socials), and an empty right column
   reserving the corner the chat launcher and docked chevron occupy. The email
   is the centrepiece, set at display size in the accent colour. Narrow screens
   restack it — see the media query at the end of this section.
   ===================================================================== */

.ftr {
  position: relative;
  background: var(--bg);
  /* bottom padding is --fab-edge, not a round number, so the footer's baseline
     row lands level with the fixed chat launcher (which is pinned that same
     distance from the viewport bottom) — otherwise the blob sits ~8px low
     against the copyright and email block */
  padding: 48px var(--page-pad) var(--fab-edge);
}

/* Three columns sharing one bottom edge: copyright bottom-left, the email
   block centred, and an empty right column that balances the centre and
   reserves the corner the fixed chat launcher occupies. align-items:end is
   what puts the copyright on the same baseline as the bottom of the centre
   block rather than floating mid-height. */
.ftr-row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: end;
  gap: clamp(16px, 3vw, 32px);
}
.ftr-copy {
  grid-column: 1;
  justify-self: start;
}
.ftr-spacer {
  grid-column: 3;
  /* matches the launcher's footprint so the centre block can never end up
     underneath it — see --blob-size / --fab-edge */
  width: calc(var(--blob-size) + var(--fab-edge));
}

/* Shrink-wrapped around its widest child (the email), so .ftr-meta can be
   width:100% and land its two ends exactly under the email's edges. */
.ftr-contact {
  grid-column: 2;
  justify-self: center;
  width: fit-content;
  max-width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.ftr-meta {
  width: 100%;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: clamp(16px, 4vw, 40px);
}

.ftr-social {
  display: flex;
  align-items: baseline;
  gap: clamp(8px, 2vw, 12px);
}
/* the "." separators from the reference, drawn rather than typed so they
   can't be selected or read out as content */
.ftr-social a + a::before {
  content: "";
  display: inline-block;
  width: 3px;
  height: 3px;
  margin-right: clamp(8px, 2vw, 12px);
  border-radius: 50%;
  background: currentColor;
  opacity: .55;
  vertical-align: middle;
}
.ftr-social a {
  color: var(--text-muted);
  text-decoration: none;
  transition: color .2s ease;
}
.ftr-social a:hover {
  color: var(--text);
}
.ftr-social a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* label, socials and copyright all share the navbar's link scale so the
   footer's small type is one size rather than three */
.ftr-label,
.ftr-social,
.ftr-copy {
  font-size: clamp(11px, 2.8vw, 13px);
  font-weight: 400;
  letter-spacing: normal;
}
.ftr-label {
  color: var(--text-faint);
}

.ftr-email {
  display: inline-block;
  font-family: var(--font);
  font-weight: 300;
  font-size: clamp(22px, 4.2vw, 58px);
  line-height: 1.15;
  letter-spacing: -.01em;
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
}
.ftr-email:hover {
  opacity: .85;
}
.ftr-email:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* size/letter-spacing now come from the shared rule above — this was
   clamp(11px,2.2vw,15px) with .04em tracking, which read noticeably larger
   and more spaced out than the navbar links it sits alongside */
.ftr-copy {
  color: var(--text-faint);
}
.ftr-heart {
  color: var(--accent);
}

/* Narrow-screen footer. Desktop is untouched — everything here is inside the
   query. Two differences from the desktop arrangement, both to stop the block
   reading as one undifferentiated wall of text on a phone:

   1. "Thanks for exploring" moves ABOVE the email, so the line that introduces
      the address comes before it instead of after. Order ends up
      label / email / socials.
   2. The copyright is parked at the very bottom, left-aligned, well clear of
      the rest — rather than centred immediately under the socials. */
@media (max-width: 700px) {
  .ftr {
    /* Back to a plain --fab-edge. The old value padded out a whole launcher's
       height because the copyright used to be centred and would have collided
       with it; the copyright now sits bottom-LEFT while the launcher stays
       bottom-RIGHT, so they share the row the way they do on desktop and the
       extra dead space at the page end is no longer needed. */
    padding-bottom: var(--fab-edge);
  }
  .ftr-row {
    grid-template-columns: 1fr;
    justify-items: center;
    gap: 14px;
  }
  .ftr-contact {
    grid-column: 1;
    grid-row: 1;
  }
  .ftr-spacer {
    display: none;
  }

  /* `display: contents` dissolves .ftr-meta so .ftr-label and .ftr-social
     become direct children of the .ftr-contact column, which is the only way
     `order` can move the label past .ftr-email — order only ever sorts
     siblings, and the label is otherwise nested a level deeper. .ftr-contact
     already centres its items and carries the 10px gap, so nothing is lost
     along with the box. */
  .ftr-meta {
    display: contents;
  }
  .ftr-label {
    order: -1;
  }
  .ftr-social {
    order: 1;
  }

  .ftr-copy {
    grid-column: 1;
    grid-row: 2;
    justify-self: start;
    /* Two jobs at once. It gives the copyright the separation the brief asked
       for, and it reserves the strip the fixed corner controls occupy so the
       centred block above can never run into them: at 28px the launcher +
       docked chevron reached up into the email row and the chevron's hit box
       clipped the last 5px of the address. The gap is not dead space — the
       copyright sits in it on the left, the blob and chevron on the right. */
    margin-top: calc(var(--blob-size) + 20px);
    /* Wraps before it can run under the fixed launcher in the opposite corner
       instead of disappearing behind it. */
    max-width: calc(100% - var(--blob-size) - var(--fab-edge) - 12px);
  }

  .ftr-email {
    white-space: normal;
    text-align: center;
    font-size: clamp(24px, 7vw, 32px);
  }
}

/* =====================================================================
   HOBBY WORDS — the hero subtitle's "skate / tennis / drum" buttons. Accent
   colour on hover and focus, plus a recorded one-shot per word (see the sound
   engine in index.html). That is the whole feature.

   A circular photo-reveal used to sit on top of this and has been removed: it
   was never wired up (index.html stopped generating the markup) and sat as
   ~90 lines of dormant CSS. If it ever comes back it should be built fresh
   against whatever the hero looks like then, not restored from here.
   ===================================================================== */

/* Light-mode hero: the same photo under a bone scrim with dark ink, so the
   hero reads as part of the light page rather than a dark block sitting in
   it. Mirrors the values in html[data-scheme="light"] at the top of the file
   — restated here because the hero re-pins these tokens locally, so it can't
   simply inherit them. */
html[data-scheme="light"] .hero-v3 {
  --bg: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .66);
  --accent: #737841;
  --glass-bg: rgba(13, 13, 13, .075);
  --glass-border: rgba(13, 13, 13, .16);
  --accent-wash: rgba(115, 120, 65, .14);
  --accent-wash-border: rgba(115, 120, 65, .3);
}
html[data-scheme="light"] .hero-v3-overlay {
  /* MEASURED, not picked. See the light-mode note in DESIGN-BRIEF.md: at .72
     the headline sat at 8.96:1 against the composited photo, and the photo was
     gone. .50 keeps 4.53:1 for the headline and 6.61:1 for the subtitle, which
     clears the small-text floor as well as the large-text one. */
  background: rgba(245, 244, 240, .5);
}
/* The launcher's on-photo re-pin exists to keep it legible over a DARK photo.
   With a bone scrim there's nothing to compensate for, so in light mode it
   restates the ordinary light values instead of forcing light-on-dark. */
html[data-scheme="light"] .bot-launcher.bot-launcher--on-photo {
  --text: #0d0d0d;
  --glass-bg-soft: rgba(13, 13, 13, .055);
  --glass-border-soft: rgba(13, 13, 13, .16);
  --accent: #737841;
}

.hobby-word {
  position: relative;
  display: inline;
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: color .3s ease, opacity .3s ease;
}
.hobby-word:hover,
.hobby-word:focus-visible {
  color: var(--accent);
}
.hobby-word:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .hero-v3-headline,
  .hero-v3-overlay {
    transition: opacity 150ms ease;
  }
}

/* =====================================================================
   CASE STUDY TEMPLATE — shared wireframe for project pages. project2.html
   is the first pass; project1.html (and any future case study) should
   reuse these same classes so every project page reads as one system
   rather than a one-off. Content/section count can vary per project —
   the shell, spacing rhythm, and component atoms (eyebrow, frame, row,
   card, gallery) shouldn't.

   Nav is deliberately just `.hero-v3-nav` reused with zero overrides —
   it must render pixel-identical to the homepage hero nav, not a
   case-study-specific variant.
   ===================================================================== */

.cs-shell {
  max-width: 1120px;
  margin: 0 auto;
  padding: 0 var(--page-pad);
}

/* The name is a link back home on the case-study pages. This used to be
   `.hero-v3-nav > a` only, which stopped matching when the nav grew a
   .hero-v3-nav-main wrapper — the link sat one level deeper and rendered as a
   default blue underlined browser link on project2 and project3. The child
   selector on the wrapper matches only that link, not .hero-v3-links a, which
   has its own styling at the same specificity. */
.hero-v3-nav > a,
.hero-v3-nav-main > a {
  text-decoration: none;
  color: inherit;
}

.cs-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  font-size: 12.5px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-muted);
}
.cs-eyebrow::before {
  content: '';
  width: 5px;
  height: 5px;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--accent-dot);
}
.cs-section > .cs-eyebrow {
  margin-bottom: 18px;
}

.cs-hero {
  padding: clamp(48px, 8vw, 88px) 0 clamp(40px, 6vw, 64px);
}

.cs-title {
  margin: 18px 0 14px;
  font-weight: 300;
  font-size: clamp(30px, 5.4vw, 54px);
  line-height: 1.12;
  letter-spacing: -.01em;
  color: var(--text);
}

/* subtitle + client mark, paired as the page's first paragraph */
.cs-intro {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 40px;
  margin-bottom: clamp(32px, 5vw, 48px);
}
.cs-subtitle {
  margin: 0;
  max-width: 56ch;
  font-size: 16px;
  line-height: 1.55;
  color: var(--text-muted);
}
.cs-intro-mark {
  flex-shrink: 0;
  width: 88px;
  height: auto;
  opacity: .92;
}

.cs-meta {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 24px var(--gutter);
  padding-top: 28px;
  margin-bottom: clamp(32px, 5vw, 56px);
  border-top: 1px solid var(--hairline);
}
.cs-meta-label {
  margin: 0 0 8px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.cs-meta-value {
  margin: 0;
  font-size: 14.5px;
  font-weight: 500;
  line-height: 1.5;
  color: var(--text);
}

/* mockup shots are transparent-background renders with their own built-in
   shadow — no border/card chrome, they just float on the page bg. Always
   used inside a .cs-row-media column, never full-width on its own. */
.cs-frame {
  max-width: 460px;
  margin: 0 auto;
}
.cs-frame img {
  display: block;
  width: 100%;
  height: auto;
}

.cs-overview-body {
  margin: 0;
  max-width: 52ch;
  font-size: 15.5px;
  line-height: 1.75;
  color: var(--text-muted);
}

.cs-section {
  padding: clamp(56px, 8vw, 96px) 0;
  border-top: 1px solid var(--hairline);
}

.cs-heading {
  margin: 0 0 14px;
  font-weight: 300;
  font-size: clamp(22px, 3vw, 32px);
  line-height: 1.2;
  color: var(--text);
}
.cs-heading--lg {
  font-size: clamp(26px, 4vw, 40px);
  margin-bottom: 28px;
}

.cs-body {
  margin: 0;
  max-width: 48ch;
  font-size: 15px;
  line-height: 1.75;
  color: var(--text-muted);
}
.cs-stat {
  color: var(--accent);
  font-weight: 500;
}

/* shared image+text row — used for the hero mockup, the achievement
   mockup, and both approach steps, so every pairing in the page reads
   as one rhythm instead of a one-off layout per section */
.cs-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(32px, 5vw, 64px);
  align-items: start;
}
.cs-row + .cs-row {
  margin-top: clamp(48px, 7vw, 80px);
}
.cs-row--reverse .cs-row-text { order: 2; }
.cs-row--reverse .cs-row-media { order: 1; }

/* research/process photos — real images, no stroke, natural aspect so
   nothing legible (archive text, map arrows) gets cropped out */
.cs-row-media figure {
  margin: 0;
}
.cs-row-media img {
  display: block;
  width: 100%;
  height: auto;
}
.cs-row-caption {
  margin: 10px 2px 0;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}

.cs-gallery {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--gutter);
  margin-top: clamp(24px, 4vw, 40px);
}
.cs-gallery img {
  display: block;
  width: 100%;
  height: auto;
}

@media (max-width: 900px) {
  .cs-meta { grid-template-columns: repeat(2, 1fr); }
  .cs-row { grid-template-columns: 1fr; }
  .cs-row--reverse .cs-row-text,
  .cs-row--reverse .cs-row-media { order: initial; }
}

@media (max-width: 700px) {
  .cs-intro { flex-direction: column; align-items: flex-start; gap: 20px; }
  .cs-intro-mark { width: 64px; }
}

@media (max-width: 560px) {
  .cs-gallery { grid-template-columns: 1fr; }
  .cs-meta { grid-template-columns: repeat(2, 1fr); gap: 20px var(--gutter); }
}

/* =====================================================================
   CASE STUDY TEMPLATE — UX cover variant. Adds a full-bleed cover hero,
   an image-slot placeholder, and a couple of new grid shapes on top of
   the .cs-* atoms above. Same tokens, same rhythm — project3.html is
   the first page to use it.
   ===================================================================== */

.cs-block {
  padding: clamp(56px, 8vw, 96px) 0;
}

.cs-cover {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 320px;
  overflow: hidden;
  /* stays dark-scrimmed in both schemes, same local re-pin as .hero-v3 */
  --bg: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --accent-dot: #d2db76;
}
.cs-cover-scrim {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, .45);
  pointer-events: none;
}
.cs-cover-feather {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent 60%, #0d0d0d 99%);
  pointer-events: none;
}

.cs-cover-content {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  padding-bottom: clamp(24px, 3.5vw, 48px);
}
.cs-cover-title {
  margin: 14px 0 0;
  max-width: 20ch;
  font-weight: 300;
  font-size: clamp(34px, 6vw, 68px);
  line-height: 1.05;
  letter-spacing: -.015em;
  color: var(--text);
}
.cs-cover-subtitle {
  margin: 16px 0 0;
  max-width: 52ch;
  font-size: 16px;
  line-height: 1.55;
  color: var(--text-muted);
}

/* empty image placeholder — stands in for a real photo until one exists */
.cs-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: 16px;
  text-align: center;
  background: var(--glass-bg-soft);
  border: 1px dashed var(--dashed-border);
}
.cs-slot span {
  max-width: 30ch;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .01em;
  line-height: 1.6;
  color: var(--text-faint);
  text-transform: none;
}

.cs-meta--6 {
  grid-template-columns: repeat(6, 1fr);
}

.cs-artifacts {
  padding: 0 var(--gutter);
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: var(--gutter);
}
.cs-artifacts-big {
  aspect-ratio: 16 / 11;
}
.cs-artifacts-stack {
  display: grid;
  grid-template-rows: 1fr 1fr;
  gap: var(--gutter);
}

.cs-num {
  margin: 0 0 10px;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .06em;
  color: var(--accent);
}

.cs-moments {
  display: flex;
  flex-direction: column;
  gap: clamp(64px, 9vw, 112px);
}

.cs-row--end {
  align-items: end;
}

.cs-stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--gutter);
}
.cs-stat-num {
  margin: 0;
  font-size: clamp(26px, 3.4vw, 42px);
  font-weight: 300;
  letter-spacing: -.02em;
  color: var(--accent);
}
.cs-stat-label {
  margin: 8px 0 0;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}

.cs-endnav {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(26px, 3.5vw, 40px) 0 clamp(56px, 7vw, 88px);
  border-top: 1px solid var(--hairline);
}
.cs-endnav a {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 500;
  text-decoration: none;
  transition: color .2s ease;
}
.cs-endnav a:hover { color: var(--text); }
.cs-endnav a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

@media (max-width: 900px) {
  .cs-meta--6 { grid-template-columns: repeat(2, 1fr); }
  .cs-artifacts { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
  .cs-cover-title { font-size: clamp(28px, 8vw, 40px); }
}

/* =====================================================================
   UX PROJECT CARDS — the two big work-grid tiles (replaces the old
   .wtile--empty placeholder pattern). Deliberately NOT re-pinned dark like
   the photo tiles below: the fill is a translucent tint over the page's
   own --bg, so it genuinely shifts with the light/dark toggle. Header/desc
   reuse .wtile-title/.wtile-desc so the type matches the graphic tiles.
   ===================================================================== */

.wux-card {
  position: relative;
  overflow: hidden;
  display: flex;
  color: var(--text);
  text-decoration: none;
  min-height: 320px;
}
.wux-card:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Dark scheme. Each card is a fixed pair of brand values — YSU app near-black,
   Spex its own navy — inverted for the light scheme just below. Each locally
   re-pins --text/--text-muted/--text-faint/--glass-* so its content AND the
   cursor-follow label (a child of the card) stay legible regardless of the
   site's global light/dark toggle, same technique as .hero-v3. */
.wux-card--ysu {
  /* INVERTED from the page: bone surface, near-black ink, on the dark page.
     The previous approach was a lifted black (#191816) on a black page, which
     needed retuning every time either value moved. The opposite value never
     has that problem, and a bone card on a black page is the shape this kind
     of tile takes on Apple's own pages. */
  background: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .55);
  --glass-bg: rgba(13, 13, 13, .055);
  --glass-border: rgba(13, 13, 13, .16);
  align-items: center;
  justify-content: center;
  gap: clamp(32px, 6vw, 90px);
  padding: clamp(24px, 4vw, 48px);
}
/* Inverted from the original pale-blue-on-navy: Spex's two brand values are
   the same, just swapped, so the card now runs navy #003057 as the surface
   with pale #e3eef6 as the ink. Muted/faint are the pale value stepped down
   rather than new colours. */
.wux-card--spex {
  background: #003057;
  --text: #e3eef6;
  --text-muted: rgba(227, 238, 246, .68);
  --text-faint: rgba(227, 238, 246, .6);
  --glass-bg: rgba(227, 238, 246, .07);
  --glass-border: rgba(227, 238, 246, .18);
  flex-direction: column;
  align-items: center;
  text-align: center;
  justify-content: space-between;
  gap: clamp(10px, 2vw, 18px);
  padding: clamp(24px, 4vw, 36px) clamp(24px, 4vw, 40px);
}

/* ---- Light scheme: both UX cards invert -----------------------------------
   In light mode each card swaps its surface and its ink rather than picking
   new colours: YSU runs bone #f5f4f0 with near-black type, Spex runs pale
   #e3eef6 with navy #003057 type. Same four brand values as dark mode, just
   the other way round, so the pair keeps reading as one dark tile and one
   blue tile at a glance in either scheme.

   These re-pin the same custom properties the dark rules do, which is what
   carries the change through to the cursor-follow label parented inside each
   card, and to the Spex logo (a mask painted with --text). */
html[data-scheme="light"] .wux-card--ysu {
  /* The same inversion the other way up: the page is bone, so the tile is the
     site's black. */
  background: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-border: rgba(255, 255, 255, .12);
}
html[data-scheme="light"] .wux-card--spex {
  background: #e3eef6;
  --text: #003057;
  --text-muted: rgba(0, 48, 87, .68);
  --text-faint: rgba(0, 48, 87, .6);
  --glass-bg: rgba(0, 48, 87, .07);
  --glass-border: rgba(0, 48, 87, .18);
}
/* The device shadows are tuned for dark surfaces; at full strength they read
   as dirt on a pale card. Same shape, less weight.

   The YSU phone's shadow is keyed to the TILE rather than the scheme, because
   that tile inverts: it is the pale one in DARK mode now. The Spex screen
   still follows the scheme, because the Spex tile still does. */
.wux-card--ysu .wux-phone {
  box-shadow: 0 20px 60px rgba(0, 0, 0, .18);
}
html[data-scheme="light"] .wux-card--ysu .wux-phone {
  /* black tile: there is nothing for a black shadow to do */
  box-shadow: none;
}
html[data-scheme="light"] .wux-screen {
  box-shadow: 0 20px 50px rgba(0, 48, 87, .16);
}

.wux-copy {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: clamp(6px, 2vw, 12px);
}
.wux-card--ysu .wux-copy {
  max-width: 30ch;
}
.wux-card--spex .wux-copy {
  max-width: 46ch;
}
.wux-card--spex .wtile-desc {
  margin: 0 auto;
}

.wux-logo {
  position: relative;
  z-index: 2;
  display: block;
}
.wux-logo--ysu {
  width: clamp(56px, 8vw, 78px);
  height: auto;
  margin-top: 16px;
  opacity: .95;
}
/* A <span> painted through the logo PNG as a mask, rather than an image tag:
   art is solid navy, which would disappear against the now-navy card. Masking
   means the mark simply takes --text, so it stays correct if the card's
   colours are ever swapped back. */
.wux-logo--spex {
  width: 96px;
  height: 26px;
  background: var(--text);
  -webkit-mask: url("img/spex-logo.png") center / contain no-repeat;
  mask: url("img/spex-logo.png") center / contain no-repeat;
}

/* Sized to fit BOTH axes of the card so narrowing the window never crops
   the video — phone is height-led (tall shape, card can get short), screen
   sized by width, with the card's own height now auto (content-driven, see
   .wux-card) instead of a fixed aspect-ratio — that's what used to force a
   too-short card at narrower widths and crop the video top/bottom. */
.wux-phone,
.wux-screen {
  position: relative;
  flex-shrink: 0;
  overflow: hidden;
}
/* Only the browser-window mock gets a black bed. The phone must NOT: with
   object-fit: contain any rounding error shows as a black hairline, which on
   a bone tile is a visible line. Transparent means a stray sub-pixel shows
   the tile colour instead, which is invisible either way. */
.wux-screen {
  background: #000;
}
/* The clip inside this carries a real device — frame, bezel, rounded corners.
   No shadow: the recording already has one under the device.

   The aspect ratio matches the TILE export exactly (522x1080). It used to be
   436/876 from the old frameless capture, and with object-fit: cover that
   mismatch silently cropped the clip's sides and bottom — which is what made
   the phone look cut off.

   The radius stays, and it is doing a different job than before. The tile
   export is cropped to the device's own frame edge, so the only thing outside
   the device's silhouette is the small sliver of recording canvas inside each
   rounded corner — and the tile's background is a lifted card colour, not the
   page colour the canvas matches, so that sliver would show. A radius scaled
   to the device's own corners clips exactly those slivers. It traces the
   device rather than framing it.

   ~0.15 of the width is an iPhone's outer corner radius, so the clamp tracks
   the width clamp above: 150 -> 22px, 260 -> 39px. */
.wux-phone {
  --phone-w: clamp(150px, 22vw, 260px);
  width: var(--phone-w);
  /* Matches the re-encoded tile clip exactly. It was 522/1080; the clip is
     522x1078 now, two rows shorter because the last row of the recording was
     a dark letterbox line. */
  aspect-ratio: 522 / 1078;
  /* 0.17 of the width, and DERIVED from the width rather than a second clamp
     that has to be kept in step with it. Measured from the clip: the device's
     own outer corner radius is 0.163-0.169 of the crop width, where the old
     clamp worked out to 0.147-0.150. That left a sliver of recording canvas
     inside each corner, invisible only while the tile colour was close to the
     canvas colour. The tile is bone now, so it would have shown. */
  border-radius: calc(var(--phone-w) * .17);
  overflow: hidden;
}
.wux-screen {
  width: min(100%, 480px);
  display: flex;
  flex-direction: column;
  border-radius: 14px;
  box-shadow: 0 20px 50px rgba(0, 0, 0, .25);
}
.wux-phone video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* contain, not cover: with the ratio matched these are equivalent, and
     contain is the one that stays correct if a future recording's ratio
     drifts — it scales the whole thing down instead of cropping the edges. */
  object-fit: contain;
  display: block;
}

/* A labeled, restrained nod to a browser window — three dots, no fake
   address-bar text. Fixed light chrome regardless of scheme: it's standing
   in for a real macOS titlebar, not site content. */
.wux-screen-bar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 7px;
  height: 28px;
  padding: 0 14px;
  background: #e4e4e4;
}
.wux-screen-dot {
  width: 11px;
  height: 11px;
  border-radius: 50%;
}
.wux-screen-dot--red { background: #ff5f57; }
.wux-screen-dot--yellow { background: #febc2e; }
.wux-screen-dot--green { background: #28c840; }

.wux-screen-body {
  position: relative;
  flex: 1;
  aspect-ratio: 1072 / 680;
}
.wux-screen-body video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

@media (max-width: 900px) {
  .wux-card--ysu {
    flex-direction: column;
    justify-content: center;
    gap: 28px;
  }
}

/* Cursor-follow label — any tile with data-cursor-label gets one (both
   .wux-card and .wtile use it, for a consistent hover language across the
   whole grid instead of a blurred overlay). Real pointers only; touch/
   no-hover devices never hide the cursor and the tile stays a plain link
   with no custom affordance. Inherits --text/--glass-bg/--glass-border from
   whichever local re-pin its host element sets, so it's automatically
   legible on fixed-color cards and dark-scrimmed photo tiles alike. */
@media (hover: hover) and (pointer: fine) {
  [data-cursor-label].cursor-active {
    cursor: none;
  }
}

/* Same liquid-glass recipe as the FAB, for one consistent glass "material"
   across the site: blur/saturate values matched exactly (was blur(12px)
   saturate(140%)) and the same #fab-liquid-glass turbulence/displacement
   warp layered on top. Unlike the bot-launcher, this is a real border-radius
   pill, not an arbitrary alpha silhouette, so — same as the FAB — box-shadow
   works natively here and gets the identical bevel treatment (light-mode
   override right below), no drop-shadow/mask workaround needed. */
.cursor-label {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 6;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px;
  border-radius: 999px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(16px) saturate(120%);
  -webkit-backdrop-filter: blur(16px) saturate(120%);
  box-shadow:
    0 2px 8px rgba(0, 0, 0, .22),
    inset 1px 1px .5px -.5px rgba(255, 255, 255, .55),
    inset -1px -1px .5px -.5px rgba(255, 255, 255, .3),
    inset 0 0 3px 2px rgba(255, 255, 255, .07);
  color: var(--text);
  font-size: 12.5px;
  font-weight: 500;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity .25s ease;
  will-change: transform;
}
html[data-scheme="light"] .cursor-label {
  box-shadow:
    0 2px 8px rgba(0, 0, 0, .14),
    inset 1px 1px .5px -.5px rgba(0, 0, 0, .4),
    inset -1px -1px .5px -.5px rgba(0, 0, 0, .28),
    inset 0 0 3px 2px rgba(0, 0, 0, .05);
}
.cursor-label.is-visible {
  opacity: 1;
}
/* The eye glyph reads as a small olive dot ahead of the label text. */
.cursor-label i {
  font-size: 11px;
  color: var(--accent-dot);
}
@media (prefers-reduced-motion: reduce) {
  .cursor-label { transition: none; }
}

/* =====================================================================
   SPEX CASE STUDY  (project3.html)
   ---------------------------------------------------------------------
   A project page that takes on its project's colours while keeping the
   site's structure, type scale and motion. Everything here is scoped under
   .spx so it can't leak into the other case study or the landing page.

   Palette follows the work-grid tile's inversion exactly (see the brand
   table in DESIGN-BRIEF.md): navy surface with bone ink in dark mode, pale
   blue surface with navy ink in light. Only the two Spex brand values plus
   our own bone are in play — no third colour is introduced.
   ===================================================================== */

.spx {
  /* Local token re-pin, the same technique .hero-v3 and the work cards use.
     Everything inside the page — including the chat launcher's on-photo
     variant and the cursor label — inherits from here. */
  --navy: #003057;
  --navy-deep: #00233f;
  --pale: #e3eef6;
  --bone: #f5f4f0;

  --bg: var(--navy);
  --text: var(--bone);
  --text-muted: rgba(245, 244, 240, .68);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(245, 244, 240, .14);
  --glass-bg: rgba(245, 244, 240, .05);
  --glass-bg-soft: rgba(245, 244, 240, .08);
  --glass-border: rgba(245, 244, 240, .16);
  --glass-border-soft: rgba(245, 244, 240, .22);

  /* Two lifts used by the glows below. Kept as tokens so the whole page's
     atmosphere can be retuned from one place. */
  --glow-a: rgba(56, 96, 200, .55);
  --glow-b: rgba(120, 165, 235, .30);

  background: var(--bg);
  color: var(--text);
}

/* The footer is a sibling of the page content, not a descendant of it, so with
   the scope on <main> it kept the site's jet black and read as a hard seam
   under the navy. On <body> everything inherits, including the nav and the
   chat launcher. */
body.spx .ftr {
  background: var(--bg);
}

html[data-scheme="light"] .spx {
  --bg: var(--pale);
  --text: var(--navy);
  --text-muted: rgba(0, 48, 87, .72);
  --text-faint: rgba(0, 48, 87, .58);
  --hairline: rgba(0, 48, 87, .16);
  --glass-bg: rgba(0, 48, 87, .05);
  --glass-bg-soft: rgba(0, 48, 87, .07);
  --glass-border: rgba(0, 48, 87, .18);
  --glass-border-soft: rgba(0, 48, 87, .24);
  --glow-a: rgba(120, 165, 235, .42);
  --glow-b: rgba(0, 48, 87, .10);
}

/* --- measure ---------------------------------------------------------
   Wider side padding than the landing page's 20px. Long-form reading is
   the whole job on this page and full-bleed text is hard to skim, so the
   shell keeps a generous gutter that only collapses toward --page-pad on
   phones. Full-bleed sections opt out by not using the shell. */
.spx-shell {
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 clamp(20px, 6vw, 72px);
}
.spx-shell--wide {
  max-width: 1420px;
}

.spx-section {
  position: relative;
  /* Clips the glows. They are placed past the section edges on purpose so the
     light falls off outside the content, which without this pushes the
     document wider than the viewport and gives the whole page a horizontal
     scrollbar. */
  overflow: hidden;
  padding: clamp(72px, 11vw, 152px) 0;
}
.spx-section--tight {
  padding: clamp(48px, 7vw, 88px) 0;
}
.spx-section + .spx-section {
  padding-top: 0;
}

/* --- atmosphere ------------------------------------------------------
   Soft radial lifts sitting behind content so the large navy fields don't
   read as flat. Deliberately irregular: an even border-radius reads as a
   deliberate circle, whereas uneven radii plus a heavy blur read as light.

   `filter: blur()` makes an element a backdrop root, which is normally the
   bug that eats this site's glass (see DESIGN-BRIEF §4) — safe here only
   because these are empty decorative layers with nothing inside them, and
   the chat launcher is a fixed sibling of the page, never a descendant.
   Don't nest glass inside one of these. */
.spx-glow {
  position: absolute;
  z-index: 0;
  pointer-events: none;
  border-radius: 54% 46% 38% 62% / 48% 58% 42% 52%;
  background: radial-gradient(closest-side, var(--glow-a), transparent 72%);
  filter: blur(70px);
  opacity: .85;
}
.spx-glow--b {
  background: radial-gradient(closest-side, var(--glow-b), transparent 70%);
  border-radius: 42% 58% 56% 44% / 56% 44% 52% 48%;
  filter: blur(90px);
}
/* Content has to sit above the glows without each section needing a z-index. */
.spx-section > .spx-shell,
.spx-section > .spx-bleed {
  position: relative;
  z-index: 1;
}
@media (prefers-reduced-transparency: reduce) {
  .spx-glow { display: none; }
}

/* --- type ------------------------------------------------------------
   Sizes come straight off the system's scale; only the two display steps
   are new, and both are interpolations of existing ones rather than
   invented values. */
.spx-h1 {
  margin: 0;
  font-weight: 300;
  font-size: clamp(34px, 6.4vw, 72px);
  line-height: 1.06;
  letter-spacing: -.01em;
}
.spx-h2 {
  margin: 0 0 var(--stack-gap);
  font-weight: 300;
  font-size: clamp(26px, 3.6vw, 44px);
  line-height: 1.14;
  letter-spacing: -.01em;
}
.spx-h3 {
  margin: 0 0 12px;
  font-weight: 400;
  font-size: clamp(20px, 2.4vw, 28px);
  line-height: 1.2;
  letter-spacing: -.01em;
}
/* .spx-lead is the same voice as .spx-body, one step up in size — same colour,
   same weight. They had drifted (300 vs inherited 400), which read as the
   reflection and impact copy being a different grey from the rest. */
.spx-lead {
  margin: 0;
  max-width: 54ch;
  font-weight: 400;
  font-size: clamp(14px, 1.45vw, 17px);
  line-height: 1.66;
  color: var(--text-muted);
}
.spx-body {
  margin: 0;
  max-width: 46ch;
  font-size: clamp(13px, 1.25vw, 15px);
  line-height: 1.7;
  color: var(--text-muted);
}
.spx-body + .spx-body {
  margin-top: 1.1em;
}
.spx-body strong {
  font-weight: 500;
  color: var(--text);
}
/* Section marker: olive dot + small caps, matching .cs-eyebrow's language
   but re-pinned to this page's palette. */
.spx-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin: 0 0 var(--stack-gap);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.spx-eyebrow::before {
  content: "";
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent-dot);
}
/* The moment numbers pick up the accent — the one olive splash on the page
   besides the section dots, and it makes the 01/02/03/04 sequence scannable
   for someone skimming rather than reading. */
.spx-num {
  color: var(--accent);
  opacity: .9;
}

/* Mockups lift very slightly toward the reader on hover. Not a hover state in
   the interactive sense — nothing here is clickable — just enough motion to
   signal the page is alive while someone reads it. */
.spx-plate,
.spx-phone,
.spx-photo {
  transition: transform var(--dur-slow) var(--ease-soft);
}
.spx-plate:hover,
.spx-photo:hover {
  transform: translateY(-4px);
}
.spx-artifacts-pair .spx-phone:hover,
.spx-artifacts-mid .spx-phone:hover {
  transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
  .spx-plate,
  .spx-phone,
  .spx-photo { transition: none; }
  .spx-plate:hover,
  .spx-photo:hover,
  .spx-artifacts-pair .spx-phone:hover,
  .spx-artifacts-mid .spx-phone:hover { transform: none; }
}

.spx-caption {
  margin: 14px 0 0;
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.spx-num {
  display: block;
  margin: 0 0 10px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .14em;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* --- media -----------------------------------------------------------
   Most mockups were exported raw at 2x, so the rounding and the scrim live
   here rather than being baked into the files. Phone and browser mocks
   already carry their own device frame and a transparent margin, so they
   get neither. */
.spx-media {
  display: block;
  width: 100%;
  height: auto;
}

/* .cs-slot is built to fill a sized parent (height: 100%), which collapses it
   to nothing when the only thing giving it height is its own aspect-ratio.
   These placeholders stand in a plain grid cell, so they size themselves. */
.spx .cs-slot {
  height: auto;
  border-radius: 16px;
}
/* For raw exports: photographs and flat screenshots. */
.spx-media--round {
  border-radius: 14px;
  overflow: hidden;
}
/* A framed plate for a raw screenshot — rounding plus a hairline so it
   reads as an artifact on the page rather than a bleed. */
.spx-plate {
  position: relative;
  overflow: hidden;
  border-radius: 16px;
  border: 1px solid var(--glass-border-soft);
  background: var(--glass-bg);
}
.spx-plate > img,
.spx-plate > video {
  display: block;
  width: 100%;
  height: auto;
}
/* Never display a video wider than it was recorded. The phone report-flow clip
   is a 388px-wide screen recording; given a full column it stretched to 575px
   and upscaled 1.48x, which is the one thing guaranteed to look blurry. 380px
   is also a comfortable size for a phone mock (see the legibility table in
   DESIGN-BRIEF.md) — its own UI text lands near 12px.

   The cap is on the PLATE, not the video. Capping only the video left the
   plate's fill and border stretched across the whole column with the clip
   floating inside it — a box visibly bigger than its contents. */
.spx-plate--native {
  max-width: 380px;
}
/* No frame, just rounding and a lift. For opaque exports — photographs and
   paper sketches — where the hairline border reads as an unwanted picture
   frame around art that already has its own edge. */
.spx-plate--bare {
  border: none;
  background: none;
  box-shadow: 0 14px 34px rgba(0, 20, 38, .34);
}

/* Scrim for the exports whose filename asks for an overlay. Sits above the
   image, below any text. */
.spx-plate--scrim::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(0, 35, 63, .18), rgba(0, 35, 63, .58));
  pointer-events: none;
}
/* Device mocks: no frame, no radius, just a soft lift so they don't float
   flatly on the navy. */
.spx-phone {
  display: block;
  width: 100%;
  height: auto;
  filter: drop-shadow(0 18px 42px rgba(0, 20, 38, .42));
}

/* --- hero ------------------------------------------------------------ */
/* The hero has to fit whatever screen it lands on. Sized purely by width it
   came out ~970px tall regardless of viewport, which is fine on a large monitor
   and cropped the title off a 1280x720 laptop. So: one viewport tall, contents
   centred, and the phone row gives up height before the copy does. */
.spx-hero {
  position: relative;
  overflow: hidden;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(72px, 8vw, 104px) 0 clamp(104px, 11vw, 140px);
  text-align: center;
}
.spx-hero > .spx-shell {
  display: flex;
  flex-direction: column;
  min-height: 0;
  width: 100%;
}
.spx-hero .spx-glow--1 {
  top: -14%;
  left: 50%;
  transform: translateX(-50%);
  width: min(1100px, 120vw);
  aspect-ratio: 1.5;
}
/* Four phones on one line, shoulder to shoulder. They overlap slightly on
   purpose so the row reads as one artifact rather than four loose images. */
/* Height, not width, is the binding constraint on a laptop. The row is a flex
   child that may shrink, and each device is capped by BOTH the track width and
   a share of the viewport height — whichever runs out first. justify-items
   centres them in their tracks, because when the height cap is the one binding
   the image is narrower than its cell. */
.spx-hero-phones {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(10px, 2.4vw, 34px);
  max-width: 1000px;
  justify-items: center;
  align-items: end;
  width: 100%;
  margin: 0 auto clamp(26px, 3.2vw, 44px);
  flex: 0 1 auto;
  min-height: 0;
}
.spx-hero-phones .spx-phone {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: min(46svh, 460px);
}
.spx-hero-title {
  max-width: 22ch;
  margin: 0 auto clamp(16px, 2.4vw, 26px);
}
.spx-hero-sub {
  max-width: 44ch;
  margin: 0 auto;
  font-size: clamp(13px, 1.35vw, 16px);
  line-height: 1.6;
  color: var(--text-muted);
}

/* --- meta strip ------------------------------------------------------
   Full-bleed pale band, the one place the page inverts. Re-pins its own
   tokens so the labels and values stay navy-on-pale in BOTH schemes —
   it's a fixed brand surface, not a themed one. */
.spx-meta {
  background: var(--pale);
  --text: var(--navy);
  --text-faint: rgba(0, 48, 87, .6);
  color: var(--text);
  padding: clamp(22px, 3vw, 34px) 0;
}
/* In light mode the page IS pale, so a pale band would vanish into it — the
   same dissolve the work-grid cards had when a card matched --bg. The band
   inverts instead, so there is always exactly one navy/pale contrast between
   the strip and the page, whichever scheme is active. */
html[data-scheme="light"] .spx-meta {
  background: var(--navy);
  --text: var(--bone);
  --text-faint: rgba(245, 244, 240, .6);
}

.spx-meta-grid {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 18px clamp(14px, 2.5vw, 28px);
}
.spx-meta-label {
  margin: 0 0 7px;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.spx-meta-value {
  margin: 0;
  font-size: clamp(11px, 1.1vw, 13px);
  line-height: 1.45;
  color: var(--text);
}
/* A second line inside a meta cell, for detail that qualifies the value above
   it rather than deserving a column of its own. */
.spx-meta-sub {
  display: block;
  margin-top: 3px;
  font-size: clamp(10px, 1vw, 11.5px);
  color: var(--text-faint);
}

/* --- credits ---------------------------------------------------------
   Set narrow and centred, like the reflection and impact blocks, so it reads
   as a closing note rather than another section of the case study. */
.spx-thanks {
  max-width: 62ch;
}
.spx-thanks .spx-lead {
  margin-bottom: 1.2em;
}
.spx-thanks .spx-body {
  max-width: 62ch;
}
.spx-thanks .spx-body + .spx-body {
  margin-top: 1.1em;
}

/* --- two-up rows ----------------------------------------------------- */
.spx-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(32px, 6vw, 88px);
  align-items: center;
}
.spx-row--top { align-items: start; }
/* .spx-row--reverse used to set `order: 2` on the media, which pushed the
   artwork to the RIGHT — the opposite of the comp, and it left every desktop
   row with its text on the left. DOM order is now what stands: a row whose
   media comes first shows that media on the left, which restores the
   alternating rhythm. The modifier survives only to mark which rows those are,
   for the width ratio below. */
.spx-row + .spx-row {
  margin-top: clamp(56px, 7.5vw, 104px);
}
/* A narrower text column against a wider visual. */
.spx-row--media-lead {
  grid-template-columns: 1.55fr 1fr;
}
/* Text first, but the artwork gets the larger half — for rows where the visual
   is doing more of the work than the copy. */
.spx-row--media-wide {
  grid-template-columns: 1fr 1.3fr;
}
/* This override existed to compensate for the `order` swap that used to move
   the media to column two. With the swap gone the media is in column ONE, so
   the wide track is already the right one and the override would put the
   artwork back in the narrow column — straight back to 177px sketches. */

/* --- final artifacts cluster -----------------------------------------
   Three groups on one line at desktop: a pair of phones, a text column
   with a third phone under it, and a framed stack of the two web views.
   Collapses to one column on narrow screens in the same reading order. */
/* One panel, sharp corners, holding the whole cluster — the three separate
   rounded trays read as clutter at desktop width. The grid is 12 columns so
   the phone pair, the copy and the web stack can be given exact spans that
   add up to a full rectangle with no leftover gutter.

   Phone widths keep the trays instead (see the media query at the end of this
   section): stacked in one column, a single tall panel would be a huge empty
   box, and the per-group trays are what make the groups readable there. */
/* One row per platform, each a full-width sharp-cornered panel. Nothing shares
   horizontal space with the other platform, which is the entire point: two
   browser mocks squeezed into a side column rendered ~370px wide, where their
   own 13px UI text works out to about 3px. Full width they get ~600px each.

   Captions sit OUTSIDE the panels, underneath, so a panel is purely the
   artwork's ground. */
.spx-artifacts {
  display: flex;
  flex-direction: column;
  gap: clamp(28px, 4vw, 56px);
}
.spx-artifacts-copy {
  max-width: 56ch;
}
.spx-group {
  margin: 0;
}
.spx-panel {
  background: var(--tray);
  border: 1px solid var(--tray-edge);
  padding: clamp(20px, 2.6vw, 38px);
}
.spx-group .spx-caption {
  margin-top: 12px;
}
/* Captions sit under their image, aligned to its left edge — they are labels
   for the artwork above them, not headings for it. Moving them above was tried
   and reverted: the inconsistency it was meant to fix was really about section
   copy order, not caption placement (see the phone rules at the end of this
   section). */

/* Phones: two at natural height plus the cropped export, which is wider by
   exactly the ratio difference between the files (0.65 vs 0.497 = 1.31x) so all
   three stand the same height with nothing cropped. */
.spx-panel--phones {
  display: grid;
  grid-template-columns: 1fr 1fr 1.31fr;
  gap: clamp(14px, 2vw, 28px);
  align-items: end;
  /* No padding under the row. The cropped export has to meet the panel's
     bottom border — floating it above a line is what made the cut look like a
     loading failure rather than a deliberate bleed. */
  padding-bottom: 0;
  overflow: hidden;
}

/* Web: side by side across the full width, with a deliberately tight gap and
   side padding. Every pixel taken by the gutter comes straight off the mock's
   width, and these are the images that need width most — see the legibility
   table in DESIGN-BRIEF.md. */
.spx-panel--web {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(10px, 1.1vw, 16px);
  align-items: start;
  padding-left: clamp(14px, 1.6vw, 22px);
  padding-right: clamp(14px, 1.6vw, 22px);
}
.spx-artifacts-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(10px, 1.6vw, 20px);
}

/* --- the report flow -------------------------------------------------
   Markup order is the PHONE order (copy, web clip, phone clip). Desktop is
   assembled with explicit grid placement so the arrangement can differ from
   the reading order without touching the markup:

     row 1   the web clip, full width — it is a wide horizontal frame and
             sharing a row with a portrait phone is what kept it small
     row 2   the phone clip beside the copy, the same shape as every other
             text-and-media row on the page

   The phone column is `auto` so it takes exactly the plate's capped width and
   hands the rest to the copy. */
.spx-flows {
  display: grid;
  /* A FIXED first track, not `auto`. With `auto` the phone column is sized by
     its content's min-content width, so a wide neighbour — the web clip spans
     both tracks — could squeeze it toward nothing and leave the clip as a
     sliver with its caption wrapping beside it. 380px is the phone plate's cap,
     so the track is exactly the size the clip wants and cannot be bargained
     down. minmax(0, 1fr) lets the copy column shrink instead of overflowing. */
  grid-template-columns: 380px minmax(0, 1fr);
  gap: clamp(20px, 3vw, 44px) clamp(28px, 4vw, 64px);
  align-items: start;
}
/* Grid children default to min-width: auto, which means an item cannot shrink
   below its content's intrinsic width — for a video that is its recorded pixel
   width, and it is what pushes a track wider than the container. */
.spx-flows > * {
  min-width: 0;
}
.spx-flows-web {
  grid-column: 1 / -1;
  grid-row: 1;
}
.spx-flows-phone {
  grid-column: 1;
  grid-row: 2;
}
.spx-flows-copy {
  grid-column: 2;
  grid-row: 2;
}

/* --- sketches -------------------------------------------------------- */
/* The sketches carry handwriting, which needs more size than a screenshot to
   be worth showing at all — this row gives them the wider half of the split
   (see .spx-row--media-lead on the markup) and a tighter internal gap. */
.spx-sketches {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(10px, 1.4vw, 18px);
}

/* --- the team photo -------------------------------------------------
   A raw photo export with the scrim and the rounding done here, and the
   moment's copy laid over it. Text sits in its own layer above the scrim. */
/* Capped rather than full-bleed. The source is 2540x2840 — nearly square and
   very tall — so at full measure it filled more than a viewport on its own and
   dominated the page. The cap keeps it a figure rather than an interruption. */
.spx-photo {
  position: relative;
  overflow: hidden;
  border-radius: 18px;
  isolation: isolate;
  max-width: 880px;
  margin: 0 auto;
}
.spx-photo > img {
  display: block;
  width: 100%;
  height: auto;
}
/* Heavier than it looks like it needs to be. The photo is a desk with two lit
   screens on it, so the copy sits over bright UI rather than an even field —
   at .30 at the top the headline was unreadable. The extra radial pass darkens
   the middle, where the text actually is. */
.spx-photo::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(0, 20, 36, .74) 0%, rgba(0, 20, 36, .88) 100%),
    radial-gradient(46% 34% at 50% 47%, rgba(0, 16, 30, .72), transparent 78%);
}
.spx-photo-copy {
  position: absolute;
  z-index: 2;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(46ch, 82%);
  text-align: center;
  /* The photo is fixed-dark regardless of scheme, so the copy over it is
     pinned to bone in both — same rule as the hero photo on the homepage. */
  color: var(--bone);
}
.spx-photo-copy .spx-num,
.spx-photo-copy .spx-body {
  color: rgba(245, 244, 240, .8);
}
.spx-photo-copy .spx-body {
  margin-left: auto;
  margin-right: auto;
}

/* --- centred blocks (reflection / impact) ---------------------------- */
.spx-centre {
  max-width: 60ch;
  margin: 0 auto;
  text-align: center;
}
.spx-centre .spx-eyebrow {
  justify-content: center;
}
.spx-centre .spx-lead,
.spx-centre .spx-body {
  margin-left: auto;
  margin-right: auto;
}
.spx-figure-wide {
  max-width: 1060px;
  margin: clamp(36px, 6vw, 72px) auto 0;
}
/* The thanks now close the reflection section rather than getting a section of
   their own — saying the same lesson twice, once as a reflection and once as a
   thank-you, was the repetition. */
.spx-centre--after {
  margin-top: clamp(44px, 6vw, 80px);
}
.spx-centre--after .spx-body {
  max-width: 60ch;
  margin-left: auto;
  margin-right: auto;
}
.spx-centre--after .spx-body + .spx-body {
  margin-top: 1.1em;
}
/* The board is exported from Figma with no background, so it needs no plate
   and no rounding — a frame around transparent art just draws a box in the
   navy. A soft shadow is enough to lift it. */
.spx-figure-wide > .spx-media {
  filter: drop-shadow(0 18px 44px rgba(0, 18, 34, .40));
}

/* --- responsive ------------------------------------------------------ */
@media (max-width: 1000px) {
  /* Stacked, the single panel would be one enormous empty box, so the cluster
     goes back to a tray per group — which is also the version that reads best
     on a phone. */
  .spx-meta-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* 900, not 760. At 768 a two-column row leaves each column around 330px,
   which is under a comfortable measure for the body copy and shrinks the
   mockups to thumbnails. Long-form reading is the point of this page, so the
   rows stack while there is still room to read them. */
@media (max-width: 900px) {
  /* Below this width EVERY media pair goes one per row, full bleed inside the
     shell. Two side by side left each image around 125px, where a phone mock's
     own 12px UI text lands near 4px — decorative rather than readable. Full
     width they get ~340px, which puts that text back around 10px.

     The selectors are doubled up on purpose. `.spx-row--reverse.spx-row--media-lead`
     is two classes, so a single-class override inside a media query loses to it
     — media queries add no specificity. That is why the sketches stayed at
     125px after the first attempt at this rule. */
  .spx-sketches,
  .spx-sketches.spx-sketches,
  .spx-artifacts-pair,
  .spx-artifacts-pair.spx-artifacts-pair,
  .spx-panel--phones,
  .spx-panel--web,
  .spx-row--reverse.spx-row--media-lead,
  .spx-row.spx-row--media-lead {
    grid-template-columns: 1fr;
    gap: clamp(16px, 3vw, 26px);
  }
  /* The bleed only reads as one when the device is the last thing in the
     panel; stacked, it is not, so it gets its padding back. */
  .spx-panel--phones {
    padding-bottom: clamp(20px, 2.6vw, 38px);
  }
  /* Below 900 the hero phones wrap to 2x2, so each one can only claim about
     half the height it gets on a wide screen — otherwise two rows of them
     push the title off the bottom of a tall narrow screen. */
  .spx-hero-phones .spx-phone {
    max-height: min(23svh, 300px);
  }
  .spx-row,
  .spx-row--media-lead,
  .spx-row--media-wide {
    grid-template-columns: 1fr;
    gap: clamp(24px, 6vw, 36px);
  }
  /* Text above media in EVERY row once stacked. This used to reset the media
     back to first, which is the opposite of what the comment claimed and is why
     some sections read text-then-image and others image-then-text on a phone.
     Keeping the desktop order (media second) is what makes it consistent. */
  /* Stacked, the text always comes first regardless of which side its media
     sits on at desktop width. Reading a label or a paragraph before the thing
     it describes is the consistent version; alternating it by row was the
     inconsistency. */
  .spx-rowtext { order: -1; }
  /* One column, and every explicit placement released so the children fall
     back to markup order — which is already the phone order: copy, web clip,
     phone clip. */
  .spx-flows {
    grid-template-columns: 1fr;
    gap: clamp(24px, 5vw, 36px);
  }
  .spx-flows-web,
  .spx-flows-phone,
  .spx-flows-copy {
    grid-column: 1;
    grid-row: auto;
  }
  .spx-hero-phones {
    grid-template-columns: repeat(2, 1fr);
    max-width: 460px;
    gap: clamp(14px, 3vw, 22px);
  }
  .spx-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .spx-sketches { grid-template-columns: 1fr; }
  .spx-photo-copy {
    width: 88%;
  }
}
@media (max-width: 420px) {
  .spx-meta-grid { grid-template-columns: 1fr 1fr; }
}

/* --- tray ------------------------------------------------------------
   A slightly lifted navy panel that a group of mocks sits on. Two jobs: it
   binds a pair or a stack into one artifact, and it gives the deliberately
   cropped phone export something to bleed off, so the crop reads as a
   decision rather than a broken image. */
.spx-tray {
  /* Tighter than it looks like it should be. Tray padding eats directly into
     the mock inside it, and the phone pair had dropped to 143px wide — small
     enough that no amount of image quality would make the UI inside legible. */
  padding: clamp(12px, 1.6vw, 22px);
  border-radius: 18px;
  background: var(--tray);
  border: 1px solid var(--tray-edge);
}
.spx {
  /* Lifted off --bg rather than a new colour, the same ~12-per-channel move the
     work-grid cards use to separate from the page. */
  --tray: rgba(245, 244, 240, .055);
  --tray-edge: rgba(245, 244, 240, .09);
}
html[data-scheme="light"] .spx {
  --tray: rgba(0, 48, 87, .055);
  --tray-edge: rgba(0, 48, 87, .10);
}
/* The cropped export ends part-way down the device. Clipping it to the panel's
   own bottom edge is what makes that read as a deliberate bleed rather than a
   half-loaded image. */
.spx-tray--bleed {
  overflow: hidden;
}
.spx-tray .spx-media + .spx-media {
  margin-top: clamp(12px, 1.8vw, 20px);
}

/* Two views of the same site, stacked, sharing one treatment. */
.spx-context-stack {
  display: flex;
  flex-direction: column;
  gap: clamp(14px, 1.8vw, 22px);
}

/* --- a mark laid over a scrimmed image ------------------------------- */
.spx-overlaid {
  margin: 0;
}
/* Heavier and flatter than the generic scrim. The generic one is a gradient
   that stays light at the top, which is fine over artwork but not when a bone
   wordmark has to sit on it — the source here is a near-white marketing page,
   so the mark had almost nothing to read against. */
.spx-overlaid.spx-plate--scrim::after {
  background: linear-gradient(180deg, rgba(0, 28, 50, .66) 0%, rgba(0, 28, 50, .78) 100%);
}
.spx-overlaid-mark {
  position: absolute;
  z-index: 2;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: clamp(96px, 16vw, 168px);
  aspect-ratio: 2142 / 604;
  /* Masked, not an image: the source PNG is solid navy and would vanish against
     the darkened photo. As a mask it takes this colour instead. Pinned to bone
     in both schemes, because the scrim under it is dark in both. */
  background: #f5f4f0;
  -webkit-mask: url("img/spex-logo.png") center / contain no-repeat;
  mask: url("img/spex-logo.png") center / contain no-repeat;
}

/* --- copy tucked into a column's leftover space ---------------------- */
.spx-aside {
  margin-top: 0;
}
.spx-aside .spx-body + .spx-body {
  margin-top: 1.1em;
}

/* --- end nav ---------------------------------------------------------
   Split to the two ends. Centred with no gap, the two labels ran together
   and read as one phrase. */
.spx-endnav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: clamp(20px, 5vw, 56px);
  padding: clamp(26px, 3.5vw, 40px) 0 clamp(56px, 7vw, 88px);
  border-top: 1px solid var(--hairline);
}
/* With only one link there is nothing to space apart, and a lone link
   hanging off the left edge under a full-width rule reads like the row lost
   something. Centred, it reads as the end of the run. */
.spx-endnav--solo {
  justify-content: center;
}
.spx-endnav a {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  color: var(--text-muted);
  font-size: clamp(12px, 1.2vw, 14px);
  font-weight: 500;
  text-decoration: none;
  transition: color var(--dur-quick) ease;
}
.spx-endnav a:hover {
  color: var(--text);
}
.spx-endnav a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: 4px;
}
/* The site's own chevron, rotated a quarter turn, instead of an arrow
   character — one glyph vocabulary across the whole site. */
.spx-chev {
  width: 15px;
  height: auto;
  flex: none;
}
.spx-chev--left { transform: rotate(90deg); }
.spx-chev--right { transform: rotate(-90deg); }

/* --- the hero's scroll cue on this page -----------------------------
   Same component as the homepage. The hero is centred text, so the marker
   sits below the subtitle rather than at a percentage of the viewport. */
.spx-hero .hero-v3-scroll-home {
  position: absolute;
  left: 50%;
  bottom: clamp(34px, 4.5vw, 54px);
  width: 0;
  height: 0;
  pointer-events: none;
}

/* =====================================================================
   Y APP CASE STUDY  (project4.html)
   ---------------------------------------------------------------------
   The second page built on the case-study layout above, and the reason that
   layout is token-driven: this page reuses the whole .spx-* vocabulary —
   shell, section, row, panel, plate, group, caption, eyebrow, glow, centre,
   endnav, hero — and changes only the palette plus the handful of pieces
   that are genuinely different here.

   (The .spx- prefix is a misnomer as of this page: those classes are the
   shared case-study layout, not Spex's. Renaming to a neutral prefix is a
   mechanical job worth doing once a third page needs them; doing it now
   would churn two working pages for no visual change.)

   Palette is Youngstown State's. Red is sampled from
   img/ysu-penguins-logo.png, where it is 45% of the opaque pixels at
   rgb(208,16,48); #c8102e is YSU's published Penguin Red and sits inside
   that sample. Light mode goes gray rather than white because gray is what
   YSU actually uses.
   ===================================================================== */

.ysu {
  --red: #c8102e;         /* Penguin Red — the brand value */
  --red-bright: #ef3b4e;  /* lifted for text weight on near-black, see below */
  --ink: #101014;         /* near-black, a shade cooler than the site's own */
  --gray: #e4e5e7;        /* YSU cool gray — the light-mode surface */
  --bone: #f5f4f0;

  /* The shared layout reads --pale and --navy for the meta band. Aliased
     rather than renamed so no layout rule has to know which page it is on.

     --pale is a DARKER gray than --gray on purpose, and deliberately not an
     alias of it: --gray is the light-mode page surface, --pale is the one
     light band on the dark page. Sharing a value made the band glare against
     the near-black; splitting them lets the band come down without dragging
     the whole light scheme down with it. */
  --pale: #c0c4c9;
  --navy: var(--ink);

  --bg: var(--ink);
  --text: var(--bone);
  --text-muted: rgba(245, 244, 240, .68);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(245, 244, 240, .13);
  --glass-bg: rgba(245, 244, 240, .05);
  --glass-bg-soft: rgba(245, 244, 240, .08);
  --glass-border: rgba(245, 244, 240, .16);
  --glass-border-soft: rgba(245, 244, 240, .22);

  /* Red, not olive: the one thing that tells you at a glance which project
     you are in, and the only hue on the page besides near-black and bone.

     --accent and --accent-dot split because red is an awkward colour on a
     near-black ground. Measured against #101014, Penguin Red is 3.23:1 —
     fine for a decorative dot or rule (WCAG non-text wants 3:1) and under
     the 4.5:1 floor for anything text-sized, and .spx-num is 12px. So text
     weight gets the lifted red at 4.89:1 and decoration keeps the brand
     value. The same split the site already makes with olive. */
  --accent: var(--red-bright);
  --accent-dot: var(--red);
  --accent-wash: rgba(200, 16, 46, .12);
  --accent-wash-border: rgba(200, 16, 46, .28);

  /* Red atmosphere, weaker than the Spex blues on purpose — a saturated red
     glow reads as a warning light long before it reads as depth. */
  --glow-a: rgba(200, 16, 46, .42);
  --glow-b: rgba(200, 16, 46, .20);

  --tray: rgba(245, 244, 240, .055);
  --tray-edge: rgba(245, 244, 240, .09);

  background: var(--bg);
  color: var(--text);
}

html[data-scheme="light"] .ysu {
  --bg: var(--gray);
  --text: var(--ink);
  --text-muted: rgba(16, 16, 20, .72);
  --text-faint: rgba(16, 16, 20, .55);
  --hairline: rgba(16, 16, 20, .14);
  --glass-bg: rgba(16, 16, 20, .05);
  --glass-bg-soft: rgba(16, 16, 20, .07);
  --glass-border: rgba(16, 16, 20, .16);
  --glass-border-soft: rgba(16, 16, 20, .22);
  /* On the gray, Penguin Red measures 4.67:1 and needs no lift — so light
     mode is the scheme that gets to use the brand value for everything. */
  --accent: var(--red);
  --accent-dot: var(--red);
  --glow-a: rgba(200, 16, 46, .16);
  --glow-b: rgba(16, 16, 20, .07);
  --tray: rgba(16, 16, 20, .05);
  --tray-edge: rgba(16, 16, 20, .10);
}

/* The footer is a sibling of the page content, not a descendant, so it has
   to be told. */
body.ysu .ftr {
  background: var(--bg);
}

/* The stuck navbar takes this page's surface. Left alone it stays the site's
   black-on-white panel, which on a gray page reads as a different site's
   navbar sliding in — the same bug Spex had. */
body.ysu .hero-v3-nav.is-stuck {
  background: rgba(16, 16, 20, .62);
}
html[data-scheme="light"] body.ysu .hero-v3-nav.is-stuck {
  background: rgba(228, 229, 231, .70);
}

/* The meta band's faint token is hardcoded navy for Spex. Re-tinted per
   scheme here, written out explicitly rather than leaning on the fact that
   the light-mode rule happens to out-specify a single .ysu scope. */
.ysu .spx-meta {
  /* Lifted from .6 — the band got darker, and at .6 the sub-line fell to
     about 3.9:1 against it, under the 4.5:1 floor for 11px text. */
  --text-faint: rgba(16, 16, 20, .78);
}
html[data-scheme="light"] .ysu .spx-meta {
  --text-faint: rgba(245, 244, 240, .6);
}

/* Mock shadows and scrims are navy-tinted for Spex; neutralised here so they
   read as shadow rather than as a blue cast on a red-and-black page. */
.ysu .spx-phone {
  filter: drop-shadow(0 18px 42px rgba(0, 0, 0, .5));
}
.ysu .spx-plate--bare {
  box-shadow: 0 14px 34px rgba(0, 0, 0, .42);
}
.ysu .spx-plate--scrim::after {
  background: linear-gradient(180deg, rgba(12, 12, 15, .22), rgba(12, 12, 15, .62));
}
.ysu .spx-figure-wide > .spx-media {
  filter: drop-shadow(0 18px 44px rgba(0, 0, 0, .44));
}

/* The chat widget keeps the site's dark panel everywhere, but its EYES take
   --accent, and on this page that is red. The eyes are the launcher's
   identity and the one part of it that belongs to the page it sits on. */
.ysu .bot-panel,
.ysu .bot-launcher {
  --bg: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(255, 255, 255, .08);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border: rgba(255, 255, 255, .12);
  --glass-border-soft: rgba(255, 255, 255, .18);
  --accent: #ef3b4e;
  --accent-dot: #ef3b4e;
}
html[data-scheme="light"] .ysu .bot-panel,
html[data-scheme="light"] .ysu .bot-launcher {
  --bg: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .66);
  --hairline: rgba(13, 13, 13, .16);
  --glass-bg: rgba(13, 13, 13, .075);
  --glass-bg-soft: rgba(13, 13, 13, .055);
  --glass-border: rgba(13, 13, 13, .16);
  --glass-border-soft: rgba(13, 13, 13, .16);
  /* The launcher body stays light here, so the eyes need the deeper red for
     the same reason the page's text accent does in dark mode. */
  --accent: #c8102e;
}

/* --- hero ------------------------------------------------------------
   Rides on .spx-hero for the parts that were hard to get right — one
   viewport tall via svh, contents centred, the scroll-cue marker's resting
   spot — and changes only what should differ.

   No mock in the hero. Spex opened on four phones because it was a system
   across four placements; this redesign is one screen, and the copy deck
   puts its hero shot in the Context section. That leaves a purely
   typographic opening, which is also the brief: full width, minimal, hook.
   So the title carries the screen, and gets to be much larger than Spex's. */
/* .spx-hero centres its contents and pads top and bottom. This hero does
   neither: the copy sits left and vertically centred, and the mockup runs to
   the very bottom edge so it meets the meta band. */
.ysu-hero {
  display: block;
  padding: 0;
  text-align: left;
  /* The mockup's size lives here, as tokens, because two things need it: the
     mockup itself and the glow that has to sit centred behind it. Derived
     once, they cannot drift apart when the size is retuned.
     0.786 is the export's aspect ratio, 1800/2290. */
  --mock-h: min(78svh, 780px);
  --mock-w: calc(var(--mock-h) * 0.786);
}
.ysu-hero > .spx-shell {
  position: relative;
  z-index: 1;         /* above the section's own background */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
/* The glow sits behind the phone rather than behind the page, so it moves with
   it. Inside the shell for the same reason the mockup is: on a wide monitor the
   shell stops at 1420px, so a glow positioned against the section would drift
   away from a mockup positioned against the shell.

   z-index -1 rather than 0. The shell carries z-index 1 and so opens a stacking
   context; inside it, -1 puts the glow behind the copy and the mockup but still
   in front of the section's background, which is exactly the layer wanted. At
   0 it painted over the headline — positioned elements beat static text. */
.ysu-hero .spx-glow--1 {
  z-index: -1;
  top: auto;
  left: auto;
  /* Anchored to the mockup's CENTRE, not to an edge. The offsets place the
     glow's right/bottom edges at the mockup's centre point, then the 50/50
     translate pushes the glow back out by half its own size — so its centre
     lands exactly on the mockup's, whatever either one is sized to.

     Eyeballed offsets were the first attempt and put it mostly off-screen at
     the bottom right, which read as no glow at all. */
  right: calc(clamp(20px, 6vw, 72px) + var(--mock-w) / 2);
  bottom: calc(var(--mock-h) / 2);
  transform: translate(50%, 50%);
  /* Deliberately much larger than the mockup it sits behind. Sized to the
     phone it was almost entirely hidden by it — an opaque hand and device
     cover a glow of their own size, leaving only a rim. Oversized, it reads as
     light coming from behind the phone, which is the point. */
  width: min(1120px, 80vw);
  aspect-ratio: 1.1;
}
/* Capped in px, not ch. At the headline's size a 34ch measure is about
   1000px, which would run straight through the mockup. */
.ysu-hero-copy {
  max-width: min(420px, 42%);
}
/* Deliberately the landing page's own hero sizes rather than something
   bigger — this hero has a mockup carrying it, so the type does not need to,
   and matching the homepage is what makes the two feel like one site. */
.ysu-hero-title {
  margin: 0 0 clamp(10px, 1.6vw, 18px);
  font-size: clamp(22px, 4.2vw, 58px);
  line-height: 1.15;
  letter-spacing: -.01em;
}
.ysu-hero-sub {
  max-width: 34ch;
  margin: 0;
  font-size: clamp(11px, 2.2vw, 15px);
  line-height: 1.6;
  color: var(--text-muted);
}

/* The mockup. Anchored to the bottom edge so the wrist — which runs off the
   bottom of the source file — meets the meta band instead of floating above
   it, and to the RIGHT edge of the shell's content box, which mirrors the
   copy's own left padding. Centred it crowded the copy on a wide monitor;
   right-aligned the two occupy opposite halves and the gap between them grows
   with the viewport instead of shrinking.

   It lives inside .spx-shell now, which is what makes that mirroring exact:
   the shell is capped at 1420px and centred, so the right offset here is
   measured from the same edge the copy's padding is, at every viewport.

   Still sized by HEIGHT — the hero's constraint is vertical — but a step down
   from 84svh/860px, which was tall enough to reach across into the copy on a
   short wide window. */
.ysu-hero-mock {
  position: absolute;
  right: clamp(20px, 6vw, 72px);
  bottom: 0;
  z-index: 0;
  height: var(--mock-h);
  width: auto;
  max-width: none;
  pointer-events: none;
  user-select: none;
}

/* The eyebrow keeps the shared dot and nothing else. A red rule trailing
   every section marker was tried and removed: repeated down the page it read
   as decoration rather than as an accent, and the dot plus the glows already
   carry the colour. */

/* --- four screens across ---------------------------------------------
   Deliberately the same size as the Spex hero's four phones — same component,
   same constraints, so the two case studies open and pay off at a matching
   scale. Full-bleed on a tray these filled the window and became the loudest
   thing on the page, which is the wrong emphasis for a set of stills; the
   detail belongs to moment 02, where two of these same screens are shown large
   side by side.

   Capped by BOTH the track width and a share of the viewport height, exactly
   as .spx-hero-phones is: on a short window the height cap binds first, which
   is why justify-items centres them — the image is then narrower than its
   cell. */
.ysu-quad {
  margin: clamp(28px, 4vw, 56px) 0 0;
}
/* Three tracks that DIVIDE the measure, rather than flex items that add up to
   more than it. Sized by the height cap each phone came out 336px wide, and
   3 x 336 plus two gaps is 1072 against a 1036 container — 36px over, so the
   third wrapped onto a second row for want of a hair's width.

   A grid cannot overflow like that: the track is whatever a third of the
   measure is, and the phone fills it. Width drives the size, which is the
   point of giving them the full measure.

   justify-items: center matters only on a short window, where the height cap
   binds first and the phone is then narrower than its track. */
.ysu-four {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(16px, 2.2vw, 34px);
  align-items: end;
  justify-items: center;
}
.ysu-four .spx-phone {
  width: 100%;
  height: auto;
  /* CAPPED BY WIDTH, NOT HEIGHT. A max-height beside width: 100% is exactly
     what made these look squished: when the cap binds, the height is clamped
     and the width is not, so the phone gets shorter without getting
     narrower. The same budget expressed as a width cannot violate the ratio,
     because height: auto follows it. 906/1824 is the export's own ratio.

     It bound hardest on phones: a 390px screen gives each of two columns
     ~164px and a natural height of ~330px against a 312px cap, and worse on
     a shorter handset where 40svh falls to ~267px and the squash hits 19%. */
  max-width: calc(min(90svh, 880px) * 906 / 1824);
}
@media (max-width: 900px) {
  /* Two-up, the same way the Spex hero row wraps. Three full-width phone mocks
     stacked would be most of a page of scrolling for what is meant to be a
     glance, so the third simply starts a second row. */
  .ysu-four {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .ysu-four .spx-phone {
    /* width, not height -- see the base rule */
    max-width: calc(min(40svh, 420px) * 906 / 1824);
  }
}

/* --- before / after labels -------------------------------------------
   A before/after only works if you can tell which is which without hunting
   for a caption, and the label is the cheapest way to say it. The pair grid
   that used to live here is gone: both states now sit in .ysu-tri alongside
   their copy, so each label belongs to a single mock. */
.ysu-ba-label {
  display: block;
  /* Matches .spx-caption's own top margin, because that is what this now is:
     a label under its media, not a heading over it. */
  margin: 14px 0 0;
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.ysu-ba-label--after {
  color: var(--accent);
}


/* --- copy and two states on one line ---------------------------------
   Text, then before, then after, left to right. Stacked under the copy the
   reader had to scroll away from the paragraph to see what it described, and
   away from the before to see the after — which is the one thing a before/after
   must not ask.

   The text column is the wide one; the two mocks take equal halves of what is
   left, landing near 280px each in the standard shell. That is smaller than
   they were, and still a 3.2x downscale from the 906px source. */
.ysu-tri {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(20px, 3vw, 44px);
  align-items: center;
}


/* --- persona card ----------------------------------------------------
   Replaces a pull quote that attributed one line to "everyone I asked, more
   or less word for word" — which is not a real quote and read as one.

   A persona is the honest version of the same beat: it says who the app is
   for without putting words in anyone's mouth. This is a TEMPLATE — every
   [bracketed] field is a placeholder to fill in, and the fields are styled
   as placeholders (see [data-fill]) so an unfilled one is obvious rather
   than shipping as if it were content. */
.ysu-persona {
  border: 1px solid var(--glass-border);
  border-radius: 16px;
  background: var(--glass-bg);
  padding: clamp(20px, 2.4vw, 30px);
}
.ysu-persona-eyebrow {
  display: flex;
  align-items: center;
  gap: 9px;
  margin: 0 0 clamp(16px, 2vw, 22px);
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
}
.ysu-persona-eyebrow::before {
  content: "";
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent-dot);
}
.ysu-persona-head {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: clamp(16px, 2vw, 20px);
}
/* A ring, not a photograph. A persona built with one friend should not
   pretend to be a stock-photo research participant. */
.ysu-persona-avatar {
  flex: none;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  /* Was a dashed placeholder ring. It holds a real photo now: a solid
     hairline, and object-fit so a non-square source could never stretch
     the face. */
  border: 1px solid var(--glass-border-soft);
  object-fit: cover;
}
.ysu-persona-name {
  margin: 0;
  font-size: clamp(15px, 1.6vw, 18px);
  font-weight: 400;
  color: var(--text);
}
.ysu-persona-meta {
  margin: 4px 0 0;
  font-size: clamp(11px, 1.15vw, 12.5px);
  color: var(--text-faint);
}
.ysu-persona-quote {
  margin: 0 0 clamp(18px, 2.2vw, 24px);
  padding-left: clamp(14px, 1.6vw, 18px);
  border-left: 2px solid var(--accent-dot);
  font-weight: 300;
  font-size: clamp(15px, 1.7vw, 19px);
  line-height: 1.45;
  color: var(--text);
}
.ysu-persona-rows {
  margin: 0;
  display: grid;
  gap: 12px;
}
.ysu-persona-rows > div {
  display: grid;
  grid-template-columns: 11ch minmax(0, 1fr);
  gap: 14px;
  align-items: baseline;
}
.ysu-persona-rows dt {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--text-faint);
  line-height: 1.6;
}
.ysu-persona-rows dd {
  margin: 0;
  font-size: clamp(12.5px, 1.25vw, 14px);
  line-height: 1.6;
  color: var(--text-muted);
}
/* Unfilled fields look unfilled. Without this a placeholder reads as a real
   answer at a glance, which is how [Name] ends up live on a portfolio. */
[data-fill] {
  color: var(--text-faint);
  text-decoration: underline dashed var(--glass-border-soft);
  text-underline-offset: 4px;
}

/* --- tall mocks that sit beside copy ---------------------------------
   A 906x1824 phone mock given a whole half-row renders about 480px wide and
   960px TALL, which is taller than the window it is being read in: the mock
   gets cropped by the viewport no matter where you stop scrolling. Capped to
   340px it stands about 685px and fits on screen whole, and at 340 from a
   906px source it is still a 2.7x downscale — sharper than it was, not softer.

   Centred in its column rather than left-aligned, because the cap leaves room
   and a capped image hugging one edge reads as a mistake. */
.ysu-figure--cap {
  max-width: 340px;
  margin-left: auto;
  margin-right: auto;
}
/* Centred, not left-aligned. The caption is shorter than the 340px figure it
   sits in, so left-aligned it bunched under the left third of the mock and
   read as misplaced rather than as a label for the whole thing. Captions
   elsewhere sit under media that fills its column, where left-aligned is
   right — this is the case that differs. */
.ysu-figure--cap .spx-caption {
  text-align: center;
}

/* --- the real prototype clip -----------------------------------------
   436px is the recording's own width and the hard ceiling — upscaling a screen
   capture is the one thing guaranteed to look soft. It sits below that on
   purpose, for the same reason as the mocks above: at 436 the clip stood 876px
   tall and could not be watched without it running off the screen. */
/* --- the prototype clip ----------------------------------------------
   No plate, no border, no radius, no shadow. The recording's own canvas is
   the page colour (#e5e5e5 against the light page's #e4e5e7, #100f12 against
   the dark page's #101014), so the device already floats — anything drawn
   around it is a frame the clip did not ask for, and that is what made it look
   boxed and inconsistent with every other mock on the site.

   A glow sits behind it instead. That is the whole answer to the contrast
   question: where the canvas and the page match exactly there is nothing to
   separate, and where they drift by a shade the glow reads as the reason. */
.ysu-proto {
  position: relative;
  max-width: 360px;
  margin-left: auto;
  margin-right: auto;
}
.ysu-proto-video {
  position: relative;
  z-index: 1;
  display: block;
  width: 100%;
  height: auto;
}
.ysu-proto-glow {
  /* Centred on the clip and larger than it, for the same reason the hero glow
     is larger than the mockup: an opaque device covers a glow its own size and
     leaves only a rim. */
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 150%;
  aspect-ratio: 1;
  z-index: 0;
}
.ysu-proto .spx-caption {
  position: relative;
  z-index: 1;
  text-align: center;
}

/* The cue is centred again, which it can be now the mockup has moved right —
   it only went bottom-left because a centred mockup put its hand exactly
   there. It inherits left: 50% from .spx-hero, and since the marker now lives
   inside .spx-shell, that 50% is the shell's centre; the shell is centred in
   the viewport, so the two agree.

   At the widest sizes the glyph's right edge comes within a few px of the
   mockup's left edge, which is fine: the mockup is transparent along its whole
   lower-left, where the cue sits. */

/* --- responsive ------------------------------------------------------
   Same 900px break and the same reasoning as the section above: two columns
   below this leave each image too narrow for its own UI text to survive, so
   every pair goes one per row.

   Selectors are doubled where the base rule is two classes deep — media
   queries add no specificity, which is what made the Spex sketches stay at
   125px the first time round. */
@media (max-width: 900px) {
  /* Text, then before, then after, straight down. */
  .ysu-tri,
  .ysu-tri.ysu-tri {
    grid-template-columns: 1fr;
    gap: clamp(16px, 3vw, 26px);
  }
  /* A full viewport, and the contents DISTRIBUTED rather than packed.

     The hero must be at least one viewport tall or the meta band shows on
     first paint, which defeats the scroll cue — the whole job of that chevron
     is to say there is more below. Sizing the hero to its contents (tried, and
     reverted here) broke that on narrow phones: at a 320px viewport the
     content came to 719px against 780, so 61px of the band sat on screen
     before you touched anything.

     But a 100svh hero with the mockup pinned to the bottom is what produced
     the ~240px dead band in the middle, because the leftover height has to go
     somewhere. space-between is the resolution: the copy stays at the top, the
     mockup stays at the bottom, and the leftover becomes the gap between them
     — which the mockup's own margin-top sets a floor under. When the content
     is taller than the viewport (390px and up) there is no leftover at all and
     the gap is just that floor. */
  .ysu-hero > .spx-shell {
    justify-content: space-between;
    padding-top: clamp(124px, 26vw, 172px);
  }
  .ysu-hero-copy {
    max-width: none;
  }
  /* Centred again at this width, since there is no copy beside it. */
  .ysu-hero .spx-glow--1 {
    right: auto;
    left: 50%;
    bottom: -6%;
    transform: translateX(-50%);
    width: min(680px, 108vw);
  }
  /* The landing page steps its headline UP on a phone rather than letting the
     clamp bottom out (see .hero-v3-headline's own 700px rule). Matching that
     is the point of borrowing its sizes: at 320px the shared clamp floors at
     22px, which reads as body copy rather than a hero. */
  .ysu-hero-title {
    font-size: clamp(28px, 8vw, 40px);
  }
  /* In flow here, not absolute: it is simply the last block in the shell, a
     fixed margin below the copy. That is what makes the gap a number rather
     than a leftover.

     Still bound by WIDTH. Sized by height the aspect ratio decides the width,
     and at 320px that came out 331px — 6px wider than the viewport, hanging
     off both sides of a centred element. */
  .ysu-hero-mock {
    position: static;
    /* The mockup sits BEFORE the copy in the DOM, which is irrelevant while it
       is absolutely positioned but decides the order the moment it joins the
       flow — it rendered above the headline. The shell is a flex column, so
       order puts it back last without moving the markup, which has to stay put
       for the desktop layout to position it against the shell. */
    order: 2;
    transform: none;
    display: block;
    height: auto;
    /* The mockup BLEEDS off the right edge rather than shrinking to fit.

       This took three attempts and the arithmetic is why. At 88vw the mockup
       was exactly as wide as the shell's content box, so margin-left: auto had
       no space to absorb and it stayed dead centre whatever the margins said —
       measured, the cue sat at x-fraction 0.500 of the mockup at every phone
       width, right inside the hand.

       Shrinking it to move right does not work either. From the export's alpha
       channel, at the height the cue actually sits the hand is opaque from
       x-fraction ~0.36 rightward, and keeping a right-aligned mockup's opaque
       edge clear of a centred cue needs W <= contentRight - vw/2 - 25, which
       at 390px is 147px. A 147px phone mockup is not a hero.

       Pushing it off the right edge keeps it full size AND clears the cue: the
       negative margin moves the whole thing right so the cue lands at
       x-fraction 0.25 of the box, well left of the opaque hand, and the part
       that leaves the screen is the outer wrist, which reads as a deliberate
       bleed. .spx-hero already has overflow: hidden, so it clips cleanly. */
    width: min(88vw, 380px);
    max-width: none;
    /* Top margin closes the gap to the copy; the bottom one opens a lane for
       the scroll cue. The cue is centred at every width — same place as every
       other page on the site — and at this width the mockup is centred too, so
       without a lane the glyph lands on the wrist. Giving up the flush meet
       with the meta band on phones only is the cheaper trade: the cue is a
       control, and it has to be findable in the place the rest of the site
       trains you to look. */
    /* margin-top is the FLOOR under the gap, not the gap itself —
       space-between hands it whatever height is left over, and this is what
       stops the mockup butting into the copy on the screens where nothing is
       left over. Nothing below it, so its bottom edge is the hero's bottom
       edge and it meets the meta band.

       RIGHT-aligned, not centred, so the centred scroll cue has clear page
       under it. Measured on the export's alpha channel: at the height the cue
       sits, the mockup is only opaque from x-fraction 0.667 rightward, so
       pushing it right puts the hand well clear of the middle. Centred, the
       cue landed on the wrist. */
    margin: clamp(26px, 6vw, 44px) calc(-1 * clamp(60px, 22vw, 140px)) 0 auto;
  }
  .ysu-persona-rows > div {
    grid-template-columns: 1fr;
    gap: 2px;
  }
}

/* The cue overlaps the mockup's hand on a phone, which is accepted — but bone
   ink on light wood is about 1.4:1, and a control has to stay readable wherever
   it lands. A shadow under the glyph gives it an edge on the wood; over the
   near-black page and beside the blob it is simply invisible, so one rule
   covers every position the glyph travels through.

   The filter goes on the SVG, never on the button. The button is an ancestor of
   .hero-v3-scroll-blur, and an ancestor with a filter becomes a backdrop root —
   the glass would silently stop sampling the page behind it. The svg is a
   SIBLING of the glass, so filtering it is safe, exactly as dimming it is. */
/* The cue is centred at every width on this page, which is what every other
   page does, and it carries NO shadow of its own.

   A drop-shadow lived here while the cue still landed on the mockup's hand —
   bone ink on light wood is about 1.4:1 — but it read as a glow rather than as
   depth, and the glyph should look like the navbar icons: flat ink, nothing
   behind it. The overlap it compensated for is gone, because the mockup is
   right-aligned at phone widths now and the centred cue has clear page under
   it. See .ysu-hero-mock's margin in the phone block above.

   A left-aligned phone variant also lived here, and moving its marker inside
   .ysu-hero-copy to achieve that is what broke the DESKTOP hero: that block
   carries .reveal, whose transform makes it the containing block for absolute
   descendants, so left: 50% became the middle of a 420px copy box instead of
   the middle of the page. The marker is a direct child of the shell again. */

/* --- short phones ----------------------------------------------------
   A 553px-tall viewport (iPhone SE in Safari, with its toolbars up) cannot
   hold a headline, a subtitle, a phone mockup AND the scroll cue at the sizes
   the taller phones use. Measured, the hero came to 719px there, so the cue —
   pinned near the hero's bottom, like every other page — sat 66px BELOW the
   fold. A scroll affordance you have to scroll to find is no affordance.

   So on short viewports only, the mockup takes a height cap and the copy loses
   some headroom, which brings the whole hero back inside one screen. Scoped by
   height as well as width so it cannot touch the phones where everything
   already fits: iPhone 14 and up keep the full 88vw mockup.

   The cap is written as a WIDTH through the aspect ratio (1800/2290 = 0.786),
   so one property still owns the size and the ratio cannot be broken by
   setting height and max-width together. */
@media (max-width: 900px) and (max-height: 640px) {
  .ysu-hero > .spx-shell {
    padding-top: clamp(88px, 16svh, 124px);
  }
  .ysu-hero-mock {
    width: min(88vw, 380px, calc(49svh * 0.786));
    margin-top: clamp(16px, 3vw, 26px);
  }
}

/* --- the meta band is five across, not six ---------------------------
   No team and no live URL on a self-initiated concept, and padding a grid
   out to six cells so it looks full is how a meta strip starts inventing
   facts. Five real ones instead.

   Scoped rather than given its own single-class rule: a bare
   `.ysu-meta-grid` would sit later in this file than the shared
   `.spx-meta-grid` media queries and, at equal specificity, beat them at
   every width — the strip would never collapse on a phone. Scoping raises
   the specificity, which means the collapses have to be restated here, and
   the base rule has to come FIRST or its own media queries lose to it. */
.ysu .spx-meta-grid {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}
@media (max-width: 1000px) {
  .ysu .spx-meta-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 420px) {
  .ysu .spx-meta-grid { grid-template-columns: 1fr 1fr; }
}

/* The glows are clipped by .spx-section's overflow: hidden, which is there so
   glows placed past the section's SIDES cannot widen the document. The
   reflection glow is neither — it is centred and capped at min(980px, 95vw),
   so it cannot reach either edge — but it is 700px tall in a shorter section,
   and the clip cut it off dead straight across the bottom.

   Unclipping just this section lets it fall away naturally into Impact, so the
   two read as one lit area rather than a panel with a seam. Impact's own
   .spx-shell carries z-index 1 against the glow's 0, so the copy stays on top
   of it; neither section opens a stacking context (position: relative with
   z-index: auto), so those two values compare directly.

   Only safe because this glow is horizontally contained. Do not put it on a
   section whose glow uses a negative left/right. */
.ysu-section--unclipped {
  overflow: visible;
}

/* .ysu-tri and .spx-row sit next to each other in the flow, so they share the
   row rhythm. */
.spx-row + .ysu-tri,
.ysu-tri + .spx-row {
  margin-top: clamp(56px, 7.5vw, 104px);
}

/* =====================================================================
   HAUNTED HISTORY WALKING TOUR  (project2.html)
   ---------------------------------------------------------------------
   The third page on the shared case-study layout, and the first graphic
   design one. It reuses the whole .spx-* vocabulary and changes the
   palette, plus two things the print work genuinely needs: sharp corners
   everywhere, and figures capped to their sources' real widths.

   Palette is the booklet's own for the SURFACES — warm near-black ink in
   dark, the cover photograph's marble ground in light, which is why the
   booklet shots sit on the light page with no visible edge.

   The ACCENT is deliberately not the booklet's print scarlet but the same
   Penguin Red the Y App page uses. Both are YSU work; one red across the two
   reads as one system rather than two projects that happen to both be red.
   ===================================================================== */

.hnt {
  /* The same red as the Y App page, not the booklet's own #c41c1c. Both are
     YSU work, and one red across the two reads as one designer's system
     rather than two projects that happen to both be red. Penguin Red is also
     the institution's actual value, which the booklet's print scarlet only
     approximated. */
  --scarlet: #c8102e;      /* Penguin Red — shared with the Y App page */
  --scarlet-lift: #ef3b4e; /* lifted for text weight on near-black */
  --ink: #141010;          /* warm near-black — the cover's ink, deepened */
  --marble: #eae6e1;       /* the cover's marble ground, as a light surface */
  --bone: #f5f4f0;

  /* The shared layout reads --pale and --navy for the meta band. */
  --pale: #cfc8c1;
  --navy: var(--ink);

  --bg: var(--ink);
  --text: var(--bone);
  --text-muted: rgba(245, 244, 240, .68);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(245, 244, 240, .13);
  --glass-bg: rgba(245, 244, 240, .05);
  --glass-bg-soft: rgba(245, 244, 240, .08);
  --glass-border: rgba(245, 244, 240, .15);
  --glass-border-soft: rgba(245, 244, 240, .2);

  /* Same split, same reason as the Y App page: measured against #141010 the
     booklet scarlet is 3.18:1 — fine for a dot or a rule (WCAG non-text wants
     3:1), under the 4.5:1 floor for anything text-sized. Text weight gets the
     lifted red at 4.88:1; decoration keeps the brand value. In light mode the
     brand value is 4.79:1 on the marble and needs no lift. */
  --accent: var(--scarlet-lift);
  --accent-dot: var(--scarlet);
  --accent-wash: rgba(200, 16, 46, .12);
  --accent-wash-border: rgba(200, 16, 46, .28);

  --glow-a: rgba(200, 16, 46, .40);
  --glow-b: rgba(200, 16, 46, .18);

  --tray: rgba(245, 244, 240, .05);
  --tray-edge: rgba(245, 244, 240, .09);

  background: var(--bg);
  color: var(--text);
}

html[data-scheme="light"] .hnt {
  --bg: var(--marble);
  --text: var(--ink);
  --text-muted: rgba(20, 16, 16, .72);
  --text-faint: rgba(20, 16, 16, .55);
  --hairline: rgba(20, 16, 16, .14);
  --glass-bg: rgba(20, 16, 16, .05);
  --glass-bg-soft: rgba(20, 16, 16, .07);
  --glass-border: rgba(20, 16, 16, .16);
  --glass-border-soft: rgba(20, 16, 16, .22);
  --accent: var(--scarlet);
  --accent-dot: var(--scarlet);
  --glow-a: rgba(200, 16, 46, .15);
  --glow-b: rgba(20, 16, 16, .07);
  --tray: rgba(20, 16, 16, .05);
  --tray-edge: rgba(20, 16, 16, .10);
}

body.hnt .ftr {
  background: var(--bg);
}
body.hnt .hero-v3-nav.is-stuck {
  background: rgba(20, 16, 16, .62);
}
html[data-scheme="light"] body.hnt .hero-v3-nav.is-stuck {
  background: rgba(234, 230, 225, .70);
}

/* The meta band's faint token is hardcoded navy by the Spex rules. */
.hnt .spx-meta {
  --text-faint: rgba(20, 16, 16, .72);
}
html[data-scheme="light"] .hnt .spx-meta {
  --text-faint: rgba(245, 244, 240, .6);
}

/* Mock shadows are navy-tinted for Spex; neutralised warm here. */
.hnt .spx-phone {
  filter: drop-shadow(0 18px 42px rgba(0, 0, 0, .5));
}
.hnt .spx-plate--bare {
  box-shadow: 0 14px 34px rgba(0, 0, 0, .42);
}
.hnt .spx-plate--scrim::after {
  background: linear-gradient(180deg, rgba(20, 16, 16, .22), rgba(20, 16, 16, .62));
}

/* The chat widget stays site chrome, with the page's accent in its eyes. */
.hnt .bot-panel,
.hnt .bot-launcher {
  --bg: #0d0d0d;
  --text: #f5f4f0;
  --text-muted: rgba(245, 244, 240, .62);
  --text-faint: rgba(245, 244, 240, .5);
  --hairline: rgba(255, 255, 255, .08);
  --glass-bg: rgba(255, 255, 255, .04);
  --glass-bg-soft: rgba(255, 255, 255, .08);
  --glass-border: rgba(255, 255, 255, .12);
  --glass-border-soft: rgba(255, 255, 255, .18);
  --accent: #ef3b4e;
  --accent-dot: #ef3b4e;
}
html[data-scheme="light"] .hnt .bot-panel,
html[data-scheme="light"] .hnt .bot-launcher {
  --bg: #f5f4f0;
  --text: #0d0d0d;
  --text-muted: rgba(13, 13, 13, .62);
  --text-faint: rgba(13, 13, 13, .66);
  --hairline: rgba(13, 13, 13, .16);
  --glass-bg: rgba(13, 13, 13, .075);
  --glass-bg-soft: rgba(13, 13, 13, .055);
  --glass-border: rgba(13, 13, 13, .16);
  --glass-border-soft: rgba(13, 13, 13, .16);
  --accent: var(--scarlet);
}

/* --- everything is sharp ---------------------------------------------
   The other two pages are screen work, where a rounded plate reads as a
   device. This is print: posters, a booklet, a folded map. A 16px radius on
   a poster mockup makes it look like an app icon, which is the one thing it
   is not. So every frame this page draws is square, and the only rounding
   left on it belongs to the fixed chrome, which is the site's, not the
   page's. */
.hnt .spx-plate,
.hnt .spx-panel,
.hnt .spx-media,
.hnt .spx-phone,
.hnt .spx-photo,
.hnt .hnt-figure img,
.hnt .blank-slot {
  border-radius: 0;
}

/* --- figures capped to their sources --------------------------------
   Several sources for this project were exported at 340-525px, well under
   what the layout would hand them. Each figure is capped to the real native
   width of the image inside it and centred in whatever column it lands in,
   because a mock stretched past its pixels is the one failure no encoder
   setting can undo.

   The number is per-figure, so it is set inline via --cap rather than
   invented here — the tool that exports them prints the list. */
.hnt-figure {
  max-width: var(--cap, 100%);
  margin-left: auto;
  margin-right: auto;
}
/* Captions centre under a capped figure, for the same reason they do on the
   Y App page: the label is far shorter than the figure, and left-aligned it
   bunches under one corner instead of reading as a label for the whole. */
.hnt-figure > .spx-caption {
  text-align: center;
}

/* --- the artefact row ------------------------------------------------
   Three print pieces across the measure, each centred in its track and
   capped at its own native width. align-items: end so they sit on a shared
   baseline despite different heights — they are photographed objects, and
   objects sit on a surface. */
.hnt-three {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(16px, 2.4vw, 36px);
  align-items: end;
  justify-items: center;
}
.hnt-three img {
  display: block;
  width: 100%;
  height: auto;
}

/* --- a blank slot for art that does not exist yet -------------------
   Deliberately plain and deliberately labelled with the width to export at,
   which is 2x the width it actually renders at — the only number that
   decides whether the art lands sharp. Nothing about it should read as
   finished, so it stays a dashed box with a spec in it. */
.blank-slot {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  aspect-ratio: var(--slot-ratio, 16 / 9);
  padding: clamp(14px, 2vw, 22px);
  text-align: center;
  border: 1px dashed var(--glass-border);
  background:
    linear-gradient(var(--glass-bg), var(--glass-bg)),
    repeating-linear-gradient(135deg, transparent 0 9px, var(--glass-bg-soft) 9px 10px);
}
.blank-slot-name {
  max-width: 34ch;
  font-size: clamp(11px, 1.1vw, 12.5px);
  font-weight: 500;
  letter-spacing: .01em;
  line-height: 1.55;
  text-transform: none;
  color: var(--text-muted);
}
.blank-slot-spec {
  font-size: clamp(9.5px, 1vw, 11px);
  letter-spacing: .05em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* --- hero ------------------------------------------------------------
   Centred copy over a full-bleed piece of the work, which is the right
   opening for print: the artefact IS the deliverable, so it gets the whole
   width rather than sharing a row. Rides on .spx-hero for the svh sizing
   and the scroll cue's resting spot. */
/* Keeps .spx-hero's 100svh — it must, or the meta band shows on first paint
   and the scroll cue has nothing to promise. Measured before this was fixed,
   the hero came to 528px against a 780px viewport at phone widths and a
   quarter of the band sat on screen.

   space-between distributes what is left: the copy stays at the top under the
   navbar, the bleed image stays at the bottom against the band, and any
   leftover height becomes the gap between them. Where the content is taller
   than the viewport — every desktop width — there is no leftover and the two
   simply stack. Same resolution as the Y App hero. */
.hnt-hero {
  justify-content: space-between;
  padding-top: clamp(128px, 16vw, 200px);
  padding-bottom: 0;
  text-align: center;
}
.hnt-hero-title {
  max-width: 24ch;
  margin: 0 auto clamp(16px, 2.4vw, 26px);
}
.hnt-hero-sub {
  max-width: 50ch;
  margin: 0 auto;
  font-size: clamp(13px, 1.35vw, 16px);
  line-height: 1.6;
  color: var(--text-muted);
}
/* The full-bleed opener sits flush against the meta band, the same way the
   Y App hero's mockup does — the band becomes the artefact's baseline
   instead of a gap. */
.hnt-hero-bleed {
  margin-top: clamp(48px, 7vw, 92px);
  padding: 0 clamp(14px, 2.2vw, 40px);
}
/* Two mockups across the opener, one per column, stacking on a phone.
   align-items: center rather than stretch or end: the two exports have
   different ratios (1.30 and 1.53) because each is cropped to its own
   artwork, so any attempt to match their heights would distort one of them.
   Centred, the difference reads as two objects laid out, which is what they
   are. */
.hnt-hero-pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(18px, 3vw, 48px);
  align-items: center;
}
/* height: auto IS LOAD-BEARING. The markup carries a height attribute, which
   the UA turns into a presentational hint for the CSS height property, so a
   rule that sets width but never mentions height leaves that hint in force
   and the image renders at its full pixel height. */
.hnt-hero-pair img {
  display: block;
  width: 100%;
  height: auto;
}
@media (max-width: 760px) {
  .hnt-hero-pair {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(22px, 6vw, 36px);
  }
}
/* The copy sits above the glow. */
.hnt-hero > .spx-shell {
  position: relative;
  z-index: 1;
}
/* The cue flows under the copy rather than being pinned to the hero's bottom,
   which on this page is the bleed image. The marker is 0x0, so auto side
   margins centre it exactly — no `left: 50%` and no measuring of the copy's
   height, which is what made a `top` offset fragile here. */
.hnt-hero .hero-v3-scroll-home {
  position: relative;
  display: block;
  left: auto;
  bottom: auto;
  margin: clamp(30px, 4.5vw, 52px) auto 0;
}

/* --- the meta band is four across ------------------------------------
   Client, role, tools, deliverables — what a print brief actually carries.
   Scoped rather than given its own single-class rule, and the collapses
   restated, for the same specificity reason as the Y App page: a bare class
   would sit later in the file and beat the shared media queries at every
   width. Base rule first, or its own media queries lose to it. */
.hnt .spx-meta-grid {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1000px) {
  .hnt .spx-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 420px) {
  .hnt .spx-meta-grid { grid-template-columns: 1fr; }
}

@media (max-width: 900px) {
  .hnt-three,
  .hnt-three.hnt-three {
    grid-template-columns: 1fr;
    gap: clamp(20px, 4vw, 32px);
  }
  /* Capped figures stop being capped once they have the column to
     themselves — the cap existed to stop them stretching in a wide track,
     and at phone widths the track is already narrower than the source. */
  .hnt-figure {
    max-width: min(var(--cap, 100%), 100%);
  }
  .hnt-hero {
    padding-top: clamp(112px, 26vw, 160px);
  }
}

/* --- case-study hero entrance ----------------------------------------
   A short staggered fade-and-rise on the hero copy, replacing .reveal there.
   .reveal is scroll-driven and waits for an IntersectionObserver, which is
   wrong for content that is already on screen at load — the hero should
   arrive, not wait to be scrolled to.

   Applied to the CHILDREN, not the block, so the eyebrow, headline and
   subtitle arrive in reading order. The delays are short on purpose: this is
   a page-load flourish, and anything past half a second starts to feel like
   the page is slow rather than considered.

   The marker for the scroll cue is deliberately NOT inside these blocks on
   any page — a moving marker during load leaves the chevron off its spot. */
@keyframes study-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
.study-in > :nth-child(1) { animation-delay: .08s; }
.study-in > :nth-child(2) { animation-delay: .20s; }
.study-in > :nth-child(3) { animation-delay: .32s; }
.study-in > :nth-child(4) { animation-delay: .44s; }
@media (prefers-reduced-motion: reduce) {
  .study-in > * {
    opacity: 1;
    animation: none;
    transform: none;
  }
}

/* =====================================================================
   VIEW-TRANSITION THEME TOGGLE
   ---------------------------------------------------------------------
   The scheme flip is a circular wipe out of the toggle button rather than
   an instant swap. The mechanics are the View Transitions API: the browser
   snapshots the old and new states as ::view-transition-old(root) and
   ::view-transition-new(root), and the script animates a clip-path circle
   on the NEW snapshot, growing from the button's centre to the far corner.
   See site-ui.js.

   The default cross-fade has to be switched off or it fights the wipe —
   both snapshots would fade through each other underneath the circle.
   mix-blend-mode: normal for the same reason: the default `plus-lighter`
   makes the overlap glow.

   No fallback needed in CSS. Where startViewTransition is missing, or the
   visitor asked for reduced motion, the script just sets the attribute and
   these rules never match anything.
   ===================================================================== */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}
/* The new state sits on top so the circle reveals it over the old one. */
::view-transition-old(root) { z-index: 0; }
::view-transition-new(root) { z-index: 1; }

/* =====================================================================
   HERO TEXT ENTRANCE — per word, blurred in and up
   ---------------------------------------------------------------------
   A staggered blur-in-up, the shape of motion the reference component
   calls blurInUp with by="word". Blur is what separates it from a plain
   fade: the words resolve rather than just appear.

   The words are wrapped by site-ui.js, which also sets --i per word. Doing
   the stagger with a custom property rather than a list of :nth-child
   delays means it works for a headline of any length — the landing page's
   own headline uses hardcoded nth-child delays and is capped at seven.
   ===================================================================== */
@keyframes blur-in-up {
  from {
    opacity: 0;
    filter: blur(10px);
    /* 12px, not 15px. Over a longer duration a shorter travel is what reads
       as smooth; the blur is doing the character, the distance was only
       adding speed. */
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: none;
  }
}

.study-in {
  --tw-dur: 1s;          /* was .6s via --dur-slow */
  --tw-step: .08s;       /* per-word stagger, was .055s */
  /* ease-out-cubic. NOT --ease-out (.16, 1, .3, 1), which is an expo curve:
     it moved each word almost the whole way in the first fifth of its
     duration, so lengthening the duration alone would not have helped. */
  --tw-ease: cubic-bezier(.33, 1, .68, 1);
}
.study-in > * {
  opacity: 0;
  animation: blur-in-up var(--tw-dur) var(--tw-ease) forwards;
}
.study-in > :nth-child(1) { animation-delay: .12s; }
.study-in > :nth-child(2) { animation-delay: .28s; }
.study-in > :nth-child(3) { animation-delay: .44s; }
.study-in > :nth-child(4) { animation-delay: .60s; }

/* Once a heading has been split into words the words carry the animation,
   so the heading itself must stop animating or the two compound — the
   blur would be applied twice and the whole line would slide as well. */
.study-in > .tw-split {
  opacity: 1;
  filter: none;
  transform: none;
  animation: none;
}
.tw-word {
  display: inline-block;
  /* The trailing space lives inside the span, so `pre` is what stops it
     being collapsed away and the words running together. */
  white-space: pre;
  opacity: 0;
  animation: blur-in-up var(--tw-dur) var(--tw-ease) forwards;
  animation-delay: calc(.12s + var(--i, 0) * var(--tw-step));
}
/* Everything after a split heading waits for the words to finish rather
   than arriving over them. --tw-count is set by the script. */
.study-in > .tw-split ~ * {
  animation-delay: calc(.12s + var(--tw-count, 0) * var(--tw-step));
}

@media (prefers-reduced-motion: reduce) {
  .study-in > *,
  .tw-word {
    opacity: 1;
    filter: none;
    transform: none;
    animation: none;
  }
}

/* =====================================================================
   THE LAUNCHER'S NUDGE
   ---------------------------------------------------------------------
   A small label above the blob. It shows itself once, a couple of seconds
   after load, then gets out of the way; after that it only appears on
   hover or focus.

   The whole point is to be noticed and then forgotten, so: no entrance
   from far away, no bounce, and it leaves on its own. A tooltip that has
   to be dismissed is a tooltip that annoys.
   ===================================================================== */
.bot-nudge {
  position: fixed;
  right: calc(var(--fab-edge) + var(--chat-shift, 0px));
  bottom: calc(var(--fab-edge) + var(--blob-size) + 10px);
  z-index: 62;
  padding: 7px 11px;
  border-radius: 999px;
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: .01em;
  white-space: nowrap;
  color: var(--text);
  background: var(--glass-bg-soft);
  border: 1px solid var(--glass-border);
  /* Same glass as the launcher it belongs to. Its own backdrop-filter is
     safe: nothing in its ancestry filters, and it has no glass children. */
  -webkit-backdrop-filter: blur(14px) saturate(1.6);
  backdrop-filter: blur(14px) saturate(1.6);
  box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
  opacity: 0;
  transform: translateY(6px) scale(.96);
  transform-origin: bottom right;
  pointer-events: none;
  transition: opacity .26s var(--ease-out), transform .26s var(--ease-out);
}
.bot-nudge.is-shown {
  opacity: 1;
  transform: none;
}
/* Hover and keyboard focus both bring it back, which is what makes it a
   tooltip rather than a one-off toast. */
.bot-launcher:hover ~ .bot-nudge,
.bot-launcher:focus-visible ~ .bot-nudge {
  opacity: 1;
  transform: none;
}
/* Nothing to nudge toward once the panel is open. */
body.chat-open .bot-nudge {
  opacity: 0 !important;
  transform: translateY(6px) scale(.96) !important;
}
@media (prefers-reduced-motion: reduce) {
  .bot-nudge { transition: opacity .2s linear; transform: none; }
  .bot-nudge.is-shown { transform: none; }
}

/* =====================================================================
   THE CHAT PANEL — a full-height sheet, not a popover
   ---------------------------------------------------------------------
   It runs the full height of the viewport down the right-hand side, and
   the page stays live beside it: still visible, still scrollable, still
   clickable. That is the whole reason for the shape. A popover floating
   over the corner reads as a modal and blocks the thing the visitor came
   for; a sheet reads as a second column.

   Two consequences worth being explicit about:

     - The page has to move over, not be covered. body.chat-open takes
       padding-right, so the flow content reflows narrower — the site is
       responsive, so it simply lays out for less width.
     - Fixed chrome cannot be moved with padding, so the navbar, the FAB,
       the launcher and its shadow all read --chat-shift in their `right`.
       It is a custom property rather than a transform BECAUSE those
       elements already animate transform (the navbar hides with
       translateY, the launcher scales on hover, the scroll cue's is
       script-driven every frame) and a second transform would overwrite
       them.
     - The click-outside-to-close handler is gone from bot-widget.js. With
       a sheet, clicking the page is something the visitor is invited to
       do, not a signal that they want the chat shut.
   ===================================================================== */
:root {
  /* The hover disc behind a header icon. Deliberately about half
     --glass-bg-soft: these are 28px targets in a very quiet bar, and the
     wash only has to confirm the target, not announce it. */
  --icon-wash: rgba(255, 255, 255, .045);
  --chat-w: min(400px, 33vw);
  /* The note is the one thing in the panel that has to float ABOVE the
     glass, so it cannot borrow --bg: the panel is 78% --bg over the page,
     which in light mode is the same colour. These are literal because the
     case-study scopes re-pin --bg and the note must not follow them. */
  --note-bg: #1b1b1e;
  --note-ink: rgba(245, 244, 240, .84);
  --note-shadow: 0 14px 34px rgba(0, 0, 0, .38);
}
html[data-scheme="light"] {
  --icon-wash: rgba(13, 13, 13, .05);
  --note-bg: #ffffff;
  --note-ink: rgba(13, 13, 13, .74);
  --note-shadow: 0 12px 28px rgba(13, 13, 13, .14);
}

body.chat-open {
  padding-right: var(--chat-w);
  --chat-shift: var(--chat-w);
}
/* The navbar spans the page, so it takes the shift on its right edge. */
body.chat-open .hero-v3-nav {
  right: var(--chat-w);
}
/* These three are corner-anchored and must keep tracking each other. */
body.chat-open .bot-launcher,
body.chat-open .blob-shadow,
body.chat-open .quicknav {
  right: calc(var(--fab-edge) + var(--chat-w));
}
/* Moving the page is a layout change, so it gets the same easing as the
   panel sliding in — otherwise the two read as separate events.

   Split in two because the page and the fixed chrome move by different
   properties: only <body> changes padding, and only the fixed elements are
   positioned. One combined list told body to transition `right` (never
   fires, body is not positioned) and told the fixed elements to transition
   `padding-right` (never fires, none of them have padding to change).

   The body half animates a LAYOUT property on purpose, and the design hook
   is right to flag it. It is the mechanism, not an oversight: the page has
   to lay out for less width — text re-wrapping to a narrower measure is the
   requested effect — and no transform can do that, because a transform
   moves pixels without changing the measure. Measured over the DevTools
   Protocol with a real clock, five runs on the 13.6-screen Spex page: a
   forced reflow at this width costs 0.1-0.2ms (about 1% of a 60fps frame),
   and the transition holds a 16.7ms median in every run. The cost is a
   SINGLE dropped frame out of ~36 — seen on 7 of 10 opens, 2 of 10 closes,
   and never at idle. Opening is the worse direction because narrowing forces
   fresh line-breaking where closing re-wraps to a width already laid out.
   project4 (8.4 screens) drops none, so it scales with page length.

   One frame at the start of a 420ms ease is not visible, which is why this
   stands. Measure before changing it, and note the real-clock harness needs
   several runs: a single run's frame counts are noise at this magnitude.

   If it ever needs to be cheaper, the fix is `content-visibility: auto` on
   off-screen sections so the re-wrap skips content nobody is looking at —
   not a transform, which would change what the animation means. */
body {
  transition: padding-right .42s var(--ease-out);
}
.hero-v3-nav,
.bot-launcher,
.blob-shadow,
.quicknav {
  transition: right .42s var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
  body, .hero-v3-nav, .bot-launcher, .blob-shadow, .quicknav {
    transition: none;
  }
}

.bot-panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: auto;
  z-index: 61;
  width: var(--chat-w);
  max-height: none;
  display: flex;
  flex-direction: column;
  /* Glass, to match the launcher it comes out of. Tinted rather than
     transparent so long messages stay readable over whatever scrolls
     behind them. */
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  -webkit-backdrop-filter: blur(30px) saturate(1.5);
  backdrop-filter: blur(30px) saturate(1.5);
  border: none;
  border-left: 1px solid var(--glass-border);
  border-radius: 0;
  box-shadow: -24px 0 60px rgba(0, 0, 0, .22);
  transform: translateX(100%);
  transform-origin: center right;
  opacity: 1;
  pointer-events: none;
  transition: transform .42s var(--ease-out);
}
.bot-panel.is-open {
  transform: none;
  pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
  .bot-panel { transition: none; }
}
/* No color-mix support: a flat tinted background instead of glass. The
   panel must never end up unreadable, and color-mix is the only modern
   thing in here. */
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .bot-panel { background: var(--bg); }
}

/* --- header ---------------------------------------------------------- */
.bot-panel-header {
  /* The note is absolutely positioned and is a SIBLING of the info button,
     not a child of it, so this is what has to be its containing block.
     position: relative sat on .bot-info first, which meant top: 100%
     resolved against .bot-panel instead and put the note off the bottom of
     the sheet entirely. */
  position: relative;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 8px;
  /* Same token as the top bar — see --bar-h. box-sizing is border-box
     globally, so the hairline below is inside the 60px. */
  min-height: var(--bar-h);
  padding: 0 18px;
  border-bottom: 1px solid var(--hairline);
  flex-shrink: 0;
}
.bot-panel-title {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -.01em;
  text-transform: none;
  color: var(--text);
}
/* Pushes the close button to the far end while the info button stays
   beside the title it qualifies. */
.bot-panel-header .bot-panel-close {
  margin-left: auto;
}

/* --- the disclosure ---------------------------------------------------
   An AI that can be wrong, and that logs what is said to it, has to say
   so. It sits behind a button rather than always-on because it is a
   footnote, not a warning — but it is one click from the title, which is
   where someone would look for it. */
/* Deliberately identical to .bot-panel-close: same 28px round hit area,
   same 13px glyph, same quiet-until-hovered treatment. They are siblings in
   one header and any difference between them reads as an accident. The open
   state is the one thing that differs, because it has to be legible that the
   note is showing. */
.bot-info {
  display: flex;
  flex: none;
  width: 28px;
  height: 28px;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition: color .2s ease, background .2s ease;
}
/* 15px to match .nav-tool, which is what sits immediately to its left once
   the sheet is open. The stroke is set here rather than on the markup so the
   weight can be tuned without touching four pages. */
.bot-info svg {
  display: block;
  width: 15px;
  height: 15px;
}
.bot-info svg [stroke] {
  stroke-width: 1.4;
}
.bot-info:hover {
  color: var(--text);
  background: var(--icon-wash);
}
.bot-info[aria-expanded="true"] {
  color: var(--accent);
  background: var(--accent-wash);
}
.bot-info:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.bot-note {
  position: absolute;
  top: calc(100% - 4px);
  left: 18px;
  z-index: 3;
  width: min(250px, calc(var(--chat-w) - 44px));
  padding: 11px 13px;
  border-radius: 12px;
  background: var(--note-bg);
  border: 1px solid var(--glass-border);
  box-shadow: var(--note-shadow);
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  line-height: 1.55;
  color: var(--note-ink);
  text-align: left;
  opacity: 0;
  transform: translateY(-4px);
  pointer-events: none;
  transition: opacity .2s ease, transform .2s var(--ease-out);
}
.bot-info[aria-expanded="true"] + .bot-note {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* --- messages -------------------------------------------------------- */
.bot-messages {
  padding: 18px;
  gap: 12px;
  /* Stops the scroll chaining out to the page when this list hits either
     end. Without it, a flick inside the transcript kept going and took the
     document with it, which on a phone means the page moving behind a sheet
     that covers the whole screen. */
  overscroll-behavior: contain;
}

/* --- recommendations, not options ------------------------------------
   These used to be outlined pills, which made them read as a closed set
   of buttons — pick one of six. As quiet lines with a leading glyph they
   read as suggestions instead, and the panel looks like it is offering
   rather than asking.

   Low opacity at rest is doing most of that work; the accent on hover is
   what tells you they are live.
   ===================================================================== */
/* A line of orientation before the suggestions. Without it the panel opens
   on six questions and reads as an interrogation rather than an offer. */
.bot-intro {
  margin: 0 8px 18px;
  font-size: 15px;
  line-height: 1.5;
  letter-spacing: -.005em;
  /* No colour here — the two spans set their own. */
}
/* The actual offer, at full ink. */
.bot-intro-lead {
  color: var(--text);
}
/* The hint after it. Held back so the eye lands on the invitation first and
   treats the suggestions below as the answer to this half of the sentence. */
.bot-intro-sub {
  color: var(--text-faint);
}
.bot-recs {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 4px;
}
.bot-rec {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  width: 100%;
  padding: 7px 8px;
  border: none;
  border-radius: 8px;
  background: none;
  color: var(--text-muted);
  font-family: var(--font);
  font-size: 12.5px;
  line-height: 1.5;
  text-align: left;
  cursor: pointer;
  opacity: .55;
  transition: opacity .2s ease, color .2s ease, background .2s ease;
}
.bot-rec-icon {
  flex: none;
  margin-top: 1px;
  font-size: 10px;
  line-height: 1.5;
  color: var(--text-faint);
  transition: color .2s ease, transform .2s var(--ease-out);
}
.bot-rec:hover,
.bot-rec:focus-visible {
  opacity: 1;
  color: var(--text);
  background: var(--glass-bg-soft);
  outline: none;
}
.bot-rec:hover .bot-rec-icon,
.bot-rec:focus-visible .bot-rec-icon {
  color: var(--accent);
  transform: translateX(2px);
}
.bot-rec:focus-visible {
  box-shadow: inset 0 0 0 1px var(--accent-wash-border);
}

/* --- input ------------------------------------------------------------ */
.bot-input-row {
  border-top: 1px solid var(--hairline);
}
/* SIXTEEN PIXELS IS A THRESHOLD, NOT A TASTE CHOICE. iOS Safari zooms the
   page in whenever a focused form field has a font-size below 16px, and it
   does not zoom back out on blur -- so tapping this input left the sheet
   magnified with the send button off-screen, and pinching back out was the
   only way to finish sending. The field was 13.5px.

   Scoped to coarse pointers rather than a width, because an iPad in
   landscape is wider than most laptops and zooms just the same. Desktop
   keeps the smaller size.

   The alternative fix, maximum-scale=1 on the viewport meta, also works and
   is not used: it disables pinch-zoom for everyone, including people who
   need it to read. */
@media (pointer: coarse) {
  .bot-input {
    font-size: 16px;
  }
}

/* --- phone: a sheet has no room to sit beside anything ---------------
   Below this width there is nothing to scooch over — the panel covers the
   page instead, and the page keeps its full width underneath so nothing
   reflows while it is open. */
@media (max-width: 700px) {
  :root { --chat-w: 100%; }
  body.chat-open {
    padding-right: 0;
    --chat-shift: 0px;
    /* The sheet covers the whole screen here, so the page behind it must
       not scroll. Without this, a touch that starts anywhere in the panel
       other than the transcript -- the header, the empty space under the
       suggestions -- scrolled the document instead, which reads as the
       sheet refusing to move. overscroll-behavior on the transcript only
       covers the scroller itself; this covers the rest. */
    overflow: hidden;
  }
  body.chat-open .hero-v3-nav { right: 0; }
  body.chat-open .bot-launcher,
  body.chat-open .blob-shadow,
  body.chat-open .quicknav {
    right: var(--fab-edge);
  }
  .bot-panel {
    border-left: none;
    box-shadow: none;
    /* Above the navbar, which sits at 70. On desktop the two never meet
       because the page moves over; here the sheet is the whole screen. */
    z-index: 71;
  }
  /* The navbar was painting straight through the sheet's header — its own
     wordmark on top of the chat title, its theme toggle on top of the close
     button. There is nothing to navigate to while the sheet covers the
     page, so it stands down and comes back on close. */
  body.chat-open .hero-v3-nav {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
}
