/* Home page */

.hero {
  position: relative;
  padding-block: var(--space-2xl) calc(var(--space-lg) * 1.75);
}

/* Confirmed on a real mobile layout (390px viewport): .hero__glow below
   is a fixed 420x420px box at this same breakpoint, positioned at
   left:0 — wider than virtually every actual phone viewport, so its
   right edge sat past the true edge of the page and contributed real
   horizontal document overflow, even while invisible at rest (opacity:0).
   A position:absolute box counts toward its ancestor's (and transitively
   the document's) scrollable overflow regardless of its own visibility,
   unlike position:fixed. Scoped to ≤900px specifically — desktop's glow
   isn't a fixed box, it follows the cursor anywhere within .hero
   (initHeroGlow in js/main.js), so at any given moment it can be centered
   right at an edge with plenty of real, still-visible gradient extending
   past this section — clipping that with overflow:hidden (tried
   site-wide initially) cut it off in a hard, visible rectangle exactly at
   .hero's own boundary instead of letting it fade out naturally past the
   section like it's meant to. Mobile/tablet's glow only ever drifts with
   the device's tilt, gently, so it doesn't have the same wide range of
   near-edge positions to expose this. */
@media (max-width: 900px) {
  .hero {
    overflow: hidden;
  }
}

/* Ambient color trail behind the hero sentence — desktop follows the
   cursor, mobile/tablet follows the device's tilt (see initHeroGlow in
   js/main.js). Negative z-index keeps it behind the text (an
   absolutely-positioned element would otherwise paint above static
   in-flow content by default, regardless of DOM order) without needing
   the heading to declare a z-index of its own. This outer element only
   handles position/size (driven by transform, every rAF frame) and the
   overall show/hide fade — the color itself lives one level down, on the
   two stacked .hero__glow-layer children, so it can cross-fade between
   palettes independently (see below) without fighting this element's
   own opacity transition. */
.hero__glow {
  position: absolute;
  top: 0;
  left: 0;
  width: 640px;
  height: 640px;
  opacity: 0;
  pointer-events: none;
  z-index: -1;
  transition: opacity 500ms var(--ease);
  will-change: transform;
}

@media (max-width: 900px) {
  .hero__glow {
    width: 420px;
    height: 420px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero__glow {
    display: none;
  }
}

/* Two identical, exactly-overlapping circles rather than one — animating
   a gradient's own color stops isn't reliably supported, so instead
   initHeroGlow() paints the *next* palette onto whichever layer is
   currently hidden, then swaps which one carries .is-active. That's a
   plain opacity crossfade, which every browser already animates
   smoothly, so the color change itself inherits that same reliability
   rather than depending on a gradient interpolating cleanly. --glow-a/
   --glow-b (set inline, per layer, from js/main.js's palette list) are
   "R G B" triples consumed via rgb(var(...) / alpha%) so each layer only
   needs its two colors swapped, not its whole gradient string rebuilt. */
.hero__glow-layer {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(circle, rgb(var(--glow-a, 255 0 128) / 55%), rgb(var(--glow-b, 0 209 255) / 42%) 45%, transparent 75%);
  opacity: 0;
  transition: opacity 3000ms ease-in-out;
}

.hero__glow-layer.is-active {
  opacity: 1;
}

/* Desktop only: the 2-stop gradient above has a visible edge where it
   hits transparent — a short, linear alpha ramp reads as a disc with a
   boundary rather than an open bloom. This spreads the same falloff over
   several extra stops (an easing curve approximated by hand, since CSS
   gradients only interpolate linearly between adjacent stops) so the
   alpha tapers gradually instead of dropping off over one short stretch,
   and a blur physically diffuses whatever edge is still left — between
   the two, there's no longer a perceptible ring at any zoom level. The
   core itself is pushed well past the base opacity (up to 85%, vs. 55%
   at the very center) so the extra room spent on a longer, softer tail
   doesn't come at the cost of the effect reading as fainter overall —
   it's still clearly there, just with nowhere the eye can find an edge.
   The box itself is sized up to give that longer tail room to fully
   resolve to transparent before reaching its own edge. */
@media (min-width: 901px) {
  .hero__glow {
    width: 820px;
    height: 820px;
  }

  .hero__glow-layer {
    background: radial-gradient(
      circle,
      rgb(var(--glow-a, 255 0 128) / 85%) 0%,
      rgb(var(--glow-a, 255 0 128) / 65%) 10%,
      rgb(var(--glow-a, 255 0 128) / 41%) 24%,
      rgb(var(--glow-b, 0 209 255) / 24%) 42%,
      rgb(var(--glow-b, 0 209 255) / 11%) 60%,
      rgb(var(--glow-b, 0 209 255) / 3%) 76%,
      transparent 92%
    );
    filter: blur(56px);
  }
}

/* Mobile only — cancels .page's own fluid --gutter padding (which varies
   continuously across the mobile width range) and replaces it with the
   same fixed 28px the logo lands at (12px .site-header margin + 16px
   .nav padding — see css/layout.css), so the two stay aligned at any
   mobile width instead of just coincidentally matching at one. */
@media (max-width: 640px) {
  .hero {
    margin-inline: calc(-1 * var(--gutter));
    padding-inline: 28px;
  }
}

.hero__heading {
  font-size: var(--fs-hero);
  font-weight: 300;
  /* -0.02em (a classic large-display tightening, compensating for the
     extra optical space big letterforms tend to show) suits desktop,
     where this now wraps to just 3-4 lines at a bold 76px. At the
     smaller mobile/tablet size it's paired with a light (300) weight and
     several lines of continuous reading, not a glanced-at headline —
     tight tracking on thin strokes at that combination starts to cost
     legibility rather than add polish, so it eases up below 900px. */
  letter-spacing: -0.02em;
  /* 1.2 read fine as a big display headline, but for a multi-line
     paragraph — which is what this is at every width now, even
     desktop's 3-4 lines — a little more breathing room between lines
     meaningfully helps reading comfort without loosening the block
     enough to feel like body text. */
  line-height: 1.25;
  text-wrap: pretty;
}

@media (max-width: 900px) {
  .hero__heading {
    letter-spacing: -0.01em;
  }
}

/* Gallery/list view toggle — two ways to browse the same six projects.
   Which one is visible is driven by a class on <html> (view-gallery or
   view-list), set synchronously in <head> from the viewport width (so
   there's no flash of the wrong view on load) and kept in sync afterwards
   by initViewToggle in js/main.js as the user clicks between them. Not
   persisted — every fresh load lands back on the device default,
   gallery on mobile/tablet and list on desktop. Defaulting the panels to
   .project-list's own display value with no class needed means a no-JS
   visitor — who never gets a view-* class at all — still sees a fully
   working list view rather than two blank, class-gated panels. */
.work-section {
  /* keeps the #work anchor jump from landing flush under the sticky, translucent navbar */
  scroll-margin-top: calc(var(--nav-height) * 1.25);
}

.work-section__header {
  display: flex;
  justify-content: flex-end;
  padding-bottom: var(--space-sm);
}

/* Hidden until js-anim confirms JS is running: with no JS, the class on
   <html> that the buttons depend on never gets set, so a visible toggle
   with no effect would be worse than no toggle at all — the list view it
   defaults to below is already the intended fallback experience. */
.view-toggle {
  display: none;
  gap: var(--space-2xs);
}

.js-anim .view-toggle {
  display: flex;
}

/* 18x18 on mobile/tablet, 24x24 on desktop — same 900px line the rest of
   the toggle already keys off. */
.view-toggle__btn {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  color: var(--color-ink);
  opacity: 0.4;
  transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
}

@media (min-width: 901px) {
  .view-toggle__btn {
    width: 24px;
    height: 24px;
  }
}

/* Icon order mirrors which view is the default on that size of screen —
   the default sits on the left, reading as "this one's primary, the
   other is the alternate" — rather than a fixed left-to-right order that
   would put the non-default option first on mobile/tablet. Matches the
   same 900px line the rest of the site treats as the mobile/tablet vs.
   desktop split (see css/pages/project.css), and the same threshold the
   <head> script in index.html uses to pick the default view itself. */
.view-toggle__btn[data-view-btn="gallery"] {
  order: 1;
}

.view-toggle__btn[data-view-btn="list"] {
  order: 2;
}

@media (min-width: 901px) {
  .view-toggle__btn[data-view-btn="list"] {
    order: 1;
  }

  .view-toggle__btn[data-view-btn="gallery"] {
    order: 2;
  }
}

/* Outline and fill icons are stacked directly on top of each other
   (rather than swapped via JS) and cross-fade between the two — outline
   showing by default, fill fading in for whichever view is active. */
.view-toggle__icon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transition: opacity var(--duration-base) var(--ease);
}

.view-toggle__icon--fill {
  opacity: 0;
}

/* Keyed off the view-* class rather than aria-pressed: that class is set
   synchronously in <head> before first paint (including its
   viewport-width-based default — see index.html), while aria-pressed on
   the buttons themselves only gets corrected once js/main.js actually
   runs. Styling from the class means the right icon is highlighted from
   the very first frame either way, instead of briefly showing the
   HTML's hardcoded default until JS catches up. */
.view-gallery .view-toggle__btn[data-view-btn="gallery"],
.view-list .view-toggle__btn[data-view-btn="list"] {
  opacity: 1;
}

.view-gallery .view-toggle__btn[data-view-btn="gallery"] .view-toggle__icon--outline,
.view-list .view-toggle__btn[data-view-btn="list"] .view-toggle__icon--outline {
  opacity: 0;
}

.view-gallery .view-toggle__btn[data-view-btn="gallery"] .view-toggle__icon--fill,
.view-list .view-toggle__btn[data-view-btn="list"] .view-toggle__icon--fill {
  opacity: 1;
}

.view-toggle__btn:hover,
.view-toggle__btn:focus-visible {
  opacity: 1;
}

.project-gallery {
  display: none;
}

.project-list {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--color-line);
  padding-bottom: var(--space-2xl);
}

.view-gallery .project-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--space-lg) var(--gutter);
  padding-bottom: var(--space-2xl);
}

.view-gallery .project-list {
  display: none;
}

.project-list__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-md);
  padding-block: var(--space-md);
  border-bottom: 1px solid var(--color-line);
  color: var(--color-ink);
  transition: opacity 300ms var(--ease);
}

.project-list__index {
  flex: none;
  font-size: var(--fs-small);
  font-variant-numeric: tabular-nums;
  color: #000000;
  font-weight: 400;
}

.project-list__title {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-h1);
  font-weight: 300;
  transition: transform 350ms var(--ease);
}

/* Grid rather than flex: both columns get a fixed width (sized to the
   longest date/type string, in ch so it scales with the fluid font-size)
   so the block's total width — and therefore its left edge, where the
   date column starts — is identical on every row. With flex, each row's
   meta block was only as wide as that row's own text, so shorter dates
   like "May 2022" landed at a different x position than longer ones like
   "December 2023". The gap between the two columns is unchanged. */
.project-list__meta {
  display: grid;
  grid-template-columns: 11.5ch 18ch;
  flex: none;
  column-gap: var(--space-md);
  font-size: var(--fs-small);
  color: #000000;
  font-weight: 400;
}

.project-list__date,
.project-list__type {
  white-space: nowrap;
}

.project-list__date {
  text-align: left;
}

/* Grid items default to justify-self: stretch, so the type span's own box
   already fills its column — but text-align was left at its default
   (start), so the visible characters sat flush against the *left* edge
   of that box instead of the row's right edge, which is what actually
   reads as "aligned right" for it. */
.project-list__type {
  text-align: right;
}

/* Full "January 2023" style date on desktop; below 900px it switches to
   "JAN 2023" (see the media query further down) so the date column can
   shrink, freeing more of the row for the title — the thing actually
   fighting for space on a phone. Both strings live in the DOM at once
   (rather than swapped by JS) so there's no dependency on JS running,
   and whichever one is display:none is correctly left out of the
   accessibility tree — screen readers only ever announce one. */
.project-list__date-short {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .project-list__item:hover .project-list__title {
    transform: translateX(0.4em);
  }

  /* Dimming the rest of the list while one title is hovered pulls focus
     onto that row's floating preview, the same "spotlight" pattern
     award-winning agency index pages use. Purely decorative — if a
     browser doesn't support :has() yet, every row just stays at full
     opacity, nothing else changes. */
  .project-list:has(.project-list__item:hover) .project-list__item:not(:hover) {
    opacity: 0.35;
  }
}

/* Below desktop, the title's fs-h1 size is simply too wide to share a row
   with the date/type columns at all — "Standard Bank QPay" alone needs
   over 300px at that size, wider than the whole row on a phone. Rather
   than wrap to a second row (which is what this replaces), the title
   scales down with the viewport and every gap tightens, keeping the
   three-column rhythm intact at any width instead of breaking it. */
@media (max-width: 900px) {
  .project-list__item {
    gap: var(--space-xs);
    padding-block: var(--space-sm);
  }

  .project-list__title {
    font-size: clamp(0.9rem, 0.45rem + 3vw, 1.75rem);
    /* Shrinking the font gets most titles onto one line, but "Standard
       Bank QPay" and "5 insurance app" still don't fit at the very
       narrowest phone widths even at the clamp's floor — rather than let
       the title wrap to a second line (breaking the row's height) or
       overflow past the row's edge, it truncates with an ellipsis, same
       as the date/type columns below. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The date column only has to fit "JAN 2023"-style short dates here
     (see .project-list__date-full/-short above), always 8 characters —
     noticeably narrower than spelling out "December 2023" at 13, which
     is what the wider desktop column has to accommodate. That freed
     width goes straight to the title via its own flex:1, which is the
     column actually under pressure at this size. */
  .project-list__date-full {
    display: none;
  }

  .project-list__date-short {
    display: inline;
  }

  .project-list__meta {
    /* min-content seemed like the obvious fix (every short date is the
       same 8 characters), but each row's .project-list__meta is its own
       independent grid — sizing to min-content let each row's column
       width track that row's own text, and since this is a proportional
       font "JAN" and "MAY" aren't actually equal-width despite equal
       character count, so the six rows ended up with six slightly
       different column widths and their dates no longer lined up at the
       same x. Measured the actual widest string ("MAR 2022") at
       66.17px against this context's real ch size (9.02px, not the
       0.5em a "ch ≈ half the font-size" rule of thumb would suggest) to
       land on a fixed value that's shared identically by every row and
       just wide enough for the widest date, with a small buffer. */
    /* The type column was a generic 18ch guess — wider than any actual
       type string needs ("Product, UX | UI" only measures ~11ch here —
       longer strings like "Product | Mobile, UX | UI" only appear on
       desktop's wider column). Since type is right-aligned within its
       column, that unused width sat as invisible space between the gap
       and the text itself, so the *optical* gap (what's actually visible
       between the two pieces of text) was column-gap plus however much
       of the column the text didn't fill — not the 32px column-gap value
       alone. Sizing the column tightly to the widest real string here
       makes the optical gap and the declared column-gap the same thing. */
    grid-template-columns: 7.4ch 11.2ch;
    column-gap: 24px;
    font-size: clamp(0.7rem, 0.6rem + 0.5vw, var(--fs-small));
  }

  /* No overflow/ellipsis fallback here (unlike the title above) — both
     columns are now sized precisely to their actual content (see the
     grid-template-columns comment above), so there's no legitimate
     overflow case left to guard against. Keeping the fallback caused a
     real bug: text-overflow: ellipsis assumes content overflows on the
     trailing edge of left-aligned text, and produced a visibly wrong
     truncated "…" on this right-aligned text even when scrollWidth
     measured zero actual overflow. */
}

/* Floating preview thumbnail — position (translate) is driven entirely by
   initProjectListPreview in js/main.js each animation frame, easing
   toward the cursor rather than snapping to it, which is what actually
   reads as "premium" for this pattern on reference sites (agency/studio
   work indexes with a cursor-follow preview) rather than a jarring
   instant jump. 1.5x the original 220px — rows are ~144px apart, so at
   this size it does now overlap the row above/below the one being
   hovered, which is an accepted tradeoff for the larger preview. Fixed
   (not absolute) since it needs to track the cursor across the whole
   viewport regardless of scroll position; pointer-events: none so it
   can never itself become the click/hover target and steal the row
   hover it depends on. */
.project-list__preview {
  position: fixed;
  top: 0;
  left: 0;
  width: min(24vw, 330px);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transition: opacity 250ms var(--ease);
  z-index: 50;
  will-change: transform;
}

.project-list__preview.is-visible {
  opacity: 1;
}

.project-list__preview-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(0.94);
  transition: transform 300ms var(--ease);
}

.project-list__preview.is-visible .project-list__preview-image {
  transform: scale(1);
}

@media (hover: none), (pointer: coarse) {
  .project-list__preview {
    display: none;
  }
}
